学径XUEJING · 个人学习平台通知设置

课程库 TypeScript 状态机 · 把「乱」写成「图」 › 第 3

转移函数与表驱动 —— 状态机的心脏是一个纯函数

普通课50 分钟

本课导读

状态有了类型、事件有了类型,本课把它们接上电:转移函数——(当前状态, 事件) => 新状态。它是状态机的心脏,也是一个再普通不过的纯函数:不碰界面、不发请求、给同样的输入永远吐同样的输出,好写好测好讲理。本课先写 switch 版,配发大杀器穷尽检查(加新状态忘处理?编译器全给你标出来);再介绍另一个流派表驱动——把转移规则写成一张能"读"的数据表。两派各有主场,课末给出选择标准。预计 50 分钟。

说明

前置工具箱:上一课整课(状态与事件的判别联合、switch 判别收窄);unit-1 第 6 课泛型的眼熟度(本课 Record 工具类型会用到尖括号,但不需要你会写泛型)。

转移函数:状态机的全部规则住在这里

以订单为例,先定型再写心脏:

TypeScript

三个要点,每个都是上一课伏笔的兑现:

  • 非法转移的默认答案是"返回原状态"。paying 收到 SHIP?无视。done 收到任何事件?无视。上一课"洗衣机脱水时按启动没反应"的自我防御,就这一行 return state——不用 UI 层到处写 if (!isPaying) 补丁
  • 纯函数:transition 不改全局变量、不发请求、不碰 DOM。这意味着测试它只需要"喂输入、对输出"——课末练习你会亲手体会这有多爽
  • 调用方极简s = transition(s, event)——外面的世界只做两件事:发事件、拿新状态。规则百分之百住在函数里

穷尽检查:给"忘了处理"上保险

上一课埋的雷现在引爆:如果给 OrderState 加一个 "refunding"(退款中),transition 会怎样?——编译立刻报错:"函数缺少结束返回语句",因为新 case 没人处理、函数可能走到头没返回。这层保护来自"不写 default"。但它有个盲区:如果函数返回类型碰巧兼容"没覆盖"的情况(比如返回 void),漏网就悄无声息。正式武器是 never 检查

TypeScript

读懂这个套路:default 分支里,如果前面的 case 真的穷尽了所有可能,light 的剩余类型就是 never(不可能有值),assertNever(light) 编译通过;一旦类型加了新成员而 case 没跟上,light 在 default 里就"剩下"了新成员,塞给 never 参数当场编译报错。加一个状态,编译器把全项目所有漏网的 switch 逐个点名——这就是用 TS 写状态机最大的红利,重构从提心吊胆变成按图施工。

表驱动:把规则从代码变成数据

switch 版工作得很好,但规则藏在控制流里——想知道"paying 状态能响应哪些事件",得逐行读代码。另一个流派把规则写成一张表

TypeScript

表驱动的三个卖点:

  • 规则一目了然:五行表就是完整的规则说明书,"paying 只认 PAY_OK"不用读控制流,产品经理都能看懂
  • 规则是数据:能遍历、能打印每个状态的"菜单"、能自动生成状态图文档——代码读不了自己的 if,但读得了自己的表
  • Record 的完整性检查Record<OrderState, ...> 要求每个状态都必须在表里有一行——加了 refunding 忘了建行?TS2739 编译报错。穷尽检查换了个形式,依然在岗

代价也要明说:表格只能表达"事件 → 目标状态"这种简单映射;带条件的转移(余额够才能支付)、转移时要做的事(发请求),表格塞不下——那是下一课守卫与副作用的地盘。

怎么选:一条实用标准

情况用哪派
转移全是简单映射(事件→状态)表驱动——规则即文档
转移带条件、带附加逻辑switch + 穷尽检查——控制流写复杂逻辑更自然
团队要对着规则讨论、要生成状态图表驱动
两三个状态的小机器哪个顺手用哪个,别过度设计

实战里常见混合形态:主体查表,个别复杂转移落到函数——下一课就会遇到。

✍️ 练习

练习 1:红绿灯 + 行人按钮(switch 派)

状态:"red" | "green" | "yellow";事件:{ type: "TIMER" }(计时器到点,红→绿→黄→红循环)和 { type: "PEDESTRIAN" }(行人按钮:绿灯时立刻变黄,其他状态无视)。写 switch 版 transition,带 assertNever 穷尽检查,并用一串事件试驾验证"红灯按行人按钮被无视"。

TypeScript
✅ 参考答案
function transition(state: Light, event: LightEvent): Light {
    switch (state) {
        case "red":
            if (event.type === "TIMER") { return "green"; }
            return state;
        case "green":
            if (event.type === "TIMER") { return "yellow"; }
            if (event.type === "PEDESTRIAN") { return "yellow"; }
            return state;
        case "yellow":
            if (event.type === "TIMER") { return "red"; }
            return state;
        default:
            return assertNever(state);
    }
}

验证点:red + PEDESTRIAN 走 return state(无视);green 对两种事件都响应。做完可以顺手实验:给 Light 加 | "flashing",观察 assertNever 处的报错——这就是穷尽检查在岗。

练习 2:地铁闸机(表驱动派)

状态:"locked" | "unlocked";事件类型:"SWIPE"(刷卡)与 "PUSH"(推闸)。规则:locked + SWIPE → unlocked;unlocked + PUSH → locked;其余无视(locked 硬推不开,unlocked 重复刷卡不再扣费)。用表驱动写,并打印两个状态各自的"菜单"。

TypeScript
✅ 参考答案
const transitions: Record<GateState, Partial<Record<GateEventType, GateState>>> = {
    locked:   { SWIPE: "unlocked" },
    unlocked: { PUSH: "locked" },
};

function transition(state: GateState, eventType: GateEventType): GateState {
    const target = transitions[state][eventType];
    return target !== undefined ? target : state;
}

console.log("locked 能响应:" + Object.keys(transitions["locked"]).join(", "));
console.log("unlocked 能响应:" + Object.keys(transitions["unlocked"]).join(", "));

两行表 = 完整规则说明书。注意"重复刷卡不扣费"根本不用写代码——unlocked 的菜单里没有 SWIPE,查表落空自动无视。没写的规则也是规则,这是表驱动最优雅的地方。

📝 随堂测验

随堂测验0 / 5 题正确

1. 转移函数'非法转移返回原状态'这一行,替代了什么?

2. transition 是纯函数带来的直接好处是?

3. assertNever 套路的工作原理是?

4. 表驱动相对 switch 的独有超能力是?

5. 闸机例子里'unlocked 重复刷卡不扣费'的实现代码在哪?

本课小结

  • 转移函数 (state, event) => state:状态机的心脏,纯函数,规则百分之百住在里面
  • 非法转移默认 return state:自我防御一行搞定,UI 层防御补丁全下岗
  • 穷尽检查两件套:不写 default(基础版)、assertNever + never(正式版)——加状态时编译器全项目点名
  • 表驱动:Record 转移表,规则即文档、规则是数据;Record 自带"每个状态必须有一行"的完整性检查
  • 选择标准:简单映射用表,复杂逻辑用 switch,小机器别过度设计

心脏会跳了,但还只会"变状态"。真实的机器还要看条件(余额够才能支付)和干活儿(转移时发请求)——下一课:守卫与副作用。

划选正文任意文字可高亮、批注或加入复习卡

讨论

载入中…