不是所有状态都值得状态机
状态机在客户端被两种极端对待。一种是把所有东西都画成状态机:一个弹窗开关也有五个状态、十几条迁移,读代码的人要先看一张图才能知道点关闭会发生什么。另一种是永远不用:上传流程用 isUploading、isPaused、isFailed 三个布尔量拼出来,某个瞬间三个都为 false,界面既不是上传中也不是失败,只能显示空白。
两种做法都错在同一个地方:没有先判断这段状态是否真的存在“互斥阶段”和“非法迁移”。状态机的价值不是形式,而是把“哪些事件在哪些阶段合法”写成一张可检查的表。没有非法迁移可表达时,它就是多余的抽象。
「回声」里有三类典型状态。上传:空闲、上传中、暂停、校验中、完成、失败、取消,每一步都有明确的合法事件。订单:待支付、支付中、已支付、已取消、退款中,支付中不能再次支付。连接:离线、连接中、在线、重连等待,在线时不能“重新连接”。它们都值得状态机。列表筛选:按价格排序还是按时间排序,只是两个独立输入的组合,用派生值更直接。
本节先给出判定函数,再实现一个上传状态机,并说明判据的边界。
四个信号
判定不看“状态多不多”这一个维度,而是四个信号:
- 状态数量与迁移数量:太少时,布尔标记的组合成本低于状态机。
- 是否存在禁止迁移:如果某些事件在某个阶段必须被拒绝,就需要显式迁移表。
- 是否跨越异步边界:网络请求、文件读写会让状态在等待期间被其他事件打断,需要可恢复的阶段。
- 每个迁移是否绑定独立副作用:开始上传要建令牌,暂停要持久化续传点,取消要删临时文件。
第一个信号决定规模,后三个决定复杂度。一个只有两个状态、没有异步、没有副作用的开关,即使迁移合法,也不值得引入状态机。
判定函数把“单一输入推导”作为前置排除:列表筛选、排序方向、分页页码这类状态,即使数量看起来不少,也只是输入的派生结果,不应该拥有自己的迁移表。
判定与迁移表
下面的文件包含两部分:shouldUseStateMachine 负责判定,transition 负责执行上传状态机。两者都是纯函数,不发起请求、不读时钟。
// file: src/state/machine.ts
// tier: L1
// verify: node --test test/state/machine.test.ts
export interface MachineSignals {
/** 互斥状态的数量 */
stateCount: number;
/** 合法迁移的数量 */
transitionCount: number;
/** 是否存在“绝不能发生”的状态组合 */
hasForbiddenPairs: boolean;
/** 状态是否跨越网络或其他异步边界 */
asyncBoundary: boolean;
/** 平均每个迁移绑定的副作用数量 */
effectsPerTransition: number;
/** 状态是否只由单一筛选输入推导出来 */
derivedFromSingleInput: boolean;
}
export interface MachineDecision {
use: boolean;
score: number;
reason: string;
}
/**
* 判断一段状态是否值得引入状态机。
* 单一输入推导出的列表筛选先被排除,其余按禁止迁移、异步边界与状态数量计分。
*/
export function shouldUseStateMachine(signals: MachineSignals): MachineDecision {
if (
signals.derivedFromSingleInput &&
!signals.asyncBoundary &&
!signals.hasForbiddenPairs
) {
return {
use: false,
score: 0,
reason: "状态只由单一输入推导,普通派生值更简单",
};
}
let score = 0;
if (signals.stateCount >= 3) score += 2;
if (signals.transitionCount >= 4) score += 2;
if (signals.hasForbiddenPairs) score += 3;
if (signals.asyncBoundary) score += 2;
if (signals.effectsPerTransition >= 2) score += 1;
const use = score >= 4;
const reason = signals.hasForbiddenPairs
? `存在禁止迁移,需要显式迁移表(得分 ${score})`
: signals.asyncBoundary
? `跨越异步边界,需要可恢复的状态(得分 ${score})`
: use
? `状态与迁移数量已达到阈值(得分 ${score})`
: `状态与迁移都较少,用布尔标记即可(得分 ${score})`;
return { use, score, reason };
}
export type UploadState =
| "idle"
| "uploading"
| "paused"
| "verifying"
| "done"
| "failed"
| "cancelled";
export type UploadEvent =
| "start"
| "pause"
| "resume"
| "sent"
| "verify-ok"
| "verify-fail"
| "cancel"
| "retry";
export interface UploadContext {
attempts: number;
maxAttempts: number;
totalBytes: number;
error?: string;
}
export interface TransitionResult {
ok: boolean;
state: UploadState;
context: UploadContext;
effects: string[];
error?: string;
}
/** 显式迁移表:没有列出的组合一律拒绝,而不是靠 if 顺序兜底。 */
const TRANSITIONS: Record<UploadState, Partial<Record<UploadEvent, UploadState>>> = {
idle: { start: "uploading" },
uploading: { pause: "paused", sent: "verifying", cancel: "cancelled" },
paused: { resume: "uploading", cancel: "cancelled" },
verifying: { "verify-ok": "done", "verify-fail": "failed" },
failed: { retry: "uploading", cancel: "cancelled" },
done: {},
cancelled: {},
};
const EFFECTS: Record<UploadEvent, string[]> = {
start: ["create-upload-token"],
pause: ["persist-resume-token"],
resume: ["resume-upload"],
sent: ["start-verification"],
"verify-ok": ["publish-result"],
"verify-fail": ["keep-temp-file"],
cancel: ["delete-temp-file"],
retry: ["create-upload-token"],
};
/**
* 上传状态机:纯函数,不发起真实请求,也不读时钟。
* 非法事件返回 ok:false,状态与上下文保持不变,便于界面直接回显错误。
*/
export function transition(
state: UploadState,
event: UploadEvent,
context: UploadContext,
): TransitionResult {
const next = TRANSITIONS[state][event];
if (!next) {
return {
ok: false,
state,
context,
effects: [],
error: `状态 ${state} 不接受事件 ${event}`,
};
}
if (event === "retry" && context.attempts >= context.maxAttempts) {
return {
ok: false,
state,
context,
effects: [],
error: "重试次数已用尽",
};
}
const nextContext: UploadContext = { ...context };
if (event === "start" || event === "retry") {
nextContext.attempts = context.attempts + 1;
}
if (event === "resume") nextContext.error = undefined;
if (event === "verify-fail") nextContext.error = "verification-failed";
return { ok: true, state: next, context: nextContext, effects: EFFECTS[event] };
}
/** 从起始状态做广度优先遍历,返回可达状态,用于发现永远进不去的分支。 */
export function reachableStates(from: UploadState = "idle"): UploadState[] {
const seen = new Set<UploadState>([from]);
const queue: UploadState[] = [from];
while (queue.length > 0) {
const current = queue.shift() as UploadState;
for (const target of Object.values(TRANSITIONS[current])) {
if (target && !seen.has(target)) {
seen.add(target);
queue.push(target);
}
}
}
return [...seen].sort();
}
/** 迁移表里出现的每个目标都必须是已声明的状态。 */
export function allTargetsKnown(): boolean {
const states = new Set(Object.keys(TRANSITIONS));
for (const row of Object.values(TRANSITIONS)) {
for (const target of Object.values(row)) {
if (target && !states.has(target)) return false;
}
}
return true;
}
判据的结果怎么读
shouldUseStateMachine 返回 use、score 与 reason。reason 不是给日志看的,而是给评审记录看的:它把“为什么这里用状态机”写成一句话,半年后有人质疑抽象过度时可以直接引用。
计分规则如下:
| 信号 | 条件 | 分数 |
|---|---|---|
| 状态数量 | 达到 3 个 | +2 |
| 迁移数量 | 达到 4 条 | +2 |
| 禁止迁移 | 存在 | +3 |
| 异步边界 | 跨越 | +2 |
| 副作用密度 | 平均每个迁移至少 2 个 | +1 |
总分达到 4 才启用。这个门槛意味着“三个状态”或“四条迁移”单独出现还不够,必须再有一个复杂度信号。上传的得分是 2 + 2 + 3 + 2 = 9,远超阈值;列表筛选因为前置排除直接得 0。
前置排除只在前三个条件同时成立时触发:单一输入推导、没有异步、没有禁止迁移。如果列表筛选后来增加了“加载中”和“加载失败”,它跨越了异步边界,就不该再被排除。判据描述的是当前形态,不是永久标签。
迁移表与副作用
上传状态机的迁移表有七个状态、八条合法迁移。没有列出的组合一律拒绝,包括“完成之后再次开始”“取消之后再次取消”“校验中暂停”。这些拒绝不是靠 if 的顺序碰巧成立,而是表里根本没有对应项。
副作用单独放在 EFFECTS 表里,按事件索引,而不是按状态。同一个事件在不同状态下如果副作用不同,就应该拆成两个事件;当前设计里 cancel 从上传中、暂停、失败三个状态出发,副作用都是删除临时文件,因此共用一个键是安全的。
重试上限放在上下文里,由 transition 在迁移前检查。注意检查发生在查表之后:如果当前状态根本不接受 retry,返回的是“不接受事件”,而不是“重试次数已用尽”。错误信息的优先级会影响排查,先报结构性问题,再报业务限制。
reachableStates 用广度优先遍历检查是否有永远进不去的状态。七个状态都从 idle 可达,说明没有遗漏入口。这个检查不能证明迁移设计正确,但能抓住“加了状态却忘了加迁移”这类低级错误。
哪些适合,哪些不适合
| 场景 | 状态数 | 禁止迁移 | 异步边界 | 结论 |
|---|---|---|---|---|
| 上传 | 7 | 有 | 有 | 状态机 |
| 订单 | 5 | 有 | 有 | 状态机 |
| 连接 | 4 | 有 | 有 | 状态机 |
| 列表筛选 | 2 | 无 | 无 | 派生值 |
| 弹窗开关 | 2 | 无 | 无 | 布尔标记 |
| 表单必填校验 | 3 | 无 | 无 | 派生值 |
订单与连接不需要各自实现一遍状态机,可以直接复用同一套判定与迁移模式。差别只在状态名、事件名与副作用表。真正需要统一的是“非法事件必须被拒绝”这条规则,而不是把三个业务塞进一个通用引擎。
表单校验被列为派生值,原因是它的“状态”完全由输入计算得出:每个字段是否合法、整体是否可提交,都可以在不保存迁移历史的情况下重新算出来。状态机适合有历史、有阶段、有“回不去”的流程;不适合随时可以从当前输入重新推导的结果。
测试与真实输出
测试覆盖成功路径、非法事件、暂停恢复、重试上限、校验失败、取消清理、目标完整性、可达性,以及判定函数的正反例。
node --test test/state/machine.test.ts
本机一次运行的真实输出:
✔ 上传成功路径按迁移表推进并返回副作用 (0.932542ms)
✔ 非法事件被拒绝,状态与上下文不变 (0.647333ms)
✔ 暂停与恢复往返,暂停时持久化续传令牌 (0.094ms)
✔ 失败后重试会累加次数,超过上限后拒绝 (0.076916ms)
✔ 校验失败留在 failed 并记录错误,临时文件保留 (0.065375ms)
✔ 取消会清理临时文件,终态不再接受事件 (0.072208ms)
✔ 迁移表的目标全部是已声明状态 (0.08725ms)
✔ 七个状态都从 idle 可达,没有进不去的分支 (0.086666ms)
✔ 列表筛选不适合状态机 (0.114625ms)
✔ 上传与订单流程因禁止迁移与异步边界被判为适合 (0.146208ms)
✔ 状态与迁移刚好到阈值时才启用状态机 (0.060458ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 163.55
耗时同样是墙钟时间,不能横向比较实现。判定标准是退出码为 0 且 fail 0。
小结
状态机的引入判据不是“状态多不多”,而是“有没有非法迁移、有没有异步边界、迁移是否绑定独立副作用”。单一输入推导出的筛选与排序,即使取值不少,也应该用派生值。
一旦决定使用,迁移表要显式列出合法组合,未列出的默认拒绝。副作用按事件单独登记,重试上限之类的业务限制放在迁移前的守卫里。可达性检查与目标完整性检查不能证明设计正确,但能抓住结构错误。
到此,第 5 章的三层状态都有了落点:本地与共享用订阅容器,服务端用带代际失效的缓存,流程用显式迁移表。下一章讨论另一个常被忽略的状态来源:URL。哪些状态应该写进地址栏,哪些不该,会直接影响可分享性与返回行为。
阅读导航:上一节:5.2 服务端状态的缓存与失效 · 下一节:6.1 URL 即状态 。
常见错误与边界
- 给每个布尔量起一个状态名。多个布尔量能同时为 false,界面就会出现未定义的空档。
- 用 if 顺序表达非法迁移。新增事件时顺序一变,原本被挡住的操作就会漏过去。
- 把副作用写进迁移表。迁移表应该描述“能不能走”,副作用应该单独登记,便于测试与重放。
- 状态机里直接发请求。网络失败会让状态卡在中间,应该由外部执行副作用再把结果作为事件送回。
- 忘记重试上限。失败状态可以无限重试,弱网下会形成请求风暴。
- 只检查可达性就认为设计正确。可达只说明没有孤岛,不说明迁移方向符合业务。
- 把列表筛选改造成状态机。输入组合用派生值,迁移表只会增加同步负担。
- 在终态继续接受事件。完成与取消之后应显式拒绝,避免出现“完成后又重新上传”的脏数据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。