feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化: - 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId 退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准 (countVersion 单调),大图与格子双入口; - WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、 重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤 (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡; photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管; - 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调, 可见时上报、requestId 持久幂等、网络失败指数重试; - 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换; 顶栏「减少动画」偏好开关; - 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带 internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃); - e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/ __viewerBubbleMs 与 .activity[data-connection] 连接态锚。 vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、 路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、 journey 补互动腿断言)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// apps/viewer/src/realtime/router.ts —— 服务端消息路由(P2 Task 10)。
|
||||
// 纯函数 + 依赖注入:帧语义集中一处,单测直测(自身过滤/隐藏暂停/减少动画)。
|
||||
//
|
||||
// likes.burst 自身过滤(A21 客户端侧):originBatchIds 与本会话已提交批次求交
|
||||
// 得己方 delta——己方贡献不播气泡(点击时已见过动画),他人贡献照常;total
|
||||
// 恒为权威值,无论过滤与否都校准。页面不可见(visibilitychange)或「减少动画」
|
||||
// 开启时跳过气泡(队列即弃——回台只看得到校准后的数字),计数校准不受影响。
|
||||
import type { ServerMessage } from '@photo/contracts';
|
||||
|
||||
export interface RealtimeRouterDeps {
|
||||
/** 页面当前是否不可见(document.hidden)。 */
|
||||
isHidden(): boolean;
|
||||
/** 「减少动画」偏好是否开启。 */
|
||||
reducedMotion(): boolean;
|
||||
/** originBatchIds 中属于本会话已提交批次的 delta 合计。 */
|
||||
ownDeltaIn(batchIds: string[]): number;
|
||||
/** 点赞计数权威校准(版本化)。 */
|
||||
calibrateLike(photoId: string, total: number, countVersion: number): void;
|
||||
/** 他人点赞气泡(photoId + 他人贡献 delta)。 */
|
||||
showBubble(photoId: string, delta: number): void;
|
||||
/** 新照片到达:亮提示条(不自动插入)。 */
|
||||
notifyNewPhotos(): void;
|
||||
/** 照片隐藏/删除:移出序列(调用方负责大图收口与提示)。 */
|
||||
removePhoto(photoId: string): void;
|
||||
/** 相册浏览数更新(stats.updated)。 */
|
||||
setViewCount(viewCount: number): void;
|
||||
}
|
||||
|
||||
export function handleServerMessage(message: ServerMessage, deps: RealtimeRouterDeps): void {
|
||||
switch (message.type) {
|
||||
case 'pong':
|
||||
return; // 心跳应答:无动作
|
||||
case 'error':
|
||||
// subscribe 校验失败等:连接保持,服务端已说明——本地备注即可。
|
||||
console.info(`[viewer] realtime error frame: ${message.code}`);
|
||||
return;
|
||||
case 'photo.published':
|
||||
deps.notifyNewPhotos();
|
||||
return;
|
||||
case 'photo.hidden':
|
||||
case 'photo.deleted':
|
||||
deps.removePhoto(message.photoId);
|
||||
return;
|
||||
case 'likes.burst': {
|
||||
const own = deps.ownDeltaIn(message.originBatchIds);
|
||||
// 校准无条件执行(含纯己方 burst——批次响应丢失时这是唯一权威回执)。
|
||||
deps.calibrateLike(message.photoId, message.total, message.countVersion);
|
||||
const others = message.delta - own;
|
||||
if (others > 0 && !deps.isHidden() && !deps.reducedMotion()) {
|
||||
deps.showBubble(message.photoId, others);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'stats.updated':
|
||||
deps.setViewCount(message.viewCount);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// apps/viewer/src/realtime/useAlbumRealtime.ts —— 相册页实时编排(P2 Task 10)。
|
||||
// 组装 RealtimeClient + router + 降级轮询 + 气泡生命周期:
|
||||
//
|
||||
// · 挂载即连 WS(URL 由 API 基址推导),subscribe 当前 token;
|
||||
// · 重连成功(onResync)→ 第 1 页重取校准:点赞计数(likes.recalibrate-
|
||||
// FromList)+ 列表变化(新照片亮提示条;**筛选未变时**缺失照片移出序列
|
||||
// ——排序/分类切换本身会换第 1 页集合,不视为移除);
|
||||
// · 连续 3 次失败 → degraded:8s 轮询同套校准(无气泡补播——历史气泡不
|
||||
// 重放,只对齐数字与列表);恢复 open 即停轮询;
|
||||
// · 气泡:他人 likes.burst(router 已滤己方)→ 底部浮层(缩略图 + delta),
|
||||
// 点击打开该照片大图;约 4s 自动消散,至多 3 枚;页面隐藏即清空并暂停
|
||||
// (回台只看得到校准后的计数)。
|
||||
//
|
||||
// e2e 注入点(沿用 sequence __viewerPollMs 约定):__viewerRealtimePollMs /
|
||||
// __viewerWsRetryBaseMs / __viewerBubbleMs。
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from 'vue';
|
||||
import { apiBase, type PublicPhotoList } from '../api/client';
|
||||
import type { LikesController } from '../likes/useLikes';
|
||||
import { containsNewPhotoId } from '../feed/layout';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import { reducedMotion } from '../ui/prefs';
|
||||
import { handleServerMessage } from './router';
|
||||
import { RealtimeClient, wsUrlFromApiBase, type RealtimeState } from './ws';
|
||||
|
||||
/** 降级轮询间隔(毫秒)。 */
|
||||
export const DEGRADED_POLL_MS = 8000;
|
||||
/** 气泡驻留时长(毫秒)。 */
|
||||
export const BUBBLE_MS = 4000;
|
||||
/** 同屏气泡上限(超出弃最旧)。 */
|
||||
export const BUBBLE_CAP = 3;
|
||||
|
||||
export interface LikeBubble {
|
||||
key: number;
|
||||
photoId: string;
|
||||
delta: number;
|
||||
thumbUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AlbumRealtimeInputs {
|
||||
token: Ref<string>;
|
||||
sequence: PhotoSequence;
|
||||
likes: LikesController;
|
||||
/** 当前筛选下的第 1 页取数(token/locale/category/sort 由调用方组装)。 */
|
||||
fetchFirstPage: () => Promise<PublicPhotoList>;
|
||||
/** 筛选指纹(token+locale+category+sort):变化时跳过移除检测(换集合非下线)。 */
|
||||
filtersKey: () => string;
|
||||
/** 照片被推送移除(hidden/deleted):neighborIndex 为移除前下标(大图回退邻居)。 */
|
||||
onPhotoRemoved: (photoId: string, neighborIndex: number) => void;
|
||||
setViewCount: (viewCount: number) => void;
|
||||
}
|
||||
|
||||
function overrideNumber(key: string): number | null {
|
||||
const value = (globalThis as unknown as Record<string, unknown>)[key];
|
||||
return typeof value === 'number' && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** e2e 注入 WS 目标(空串/未置 → 真实推导 URL);逐次连接求值以便运行中切换。 */
|
||||
function overrideWsUrl(): string | null {
|
||||
const value = (globalThis as unknown as Record<string, unknown>).__viewerWsUrl;
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
export function useAlbumRealtime(inputs: AlbumRealtimeInputs) {
|
||||
const connection = ref<RealtimeState>('connecting');
|
||||
const bubbles = ref<LikeBubble[]>([]);
|
||||
|
||||
let client: RealtimeClient | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const bubbleTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
let bubbleKey = 0;
|
||||
/** 校准竞态令牌:仅最新一次重取可落地。 */
|
||||
let calibrationSequence = 0;
|
||||
/** 上轮第 1 页集合(移除检测基准)。 */
|
||||
let lastFiltersKey = '';
|
||||
let lastFirstPageIds = new Set<string>();
|
||||
|
||||
function pollIntervalMs(): number {
|
||||
return overrideNumber('__viewerRealtimePollMs') ?? DEGRADED_POLL_MS;
|
||||
}
|
||||
|
||||
// —— 气泡 ——
|
||||
|
||||
function showBubble(photoId: string, delta: number): void {
|
||||
const photo = inputs.sequence.photos.find((item) => item.photoId === photoId);
|
||||
const bubble: LikeBubble = {
|
||||
key: ++bubbleKey,
|
||||
photoId,
|
||||
delta,
|
||||
thumbUrl: photo?.thumb.url ?? null,
|
||||
};
|
||||
bubbles.value = [...bubbles.value.slice(-(BUBBLE_CAP - 1)), bubble];
|
||||
const timer = setTimeout(() => {
|
||||
bubbleTimers.delete(timer);
|
||||
bubbles.value = bubbles.value.filter((item) => item.key !== bubble.key);
|
||||
}, overrideNumber('__viewerBubbleMs') ?? BUBBLE_MS);
|
||||
bubbleTimers.add(timer);
|
||||
}
|
||||
|
||||
function dismissBubble(key: number): void {
|
||||
bubbles.value = bubbles.value.filter((item) => item.key !== key);
|
||||
}
|
||||
|
||||
// —— 校准(重连 resync 与降级轮询共用) ——
|
||||
|
||||
async function recalibrate(): Promise<void> {
|
||||
const run = ++calibrationSequence;
|
||||
try {
|
||||
const page = await inputs.fetchFirstPage();
|
||||
if (run !== calibrationSequence) return;
|
||||
const pageIds = new Set(page.items.map((item) => item.photoId));
|
||||
inputs.likes.recalibrateFromList(page.items);
|
||||
if (containsNewPhotoId(inputs.sequence.photos.map((photo) => photo.photoId), page.items)) {
|
||||
inputs.sequence.hasNew = true;
|
||||
}
|
||||
const key = inputs.filtersKey();
|
||||
if (key === lastFiltersKey) {
|
||||
// 筛选未变而第 1 页缺席 → 已下线:移出序列并通知(大图收口在调用方)。
|
||||
for (const id of lastFirstPageIds) {
|
||||
if (!pageIds.has(id) && inputs.sequence.indexOf(id) !== -1) {
|
||||
const neighborIndex = inputs.sequence.indexOf(id);
|
||||
inputs.sequence.removePhoto(id);
|
||||
inputs.onPhotoRemoved(id, neighborIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastFiltersKey = key;
|
||||
lastFirstPageIds = pageIds;
|
||||
} catch {
|
||||
// 校准取数失败静默:下一轮(重连/轮询)再试。
|
||||
}
|
||||
}
|
||||
|
||||
// —— 降级轮询 ——
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (pollTimer !== null) return;
|
||||
void recalibrate();
|
||||
pollTimer = setInterval(() => void recalibrate(), pollIntervalMs());
|
||||
}
|
||||
|
||||
// —— WS 客户端 ——
|
||||
|
||||
function stopClient(): void {
|
||||
client?.stop();
|
||||
client = null;
|
||||
stopPolling();
|
||||
connection.value = 'connecting';
|
||||
}
|
||||
|
||||
function startClient(): void {
|
||||
stopClient();
|
||||
client = new RealtimeClient({
|
||||
url: () => overrideWsUrl() ?? wsUrlFromApiBase(apiBase()),
|
||||
albumToken: () => inputs.token.value,
|
||||
retryBaseMs: overrideNumber('__viewerWsRetryBaseMs') ?? undefined,
|
||||
onMessage: (message) =>
|
||||
handleServerMessage(message, {
|
||||
isHidden: () => typeof document !== 'undefined' && document.hidden,
|
||||
reducedMotion: () => reducedMotion.value,
|
||||
ownDeltaIn: (batchIds) => inputs.likes.ownDeltaIn(batchIds),
|
||||
calibrateLike: (photoId, total, countVersion) =>
|
||||
inputs.likes.calibrate(photoId, total, countVersion),
|
||||
showBubble,
|
||||
notifyNewPhotos: () => {
|
||||
inputs.sequence.hasNew = true;
|
||||
},
|
||||
removePhoto: (photoId) => {
|
||||
const neighborIndex = inputs.sequence.indexOf(photoId);
|
||||
inputs.sequence.removePhoto(photoId);
|
||||
inputs.onPhotoRemoved(photoId, neighborIndex);
|
||||
},
|
||||
setViewCount: inputs.setViewCount,
|
||||
}),
|
||||
onStateChange: (state) => {
|
||||
connection.value = state;
|
||||
if (state === 'degraded') startPolling();
|
||||
else stopPolling();
|
||||
},
|
||||
onResync: () => void recalibrate(),
|
||||
});
|
||||
client.start();
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.hidden) {
|
||||
// 不可见:暂停动画——在屏气泡清空(不排队补播),计数校准照常。
|
||||
bubbles.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startClient();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopClient();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
for (const timer of bubbleTimers) clearTimeout(timer);
|
||||
bubbleTimers.clear();
|
||||
bubbles.value = [];
|
||||
});
|
||||
|
||||
// token 变化(相册间跳转):换新连接(退订旧房间由断链完成)+ 状态复位。
|
||||
watch(inputs.token, () => {
|
||||
lastFiltersKey = '';
|
||||
lastFirstPageIds = new Set();
|
||||
bubbles.value = [];
|
||||
startClient();
|
||||
});
|
||||
|
||||
// reactive 包装:模板/脚本访问 realtime.bubbles 等 ref 自动解包(同 sequence 约定)。
|
||||
return reactive({ connection, bubbles, dismissBubble });
|
||||
}
|
||||
|
||||
export type AlbumRealtime = ReturnType<typeof useAlbumRealtime>;
|
||||
@@ -0,0 +1,209 @@
|
||||
// apps/viewer/src/realtime/ws.ts —— WS 客户端(P2 Task 10,契约帧见
|
||||
// packages/contracts/src/realtime.ts)。与 Vue 解耦的纯状态机,便于单测:
|
||||
//
|
||||
// start → connecting(连 ov /v1/realtime,URL 由 API 基址 http→ws 推导)
|
||||
// open → 发 subscribe{albumToken} + 周期 ping(HEARTBEAT_MS/2,服务端
|
||||
// 20s 无心跳踢链,半频留裕量)
|
||||
// close/error → 指数退避重连(retryBaseMs 1s→2s→4s,8s 封顶);**重连成功
|
||||
// 即 onResync()**(订阅可能错过断线期事件——调用方做第 1 页重取
|
||||
// 校准);连续 3 次失败 → degraded(调用方启动轮询降级;本客户端
|
||||
// 仍按封顶间隔后台重试,恢复后回 open)。
|
||||
// stop → 幂等收尾(关连接、清全部定时器)。
|
||||
//
|
||||
// 工厂注入 socketFactory:单测用假 WebSocket;运行时缺省浏览器原生。
|
||||
// 浏览器面必须走 ./realtime 子路径:桶出口 index 会连带 internal-sync
|
||||
// (node:crypto createHmac),进浏览器包即被 Vite 外置化而整页崩溃。
|
||||
import {
|
||||
HEARTBEAT_MS,
|
||||
REALTIME_WS_PATH,
|
||||
type ServerMessage,
|
||||
} from '@photo/contracts/realtime';
|
||||
|
||||
/** 连接状态:open=正常;degraded=连续失败进入降级(轮询接管)。 */
|
||||
export type RealtimeState = 'connecting' | 'open' | 'degraded';
|
||||
|
||||
/** 重连退避封顶(毫秒)。 */
|
||||
export const RECONNECT_CAP_MS = 8000;
|
||||
/** 连续失败多少次后进入 degraded(轮询降级阈值)。 */
|
||||
export const DEGRADE_AFTER_FAILURES = 3;
|
||||
|
||||
/** 浏览器 WebSocket 的最小使用面(测试假件只需实现这些)。 */
|
||||
export interface WebSocketLike {
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void;
|
||||
}
|
||||
|
||||
export type WebSocketFactory = (url: string) => WebSocketLike;
|
||||
|
||||
export interface RealtimeClientOptions {
|
||||
/** 目标 URL:静态串或逐次连接时求值的 getter(e2e 注入切换存活性)。 */
|
||||
url: string | (() => string);
|
||||
/** 相册 token 延迟求值:重连时重新读取(相册间跳转后重挂新客户端)。 */
|
||||
albumToken: () => string;
|
||||
onMessage: (message: ServerMessage) => void;
|
||||
onStateChange: (state: RealtimeState) => void;
|
||||
/** 断线后重连成功(服务端已重订):调用方做第 1 页校准。 */
|
||||
onResync: () => void;
|
||||
socketFactory?: WebSocketFactory;
|
||||
/** 重连退避基值(毫秒,缺省 1000;e2e 可注入调短)。 */
|
||||
retryBaseMs?: number;
|
||||
/** 应用层 ping 间隔(毫秒,缺省 HEARTBEAT_MS/2)。 */
|
||||
pingMs?: number;
|
||||
}
|
||||
|
||||
/** API 基址 → WS URL:http→ws / https→wss,拼契约路径;空基址(同源部署)
|
||||
* 按当前页面 origin 推导。 */
|
||||
export function wsUrlFromApiBase(apiBase: string): string {
|
||||
let origin = apiBase.replace(/\/+$/, '');
|
||||
if (origin === '') {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
origin = `${proto}//${window.location.host}`;
|
||||
} else {
|
||||
origin = origin.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
|
||||
}
|
||||
return `${origin}${REALTIME_WS_PATH}`;
|
||||
}
|
||||
|
||||
export class RealtimeClient {
|
||||
private readonly options: RealtimeClientOptions;
|
||||
private socket: WebSocketLike | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private failures = 0;
|
||||
private hadOpenSession = false;
|
||||
private stopped = false;
|
||||
private state: RealtimeState = 'connecting';
|
||||
|
||||
constructor(options: RealtimeClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private get retryBaseMs(): number {
|
||||
return this.options.retryBaseMs ?? 1000;
|
||||
}
|
||||
|
||||
private get pingMs(): number {
|
||||
return this.options.pingMs ?? Math.ceil(HEARTBEAT_MS / 2);
|
||||
}
|
||||
|
||||
private setState(state: RealtimeState): void {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
this.options.onStateChange(state);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stopped = false;
|
||||
this.setState('connecting');
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (this.stopped) return;
|
||||
const url = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
|
||||
const factory = this.options.socketFactory ?? ((target: string) => new WebSocket(target) as unknown as WebSocketLike);
|
||||
let socket: WebSocketLike;
|
||||
try {
|
||||
socket = factory(url);
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.socket = socket;
|
||||
socket.addEventListener('open', () => this.onOpen());
|
||||
socket.addEventListener('error', () => {
|
||||
/* 握手/链路错误随后必有 close——统一在 onClosed 处理重连 */
|
||||
});
|
||||
socket.addEventListener('message', (event: unknown) => this.onMessage(event));
|
||||
socket.addEventListener('close', () => this.onClosed());
|
||||
}
|
||||
|
||||
private onOpen(): void {
|
||||
if (this.stopped || this.socket === null) return;
|
||||
const resync = this.hadOpenSession;
|
||||
this.hadOpenSession = true;
|
||||
this.failures = 0;
|
||||
this.setState('open');
|
||||
this.send({ type: 'subscribe', albumToken: this.options.albumToken() });
|
||||
if (resync) this.options.onResync();
|
||||
if (this.pingTimer === null) {
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.send({ type: 'ping', ts: Date.now() });
|
||||
}, this.pingMs);
|
||||
}
|
||||
}
|
||||
|
||||
private onMessage(event: unknown): void {
|
||||
let data: unknown;
|
||||
if (typeof MessageEvent !== 'undefined' && event instanceof MessageEvent) {
|
||||
data = event.data;
|
||||
} else {
|
||||
// 假件直传字符串(测试环境无 MessageEvent 构造)。
|
||||
data = (event as { data?: unknown }).data;
|
||||
}
|
||||
if (typeof data !== 'string') return;
|
||||
let message: ServerMessage;
|
||||
try {
|
||||
message = JSON.parse(data) as ServerMessage;
|
||||
} catch {
|
||||
return; // 非 JSON 帧静默丢弃
|
||||
}
|
||||
if (typeof message !== 'object' || message === null || typeof message.type !== 'string') return;
|
||||
this.options.onMessage(message);
|
||||
}
|
||||
|
||||
private onClosed(): void {
|
||||
this.stopPing();
|
||||
this.socket = null;
|
||||
if (this.stopped) return;
|
||||
this.failures += 1;
|
||||
if (this.failures >= DEGRADE_AFTER_FAILURES) this.setState('degraded');
|
||||
else this.setState('connecting');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopped || this.reconnectTimer !== null) return;
|
||||
const exp = Math.min(this.failures, 3) - 1; // 1s→2s→4s,其后封顶
|
||||
const delay = Math.min(this.retryBaseMs * 2 ** Math.max(exp, 0), RECONNECT_CAP_MS);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private send(frame: unknown): void {
|
||||
if (this.socket === null) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
} catch {
|
||||
// 发送缓冲异常(CLOSING 态):等 close 事件走重连。
|
||||
}
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer !== null) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.stopPing();
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
if (socket !== null) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// 已在关闭中
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user