refactor(前端): websocket 合并三层继承与三份重复 composable,803 → 660 行
Some checks failed
Deploy / deploy (push) Has been cancelled

## 删掉的

- **六个从没人传过的配置项。** WebSocketConfig 上的 maxReconnectAttempts /
  reconnectDelay / maxReconnectDelay / heartbeatTime / enableHeartbeat /
  enableAutoReconnect,六个默认值、六处 ??,而三条通道从建起来到现在没有一个调用方
  传过任何一个,也没在运行时改过。收成三个模块常量,连带三处恒真/恒不触发的分支
  (if (enableHeartbeat)、!enableAutoReconnect、reconnectAttempts >= Infinity)。
- **一个没人用的工厂。** createWebSocketComposable 顶着二十五行 @example,示例里那条
  通知通道并不存在,三个真实 composable 一个都没调它,各自手写一遍转发对象。
- **三个空壳子类。** SubmissionWebSocket / FlowchartWebSocket / ConfigWebSocket 的全部
  内容就是在构造函数里拼 URL;前两个连 URL 都是同一条 /ws/submissions,两个类加两份
  三十行 composable 的差异只有一个类型参数。改成 useChannel(path, handler) 一个工厂,
  三个 useXxxWebSocket 各自只剩一行。
- SubscribingWebSocket 这一层并进基类:三层继承变两层,只剩 CollabWebSocket 还在扩展
  (它确有自己的二进制缓冲与房间语义)。URL 拼接收进 channelUrl()。

行为一处没动:重连仍然不封顶(后端 deploy 重启一次就超过原来那 15 秒,这条注释保留)、
指数退避带抖动、30 秒上限、心跳 30 秒、force_logout、断线重连补订阅全部照旧。

## 验证

起全栈用浏览器实跑:

- 提交一发 Python3,/ws/submissions 连上并推回 judging → judging → finished,
  控制台没有出现「WebSocket未及时响应,启动轮询保底」,即结果确实走的 WS 而非轮询;
- kill 掉后端观察三条通道重连:682ms → 1.4s → 2.4s → 4.4s → …… → 15~26s 封顶,
  指数退避与 30 秒上限均符合;
- 后端起回来后**不刷新页面**自动恢复(/ws/config、/ws/collab 各自重连成功),
  再提交一发仍然全程走 WS —— 这正是「重连不封顶」要保的场景;
- vue-tsc --noEmit exit 0;vite build 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
2026-09-10 18:25:47 -06:00
parent ab47e71d6f
commit 8520192a98

View File

@@ -47,27 +47,23 @@ export interface WebSocketMessage {
} }
/** /**
* WebSocket 配置 * 重连与心跳的参数。
*
* 原来这三个(连同「最大重连次数」「是否心跳」「是否自动重连」)是构造函数上的六个
* 可选项,六个默认值、六处 `??` —— 而三条通道从建起来到现在,**没有一个调用方传过
* 任何一个**,也从没在运行时改过。所以它们是常量,不是配置。
*
* **重连不封顶**是有意的:原来默认 5 次、线性退避,加起来只有 15 秒 —— 后端 deploy
* 重启一次就超了,之后这条连接死到用户刷新页面为止。机房网络抖动同理。
*/ */
export interface WebSocketConfig { const RECONNECT_BASE_DELAY = 1000
/** 完整 URL。后端只认 /ws/submissions 和 /ws/config 两条,按当前页面的协议与 host 拼 */ const RECONNECT_MAX_DELAY = 30_000
url: string const HEARTBEAT_INTERVAL = 30_000
/**
* 最大重连次数,默认不限。 /** 按当前页面的协议与 host 拼通道地址。后端只认 /ws/submissions、/ws/config、/ws/collab */
* 原来默认 5 次、线性退避,加起来只有 15 秒 —— 后端 deploy 重启一次就超了, function channelUrl(path: string) {
* 之后这条连接死到用户刷新页面为止。机房网络抖动同理,所以默认不再封顶。 const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
*/ return `${protocol}//${window.location.host}${path}`
maxReconnectAttempts?: number
/** 首次重连延迟(毫秒),默认 1000。之后指数退避 */
reconnectDelay?: number
/** 重连延迟上限(毫秒),默认 30000 */
maxReconnectDelay?: number
/** 心跳间隔(毫秒),默认 3000030秒 */
heartbeatTime?: number
/** 是否启用心跳,默认 true */
enableHeartbeat?: boolean
/** 是否启用自动重连,默认 true */
enableAutoReconnect?: boolean
} }
/** /**
@@ -86,34 +82,22 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
protected url: string protected url: string
protected handlers: Set<MessageHandler<T>> = new Set() protected handlers: Set<MessageHandler<T>> = new Set()
protected reconnectAttempts = 0 protected reconnectAttempts = 0
protected maxReconnectAttempts: number
protected reconnectDelay: number
protected heartbeatInterval: number | null = null protected heartbeatInterval: number | null = null
protected heartbeatTime: number
protected enableHeartbeat: boolean
protected enableAutoReconnect: boolean
protected maxReconnectDelay: number
protected disconnectTimer: number | null = null protected disconnectTimer: number | null = null
protected reconnectTimer: number | null = null protected reconnectTimer: number | null = null
/** /**
* 「用户主动断开」的意图,和 enableAutoReconnect 这个**配置**分开存。 * 「用户主动断开」的意图。这是**唯一**能阻止自动重连的东西 ——
* 以前两者共用一个字段disconnect() 把配置改成 false 来阻止重连,而 connect() * 以前它和一个 enableAutoReconnect 配置项共用一个字段disconnect() 把配置改成
* 从不改回 true —— 登出再登录后,这条连接就永远失去了自动重连能力。 * false 来阻止重连,而 connect() 从不改回 true —— 登出再登录后,这条连接就永远
* 失去了自动重连能力。
*/ */
protected closedByUser = false protected closedByUser = false
protected reviveBound = false protected reviveBound = false
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected") public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
constructor(config: WebSocketConfig) { constructor(path: string) {
this.url = config.url this.url = channelUrl(path)
this.maxReconnectAttempts = config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY
this.reconnectDelay = config.reconnectDelay ?? 1000
this.maxReconnectDelay = config.maxReconnectDelay ?? 30000
this.heartbeatTime = config.heartbeatTime ?? 30000
this.enableHeartbeat = config.enableHeartbeat ?? true
this.enableAutoReconnect = config.enableAutoReconnect ?? true
} }
/** /**
@@ -147,9 +131,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
this.status.value = "connected" this.status.value = "connected"
this.reconnectAttempts = 0 this.reconnectAttempts = 0
console.log(`[WebSocket] 连接成功: ${this.url}`) console.log(`[WebSocket] 连接成功: ${this.url}`)
if (this.enableHeartbeat) { this.startHeartbeat()
this.startHeartbeat()
}
this.onConnected() this.onConnected()
} }
@@ -220,14 +202,13 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
* 别在同一毫秒一起冲回来把刚起来的后端再压趴一次。 * 别在同一毫秒一起冲回来把刚起来的后端再压趴一次。
*/ */
protected scheduleReconnect() { protected scheduleReconnect() {
if (this.closedByUser || !this.enableAutoReconnect) return if (this.closedByUser) return
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
if (this.reconnectTimer !== null) return if (this.reconnectTimer !== null) return
this.reconnectAttempts++ this.reconnectAttempts++
const base = Math.min( const base = Math.min(
this.reconnectDelay * 2 ** (this.reconnectAttempts - 1), RECONNECT_BASE_DELAY * 2 ** (this.reconnectAttempts - 1),
this.maxReconnectDelay, RECONNECT_MAX_DELAY,
) )
const delay = Math.round(base * (0.5 + Math.random() * 0.5)) const delay = Math.round(base * (0.5 + Math.random() * 0.5))
console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`) console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
@@ -249,7 +230,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
* 退避到 30 秒后,用户切回页面却还要再干等半分钟是说不过去的。 * 退避到 30 秒后,用户切回页面却还要再干等半分钟是说不过去的。
*/ */
protected readonly revive = () => { protected readonly revive = () => {
if (this.closedByUser || !this.enableAutoReconnect) return if (this.closedByUser) return
if ( if (
this.ws && this.ws &&
(this.ws.readyState === WebSocket.OPEN || (this.ws.readyState === WebSocket.OPEN ||
@@ -310,9 +291,9 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
this.disconnectTimer = null this.disconnectTimer = null
const minutes = Math.floor(delay / 60000) const minutes = Math.floor(delay / 60000)
console.log(`WebSocket idle for ${minutes} minutes, disconnecting...`) console.log(`WebSocket idle for ${minutes} minutes, disconnecting...`)
// 这里**只断开**。原来断完紧接着一句 `enableAutoReconnect = true` // 这里**只断开**。原来断完紧接着一句 `enableAutoReconnect = true`(那个配置项
// 而 close 是异步的 —— 等 onclose 跑到时标志已经翻回来了,于是 1 秒后 // 已经不存在了),而 close 是异步的 —— 等 onclose 跑到时标志已经翻回来了,
// 又自动连上:这个「省资源」的空闲断开从来没有真正生效过。 // 于是 1 秒后又自动连上:这个「省资源」的空闲断开从来没有真正生效过。
// 下一次 connect()(新提交)会自己把 closedByUser 清掉,不需要在这里预置。 // 下一次 connect()(新提交)会自己把 closedByUser 清掉,不需要在这里预置。
this.disconnect() this.disconnect()
}, delay) }, delay)
@@ -392,7 +373,7 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
this.stopHeartbeat() this.stopHeartbeat()
this.heartbeatInterval = window.setInterval(() => { this.heartbeatInterval = window.setInterval(() => {
this.sendHeartbeat() this.sendHeartbeat()
}, this.heartbeatTime) }, HEARTBEAT_INTERVAL)
} }
/** /**
@@ -405,13 +386,6 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
} }
} }
/**
* 连接成功钩子(子类可重写)
*/
protected onConnected() {
// 子类实现
}
/** /**
* 断开连接钩子(子类可重写) * 断开连接钩子(子类可重写)
*/ */
@@ -426,6 +400,45 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
// 子类实现 // 子类实现
} }
/**
* 当前在等结果的提交。**一直留着**,直到调用方 unsubscribe()。
*
* 原来这个字段叫 pendingSubmissionId订阅一发成功就清空 —— 它只解决了
* 「还没连上就调 subscribe」没解决断线重连。而真正会丢结果的恰恰是后者
* 服务端收到 subscribe 会回一份当前状态,掉线期间错过的那条推送就是靠这次
* 重放补回来的。不重新订阅,重连后就只收得到「将来」的事件,可结果已经是过去式了。
*/
private subscribedId = ""
/**
* 订阅特定提交的更新。连接没就绪也可以调,连上后会自动补发 ——
* 调用方不用关心此刻连上没有,断线重连后也会自动重新订阅。
*
* 只有 /ws/submissions 用得上;/ws/config 是单向广播、/ws/collab 自己有房间语义,
* 它们不调这两个方法subscribedId 一直是空串onConnected 里那一行就是空转。
*/
subscribe(submissionId: string) {
this.subscribedId = submissionId
this.sendSubscribe(submissionId)
}
/** 结果已经拿到,重连后不必再问一遍 */
unsubscribe() {
this.subscribedId = ""
}
private sendSubscribe(submissionId: string) {
return this.send({ type: "subscribe", submissionId })
}
/**
* 连接成功钩子。子类重写时**必须先调 super.onConnected()**,否则断线重连后
* 不会补订阅,那次判题的结果就再也回不来。
*/
protected onConnected() {
if (this.subscribedId) this.sendSubscribe(this.subscribedId)
}
/** /**
* 消息处理钩子(子类可重写) * 消息处理钩子(子类可重写)
*/ */
@@ -453,79 +466,28 @@ export interface SubmissionUpdate extends WebSocketMessage {
} }
/** /**
* 带「订阅意图」的连接 * 三条通道共用的 composable
* *
* subscribe() 在连接还没就绪时先把 id 记下来,等 onConnected() 补发 —— 调用方 * 原来每条通道各有一个空壳子类(只在构造函数里拼 URL加一个三十行的 composable
* 不用关心此刻连上没有,断线重连后也会自动重新订阅。 * 而三份 composable 除了类型参数逐字相同 —— `SubmissionWebSocket` 和
* `FlowchartWebSocket` 甚至连 URL 都是同一条 `/ws/submissions`。中间还搁着一个
* 带二十五行示例注释的 `createWebSocketComposable` 工厂,示例里那条通知通道并不
* 存在,三个真实的 composable 一个都没用它。现在只剩这一个。
* *
* 原来这套只有 SubmissionWebSocket 有FlowchartWebSocket 是 send 失败就打一行 * 每次调用都新建一条连接(和原来一致,不是单例),并在组件卸载时摘掉 handler、断开。
* 日志了事socket 一掉,那次评分的结果就再也回不来,页面永远转圈。提到基类上,
* 两条通道共用同一套语义。
*/ */
class SubscribingWebSocket< function useChannel<T extends WebSocketMessage>(
T extends WebSocketMessage, path: string,
> extends BaseWebSocket<T> { handler?: MessageHandler<T>,
/**
* 当前在等结果的提交。**一直留着**,直到调用方 unsubscribe()。
*
* 原来这个字段叫 pendingSubmissionId订阅一发成功就清空 —— 它只解决了
* 「还没连上就调 subscribe」没解决断线重连。而真正会丢结果的恰恰是后者
* 服务端收到 subscribe 会回一份当前状态,掉线期间错过的那条推送就是靠这次
* 重放补回来的。不重新订阅,重连后就只收得到「将来」的事件,可结果已经是过去式了。
*/
private subscribedId = ""
/**
* 订阅特定提交的更新。连接没就绪也可以调,连上后会自动补发。
*/
subscribe(submissionId: string) {
this.subscribedId = submissionId
this.sendSubscribe(submissionId)
}
/** 结果已经拿到,重连后不必再问一遍 */
unsubscribe() {
this.subscribedId = ""
}
protected onConnected() {
if (this.subscribedId) this.sendSubscribe(this.subscribedId)
}
private sendSubscribe(submissionId: string) {
return this.send({ type: "subscribe", submissionId })
}
}
/**
* 提交 WebSocket 连接管理类
*/
class SubmissionWebSocket extends SubscribingWebSocket<SubmissionUpdate> {
constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ url: `${protocol}//${window.location.host}/ws/submissions` })
}
}
/**
* 用于组件中使用 WebSocket 的 Composable
* 每次调用创建新的 WebSocket 实例
*/
export function useSubmissionWebSocket(
handler?: MessageHandler<SubmissionUpdate>,
) { ) {
const ws = new SubmissionWebSocket() const ws = new BaseWebSocket<T>(path)
// 如果提供了处理器,添加到实例中 // 同步注册,不放 onMounted调用方如 useConfigUpdate在 setup 阶段就 connect()
if (handler) { // 中间那段窗口收到的广播没有任何 handler 接。窗口极小,但没有理由留着它。
ws.addHandler(handler) if (handler) ws.addHandler(handler)
}
// 组件卸载时清理资源
onUnmounted(() => { onUnmounted(() => {
if (handler) { if (handler) ws.removeHandler(handler)
ws.removeHandler(handler)
}
ws.disconnect() ws.disconnect()
}) })
@@ -537,63 +499,25 @@ export function useSubmissionWebSocket(
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay), scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(), cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
status: ws.status, status: ws.status,
addHandler: (h: MessageHandler<SubmissionUpdate>) => ws.addHandler(h), addHandler: (h: MessageHandler<T>) => ws.addHandler(h),
removeHandler: (h: MessageHandler<SubmissionUpdate>) => ws.removeHandler(h), removeHandler: (h: MessageHandler<T>) => ws.removeHandler(h),
} }
} }
/** /**
* 通用 WebSocket Composable 工厂函数 * 提交状态更新的数据类型
* 用于创建自定义的 WebSocket composable
*
* @example
* ```ts
* // 创建通知 WebSocket
* interface NotificationMessage extends WebSocketMessage {
* type: 'notification'
* title: string
* content: string
* }
*
* class NotificationWebSocket extends BaseWebSocket<NotificationMessage> {
* constructor() {
* const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
* super({ url: `${protocol}//${window.location.host}/ws/notifications` })
* }
* }
*
* let notificationWs: NotificationWebSocket | null = null
*
* export function useNotificationWebSocket(handler?: MessageHandler<NotificationMessage>) {
* if (!notificationWs) {
* notificationWs = new NotificationWebSocket()
* }
* return createWebSocketComposable(notificationWs, handler)
* }
* ```
*/ */
export function createWebSocketComposable<T extends WebSocketMessage>( export interface SubmissionUpdate extends WebSocketMessage {
ws: BaseWebSocket<T>, type: "submission_update"
handler?: MessageHandler<T>, submissionId: string
) { result: number
if (handler) { status: "pending" | "judging" | "finished" | "error"
ws.addHandler(handler) score?: number
} }
onUnmounted(() => { /** 判题进度。subscribe(submissionId) 认领,断线重连会自动补订阅 */
if (handler) { export function useSubmissionWebSocket(handler?: MessageHandler<SubmissionUpdate>) {
ws.removeHandler(handler) return useChannel<SubmissionUpdate>("/ws/submissions", handler)
}
})
return {
connect: () => ws.connect(),
disconnect: () => ws.disconnect(),
send: (data: any) => ws.send(data),
status: ws.status,
addHandler: (h: MessageHandler<T>) => ws.addHandler(h),
removeHandler: (h: MessageHandler<T>) => ws.removeHandler(h),
}
} }
/** /**
@@ -614,49 +538,13 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
} }
/** /**
* 流程图 WebSocket 连接管理类 * 流程图评分进度。和判题走的是**同一条** `/ws/submissions` ——
*/ * 服务端按 submissionId 分辨是代码提交还是流程图,这里只是换一套消息类型。
class FlowchartWebSocket extends SubscribingWebSocket<FlowchartEvaluationUpdate> {
constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ url: `${protocol}//${window.location.host}/ws/submissions` })
}
}
/**
* 用于组件中使用流程图 WebSocket 的 Composable
*/ */
export function useFlowchartWebSocket( export function useFlowchartWebSocket(
handler?: MessageHandler<FlowchartEvaluationUpdate>, handler?: MessageHandler<FlowchartEvaluationUpdate>,
) { ) {
const ws = new FlowchartWebSocket() return useChannel<FlowchartEvaluationUpdate>("/ws/submissions", handler)
// 如果提供了处理器,添加到实例中
if (handler) {
ws.addHandler(handler)
}
// 组件卸载时清理资源
onUnmounted(() => {
if (handler) {
ws.removeHandler(handler)
}
ws.disconnect()
})
return {
connect: () => ws.connect(),
disconnect: () => ws.disconnect(),
subscribe: (submissionId: string) => ws.subscribe(submissionId),
unsubscribe: () => ws.unsubscribe(),
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
status: ws.status,
addHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
ws.addHandler(h),
removeHandler: (h: MessageHandler<FlowchartEvaluationUpdate>) =>
ws.removeHandler(h),
}
} }
/** /**
@@ -669,45 +557,13 @@ export interface ConfigUpdate extends WebSocketMessage {
} }
/** /**
* 配置 WebSocket 连接管理类 * 站点配置广播。这条通道是**单向**的:只收后端广播。服务端的消息处理只认
*/ * ping / subscribe客户端往这里推 config_update 会被回一个 error 帧 ——
class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> { * 别拿返回值里的 subscribe 往这条通道上发东西。配置变更走 POST /admin/website
constructor() { * 由后端广播给所有人。
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
super({ url: `${protocol}//${window.location.host}/ws/config` })
}
// 这条通道是**单向**的:只收后端广播。服务端的消息处理只认 ping / subscribe
// 客户端往这里推 config_update 会被回一个 error 帧 —— 别再加发送方法。
// 配置变更走 POST /admin/website由后端广播给所有人。
}
/**
* 用于组件中使用配置 WebSocket 的 Composable
*/ */
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) { export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
const ws = new ConfigWebSocket() return useChannel<ConfigUpdate>("/ws/config", handler)
// 同步注册,和另外两个 composable 一致。原来放在 onMounted 里,而调用方
// useConfigUpdate在 setup 阶段就 connect() 了 —— 中间那段窗口收到的广播
// 没有任何 handler 接。窗口极小,但没有任何理由留着它。
if (handler) {
ws.addHandler(handler)
}
onUnmounted(() => {
if (handler) {
ws.removeHandler(handler)
}
ws.disconnect()
})
return {
connect: () => ws.connect(),
disconnect: () => ws.disconnect(),
status: ws.status,
addHandler: (h: MessageHandler<ConfigUpdate>) => ws.addHandler(h),
removeHandler: (h: MessageHandler<ConfigUpdate>) => ws.removeHandler(h),
}
} }
export interface CollabRequestItem { export interface CollabRequestItem {
@@ -756,8 +612,7 @@ export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
private pendingBinary: ArrayBuffer[] = [] private pendingBinary: ArrayBuffer[] = []
constructor() { constructor() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" super("/ws/collab")
super({ url: `${protocol}//${window.location.host}/ws/collab` })
} }
setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) {
@@ -796,6 +651,8 @@ export class CollabWebSocket extends BaseWebSocket<CollabMessage> {
} }
protected override onConnected() { protected override onConnected() {
// 基类那一步是补订阅collab 从不调 subscribe(),这里是空转,但按约定照调不误
super.onConnected()
// 新连接,旧连接攒下的帧一概作废 // 新连接,旧连接攒下的帧一概作废
this.pendingBinary = [] this.pendingBinary = []
this.connectHandler?.() this.connectHandler?.()