本节只关心连接自身的状态
这一节不讨论握手细节、帧格式与关闭码,附录 A 已经覆盖;也不展开退避算法、抖动与连接风暴,第 7 章会专门处理。这里讨论的是端上那条连接从创建到销毁要经过哪些状态、哪些迁移合法、页面切后台时怎么处理、组件卸载时清理什么。产品能力如何随状态降级,留到 6.3。
很多客户端代码把连接写成两个布尔值:connected 与 connecting。短轮询时代这够用,因为每次请求都是独立的;长连接时代不够,因为「正在重连」和「首次连接」对用户、对写队列、对埋点的含义完全不同。连接不是事实,而是一台需要被观察和约束的状态机。
为什么布尔值不够
一个真实的长连接至少有六种可观察状态:还没开始、正在首次连接、已经打开、断开后等待重试、用户主动关闭、重试耗尽失败。用 connected: boolean 表示,会出现三个问题。
第一,UI 无法区分「首次连接」与「断线重连」。首次连接可以显示骨架屏,断线重连应该保留已有内容并显示一个轻提示。两者都写成 connected = false,产品只能统一显示「加载中」,用户会以为页面卡死。
第二,写操作无法决定该禁用还是排队。首次连接时用户还没开始输入,禁用是合理的;重连时用户可能已经写了一半,直接禁用会丢草稿。状态机把「为什么不可用」暴露出来,产品才有机会选择排队。
第三,非法事件无法被拒绝。例如连接还没开始就收到 opened,或者已经打开又收到 retry。布尔值只能被覆盖,状态机可以返回 false 并记录事件,让适配层的 bug 在测试里暴露,而不是在生产环境里变成「看起来在线、实际发不出消息」。
六个状态
| 状态 | 含义 | 进入方式 | 用户可见提示 |
|---|---|---|---|
idle | 尚未开始,或组件刚创建 | 初始状态 | 无 |
connecting | 首次连接或页面恢复后的连接中 | start、retry、show | 连接中 |
open | 通道可用,可以收发 | opened | 无 |
reconnecting | 连接丢失,等待下一次重试 | lost 且未超过上限 | 重连中 |
closed | 主动关闭、页面隐藏或已销毁 | close、hide、dispose | 离线或暂停 |
failed | 重试次数耗尽或遇到致命错误 | lost 超限、fail | 失败,可手动重试 |
closed 与 failed 的区别很重要。closed 是预期内的结束,例如用户退出页面、组件卸载、页面切后台;failed 是预期外的终止,需要产品给出「重试」入口。两者都不应该继续自动重连,但恢复方式不同:closed 可以由用户操作或页面可见性触发重新开始,failed 通常等待用户显式点击。
reconnecting 不是 connecting 的别名。它携带尝试次数与下一次重试时间,UI 可以据此显示「第 2 次重连」或延长提示;connecting 则可能是首次连接,也可能是重试真正发起后的短暂阶段。
合法迁移表
状态机的核心不是状态列表,而是「谁允许谁」。下表是本节实现接受的迁移:
| 当前状态 | 事件 | 目标状态 | 说明 |
|---|---|---|---|
idle | start | connecting | 首次连接 |
connecting | opened | open | 握手成功 |
connecting | lost | reconnecting | 连接中失败,进入退避 |
connecting | fail | failed | 鉴权失败等致命错误 |
connecting | close / hide | closed | 用户取消或页面隐藏 |
open | lost | reconnecting | 网络中断 |
open | fail | failed | 服务端要求终止 |
open | close / hide | closed | 主动关闭或切后台 |
reconnecting | retry | connecting | 到达重试时间 |
reconnecting | lost | failed | 超过最大尝试次数 |
reconnecting | close / hide | closed | 用户取消或切后台 |
closed | start | connecting | 手动重新开始 |
closed | show | connecting | 仅当因页面隐藏而关闭 |
failed | start | connecting | 用户点击重试 |
open 不能直接 start,idle 不能 opened,closed 不能 retry。这些看起来是显而易见的约束,但适配层经常在错误时机调用:socket 的 onopen 可能在组件已经卸载后触发,visibility 监听可能在连接已经失败后再次调用 show。状态机返回 false,比静默接受更能帮助定位问题。
事件不是日志,是钩子
每次迁移都会产出一个事件:from、to、reason、at、attempt。它不是给日志系统看的字符串,而是 UI、埋点、适配层共同的钩子。监听器在状态更新之后同步调用,因此读取 machine.state 一定拿到新状态。
钩子有三个约束。第一,监听器不能抛异常影响状态机;实现上应该由适配层自己包 try/catch,或者把副作用放到微任务里。第二,监听器不能同步调用会再次触发迁移的方法,否则会造成重入;例如在 closed 事件里立刻 start(),状态机会在事件分发过程中再次迁移,事件顺序变得难以推理。第三,监听器要能取消订阅,否则组件卸载后闭包仍持有旧 UI。
reason 的价值在于区分同一条边的不同原因。open -> closed 可能是用户点击关闭、页面隐藏或组件销毁;三者对「是否允许恢复」的答案不同。只记录 from 与 to,恢复逻辑只能靠猜。
重试时间只记录,不自己定时
状态机在 lost 时计算 nextRetryAt,但不持有 setTimeout。真正的定时器由调度层管理,到点后调用 retry()。这样拆分有两个好处:状态机是纯进程内逻辑,测试用假时钟就能覆盖;退避策略可以独立演进,第 7 章会加入抖动、上限与网络类型判断,而不用改状态迁移。
实现里 attempt 在 opened 时清零,而不是在 retry 时清零。这样连续失败会持续放大退避;如果每次重试都清零,退避永远停在一倍,服务端故障时会变成固定频率的连接风暴。
maxAttempts 是状态机的边界,不是产品承诺。超过上限进入 failed,只是停止自动重试;产品仍可以通过 start() 让用户手动重试。把「自动重试」与「允许恢复」分开,可以避免状态机既管连接又管产品策略。
页面切后台与恢复
移动端切后台后,JavaScript 可能被挂起,socket 可能已经被系统回收,但 onclose 不一定触发。如果客户端继续认为自己是 open,回到前台后第一次发送才会发现连接已死;更糟的是,后台期间安排的 setTimeout 会被节流,回到前台时多个重试同时触发。
本节的策略是:页面隐藏时调用 hide(),把 open、connecting 或 reconnecting 统一迁到 closed,记录 suspended 标记,并清空 nextRetryAt。这样后台不会继续安排重连,也不会出现「后台重连成功又立刻被挂起」的抖动。
回到前台时调用 show(),只有 suspended 为真且状态为 closed 才迁到 connecting。重连成功后,适配层还要做一次补拉,因为断开期间的消息不会自动出现;补拉与空洞检测属于 7.3,这里只保证状态机给出明确的恢复入口。
hide() 与 show() 都必须是幂等的。visibility 事件会重复触发,组件也可能同时被多个监听器调用;第二次调用返回 false,不产生重复迁移,UI 才不会闪。
卸载时清理什么
dispose() 是终态:如果当前不是 closed,先迁到 closed 并发出 dispose 事件;然后清空所有监听器,后续 start()、lost()、retry() 一律返回 false。重复调用 dispose() 不产生第二个事件。
状态机不拥有 socket、定时器、visibility 监听与心跳。它只负责状态,资源由适配层在 closed 事件里释放:关闭 socket、清除心跳、取消重试定时器、移除页面可见性监听、中止正在进行的补拉请求。把这些清理写进 dispose 的注释或适配层清单,比依赖垃圾回收可靠。
一个常见遗漏是事件监听器的引用。组件卸载时只关闭了 socket,却忘记取消 machine.onTransition 返回的订阅函数;下次组件挂载创建新状态机,旧监听器仍可能被旧事件触发,更新已经卸载的 UI。订阅函数必须是清理清单的第一项。
一个可运行的状态机
下面的实现属于 L2:它没有真实网络,但通过注入时钟与显式 retry() 模拟了连接生命周期。测试用假时钟推进 nextRetryAt,不需要等待真实退避时间。
// file: src/realtime/connection.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/realtime/connection.test.ts
export type ConnectionState =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "failed";
export type TransitionReason =
| "start"
| "opened"
| "network-lost"
| "retry"
| "max-attempts"
| "fatal"
| "close"
| "page-hidden"
| "page-visible"
| "dispose";
export interface TransitionEvent {
from: ConnectionState;
to: ConnectionState;
reason: TransitionReason;
at: number;
attempt: number;
}
export interface ConnectionOptions {
now?: () => number;
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
}
export type TransitionListener = (event: TransitionEvent) => void;
export function retryDelay(attempt: number, baseDelayMs = 500, maxDelayMs = 30_000): number {
if (!Number.isInteger(attempt) || attempt < 1) throw new Error("attempt 必须是正整数");
if (!Number.isFinite(baseDelayMs) || baseDelayMs <= 0) throw new Error("baseDelayMs 必须是正数");
if (!Number.isFinite(maxDelayMs) || maxDelayMs < baseDelayMs) {
throw new Error("maxDelayMs 不能小于 baseDelayMs");
}
return Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
}
export class ConnectionLifecycle {
#state: ConnectionState = "idle";
#attempt = 0;
#nextRetryAt: number | null = null;
#suspended = false;
#disposed = false;
#listeners = new Set<TransitionListener>();
#now: () => number;
#maxAttempts: number;
#baseDelayMs: number;
#maxDelayMs: number;
constructor(options: ConnectionOptions = {}) {
this.#now = options.now ?? (() => Date.now());
this.#maxAttempts = options.maxAttempts ?? 5;
this.#baseDelayMs = options.baseDelayMs ?? 500;
this.#maxDelayMs = options.maxDelayMs ?? 30_000;
if (!Number.isInteger(this.#maxAttempts) || this.#maxAttempts < 1) {
throw new Error("maxAttempts 必须是正整数");
}
retryDelay(1, this.#baseDelayMs, this.#maxDelayMs);
}
get state(): ConnectionState {
return this.#state;
}
get attempt(): number {
return this.#attempt;
}
get nextRetryAt(): number | null {
return this.#nextRetryAt;
}
get suspended(): boolean {
return this.#suspended;
}
get disposed(): boolean {
return this.#disposed;
}
onTransition(listener: TransitionListener): () => void {
this.#listeners.add(listener);
return () => {
this.#listeners.delete(listener);
};
}
start(): boolean {
if (this.#disposed) return false;
if (this.#state !== "idle" && this.#state !== "closed" && this.#state !== "failed") {
return false;
}
this.#attempt = 0;
this.#nextRetryAt = null;
this.#suspended = false;
this.#move("connecting", "start");
return true;
}
opened(): boolean {
if (this.#disposed) return false;
if (this.#state !== "connecting") return false;
this.#attempt = 0;
this.#nextRetryAt = null;
this.#move("open", "opened");
return true;
}
lost(reason: TransitionReason = "network-lost"): boolean {
if (this.#disposed) return false;
if (this.#state !== "open" && this.#state !== "connecting") return false;
this.#attempt += 1;
if (this.#attempt > this.#maxAttempts) {
this.#nextRetryAt = null;
this.#move("failed", "max-attempts");
return true;
}
this.#nextRetryAt = this.#now() + retryDelay(this.#attempt, this.#baseDelayMs, this.#maxDelayMs);
this.#move("reconnecting", reason);
return true;
}
retry(): boolean {
if (this.#disposed) return false;
if (this.#state !== "reconnecting") return false;
if (this.#nextRetryAt !== null && this.#now() < this.#nextRetryAt) return false;
this.#nextRetryAt = null;
this.#move("connecting", "retry");
return true;
}
fail(): boolean {
if (this.#disposed) return false;
if (this.#state === "idle" || this.#state === "closed" || this.#state === "failed") return false;
this.#nextRetryAt = null;
this.#move("failed", "fatal");
return true;
}
close(): boolean {
if (this.#disposed) return false;
if (this.#state === "idle" || this.#state === "closed") return false;
this.#nextRetryAt = null;
this.#suspended = false;
this.#move("closed", "close");
return true;
}
hide(): boolean {
if (this.#disposed || this.#suspended) return false;
if (this.#state !== "open" && this.#state !== "connecting" && this.#state !== "reconnecting") {
return false;
}
this.#suspended = true;
this.#nextRetryAt = null;
this.#move("closed", "page-hidden");
return true;
}
show(): boolean {
if (this.#disposed || !this.#suspended) return false;
if (this.#state !== "closed") return false;
this.#suspended = false;
this.#attempt = 0;
this.#move("connecting", "page-visible");
return true;
}
dispose(): void {
if (this.#disposed) return;
if (this.#state !== "closed") {
this.#nextRetryAt = null;
this.#suspended = false;
this.#move("closed", "dispose");
}
this.#disposed = true;
this.#listeners.clear();
}
#move(to: ConnectionState, reason: TransitionReason): void {
const from = this.#state;
this.#state = to;
const event: TransitionEvent = {
from,
to,
reason,
at: this.#now(),
attempt: this.#attempt,
};
for (const listener of this.#listeners) listener(event);
}
}
start() 只接受 idle、closed、failed,因此用户关闭后可以重新开始,失败后也可以手动重试。lost() 同时接受 open 与 connecting,因为握手阶段也可能断线。retry() 检查当前时间是否到达 nextRetryAt,未到时间返回 false,调度层可以安全地重复调用。
close() 与 hide() 都会进入 closed,区别在 suspended 标记。hide() 只在连接仍活跃时生效,避免页面隐藏事件把已经失败的连接「洗白」成可恢复状态。
测试与真实输出
本机运行结果如下:
$ node --test --test-concurrency=1 test/realtime/connection.test.ts
✔ idle 只能先进入 connecting,再进入 open (0.946ms)
✔ 非法迁移返回 false,不产生事件也不改变状态 (0.466791ms)
✔ 连接丢失进入 reconnecting,并按尝试次数安排下一次重试时间 (0.127291ms)
✔ 未到重试时间不能 retry,假时钟推进后可以 (0.07825ms)
✔ 连续丢失时退避翻倍,超过上限进入 failed,之后可以从 failed 重启 (0.276167ms)
✔ 页面切后台关闭连接并标记 suspended,回到前台重新 connecting (0.099167ms)
✔ dispose 关闭连接、清理监听,并且是终态和幂等的 (0.095ms)
✔ 取消订阅后不再收到迁移事件,但状态机继续工作 (0.071541ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 126.213708
八条用例覆盖了正常迁移、非法迁移、退避、超限、页面可见性、销毁与取消订阅。它们没有覆盖「旧 socket 的回调迟到」这一类问题,因为状态机没有连接身份,必须由适配层用代际令牌解决。
失败复盘:旧连接的回调把新连接标成 open
「回声」的会话页在切后台再回来后,偶发出现「显示在线但发不出消息」。日志里状态机确实是 open,socket 却已经关闭。复盘发现两条连接的生命周期交叠了。
页面隐藏时,适配层调用了 hide(),状态机进入 closed;但旧 socket 的关闭是异步的,onclose 在几秒后才到。回到前台,show() 让状态机进入 connecting,适配层创建了新 socket。此时旧 socket 的 onopen 迟到,适配层没有区分它属于哪一次连接,直接调用 machine.opened(),状态机从 connecting 迁到 open。新 socket 其实还在握手,于是 UI 显示在线,发送却全部失败。
修复不是改状态机,而是给每次连接分配一个递增的 generation:适配层在创建 socket 时记录当前代际,所有回调先比较代际,旧代际的事件直接丢弃。状态机仍然只负责「当前这条连接」的迁移,身份过滤属于适配层。这也说明状态机的 opened() 不应该接受任意来源的「打开」信号。
第二个问题是后台定时器。修复前,hide() 只改了状态,没有通知调度层取消已经安排的 setTimeout;回到前台后多个定时器同时调用 retry()。状态机只让第一个成功,后两个返回 false,所以没有产生重复迁移,但适配层在调用 retry() 前就创建了 socket,仍然多建了两条连接。最终把「取消定时器」放进 closed 事件的清理清单,问题才消失。
与 6.3 的边界
本节结束时,我们只知道连接处于哪个状态,还不知道每个状态对应什么产品能力。reconnecting 时评论框应该禁用还是排队?角标应该停止更新还是保留最后一次数字?这些不是状态机该回答的问题。下一节把状态映射成能力矩阵,并讨论实时不可用时页面如何避免白屏。
小结
连接生命周期要用状态机而不是布尔值表达。六个状态覆盖首次连接、已打开、等待重试、主动关闭与失败;合法迁移表拒绝迟到与重复事件;事件钩子携带原因与尝试次数;退避时间只记录、不自己定时;页面隐藏时挂起并清理重试,恢复时重新连接并补拉;销毁时进入终态并清空监听。状态机不拥有 socket,适配层要用代际令牌过滤旧连接的回调。
状态机解决了「连接现在怎么样」,但产品还需要回答「连接不好时用户能做什么」。下一节给出降级矩阵:哪些能力暂停、哪些改为手动、哪些允许排队,以及恢复后如何补拉。
阅读导航:上一节:6.1 轮询、SSE 与 WebSocket 的判定表 · 下一节:6.3 降级路径:实时不可用时产品还能做什么 。
常见错误与边界
- 用
connected布尔值表示连接,无法区分首次连接、重连与失败。 - 连接还没开始就接受
opened,适配层 bug 被静默吞掉。 - 每次
retry都把尝试次数清零,退避永远停在一倍。 - 状态机自己持有
setTimeout,测试必须等真实时间,清理也容易漏。 - 页面隐藏后继续安排重连,回到前台时多个定时器同时触发。
hide()与show()不幂等,visibility 事件重复时 UI 闪烁。dispose()只关 socket,不取消事件订阅,旧监听器更新已卸载的组件。- 不区分旧连接回调与新连接,迟到的
onopen把新连接误标为open。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。