《互联网产品的客户端架构·端形态与体验基础》5.3 状态机的引入判据

用状态数量、禁止迁移、异步边界与副作用密度四个信号,判断一段状态该用状态机还是普通布尔标记;给出可运行的 TypeScript 判定函数与上传状态机,验证非法事件拒绝、重试上限与状态可达性,并用列表筛选和表单校验说明这条判据的反例边界,避免抽象过度或抽象不足。

不是所有状态都值得状态机

状态机在客户端被两种极端对待。一种是把所有东西都画成状态机:一个弹窗开关也有五个状态、十几条迁移,读代码的人要先看一张图才能知道点关闭会发生什么。另一种是永远不用:上传流程用 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 顺序表达非法迁移。新增事件时顺序一变,原本被挡住的操作就会漏过去。
  • 把副作用写进迁移表。迁移表应该描述“能不能走”,副作用应该单独登记,便于测试与重放。
  • 状态机里直接发请求。网络失败会让状态卡在中间,应该由外部执行副作用再把结果作为事件送回。
  • 忘记重试上限。失败状态可以无限重试,弱网下会形成请求风暴。
  • 只检查可达性就认为设计正确。可达只说明没有孤岛,不说明迁移方向符合业务。
  • 把列表筛选改造成状态机。输入组合用派生值,迁移表只会增加同步负担。
  • 在终态继续接受事件。完成与取消之后应显式拒绝,避免出现“完成后又重新上传”的脏数据。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查