课程库 › TypeScript › 状态机 · 把「乱」写成「图」 › 第 3 课
转移函数与表驱动 —— 状态机的心脏是一个纯函数
普通课约 50 分钟
本课导读
状态有了类型、事件有了类型,本课把它们接上电:转移函数——(当前状态, 事件) => 新状态。它是状态机的心脏,也是一个再普通不过的纯函数:不碰界面、不发请求、给同样的输入永远吐同样的输出,好写好测好讲理。本课先写 switch 版,配发大杀器穷尽检查(加新状态忘处理?编译器全给你标出来);再介绍另一个流派表驱动——把转移规则写成一张能"读"的数据表。两派各有主场,课末给出选择标准。预计 50 分钟。
说明
前置工具箱:上一课整课(状态与事件的判别联合、switch 判别收窄);unit-1 第 6 课泛型的眼熟度(本课 Record 工具类型会用到尖括号,但不需要你会写泛型)。
转移函数:状态机的全部规则住在这里
以订单为例,先定型再写心脏:
三个要点,每个都是上一课伏笔的兑现:
- 非法转移的默认答案是"返回原状态"。paying 收到 SHIP?无视。done 收到任何事件?无视。上一课"洗衣机脱水时按启动没反应"的自我防御,就这一行
return state——不用 UI 层到处写if (!isPaying)补丁 - 纯函数:transition 不改全局变量、不发请求、不碰 DOM。这意味着测试它只需要"喂输入、对输出"——课末练习你会亲手体会这有多爽
- 调用方极简:
s = transition(s, event)——外面的世界只做两件事:发事件、拿新状态。规则百分之百住在函数里
穷尽检查:给"忘了处理"上保险
上一课埋的雷现在引爆:如果给 OrderState 加一个 "refunding"(退款中),transition 会怎样?——编译立刻报错:"函数缺少结束返回语句",因为新 case 没人处理、函数可能走到头没返回。这层保护来自"不写 default"。但它有个盲区:如果函数返回类型碰巧兼容"没覆盖"的情况(比如返回 void),漏网就悄无声息。正式武器是 never 检查:
读懂这个套路:default 分支里,如果前面的 case 真的穷尽了所有可能,light 的剩余类型就是 never(不可能有值),assertNever(light) 编译通过;一旦类型加了新成员而 case 没跟上,light 在 default 里就"剩下"了新成员,塞给 never 参数当场编译报错。加一个状态,编译器把全项目所有漏网的 switch 逐个点名——这就是用 TS 写状态机最大的红利,重构从提心吊胆变成按图施工。
表驱动:把规则从代码变成数据
switch 版工作得很好,但规则藏在控制流里——想知道"paying 状态能响应哪些事件",得逐行读代码。另一个流派把规则写成一张表:
表驱动的三个卖点:
- 规则一目了然:五行表就是完整的规则说明书,"paying 只认 PAY_OK"不用读控制流,产品经理都能看懂
- 规则是数据:能遍历、能打印每个状态的"菜单"、能自动生成状态图文档——代码读不了自己的 if,但读得了自己的表
- Record 的完整性检查:
Record<OrderState, ...>要求每个状态都必须在表里有一行——加了 refunding 忘了建行?TS2739 编译报错。穷尽检查换了个形式,依然在岗
代价也要明说:表格只能表达"事件 → 目标状态"这种简单映射;带条件的转移(余额够才能支付)、转移时要做的事(发请求),表格塞不下——那是下一课守卫与副作用的地盘。
怎么选:一条实用标准
| 情况 | 用哪派 |
|---|---|
| 转移全是简单映射(事件→状态) | 表驱动——规则即文档 |
| 转移带条件、带附加逻辑 | switch + 穷尽检查——控制流写复杂逻辑更自然 |
| 团队要对着规则讨论、要生成状态图 | 表驱动 |
| 两三个状态的小机器 | 哪个顺手用哪个,别过度设计 |
实战里常见混合形态:主体查表,个别复杂转移落到函数——下一课就会遇到。
✍️ 练习
练习 1:红绿灯 + 行人按钮(switch 派)
状态:"red" | "green" | "yellow";事件:{ type: "TIMER" }(计时器到点,红→绿→黄→红循环)和 { type: "PEDESTRIAN" }(行人按钮:绿灯时立刻变黄,其他状态无视)。写 switch 版 transition,带 assertNever 穷尽检查,并用一串事件试驾验证"红灯按行人按钮被无视"。
✅ 参考答案
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 重复刷卡不再扣费)。用表驱动写,并打印两个状态各自的"菜单"。
✅ 参考答案
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,查表落空自动无视。没写的规则也是规则,这是表驱动最优雅的地方。
📝 随堂测验
1. 转移函数'非法转移返回原状态'这一行,替代了什么?
2. transition 是纯函数带来的直接好处是?
3. assertNever 套路的工作原理是?
4. 表驱动相对 switch 的独有超能力是?
5. 闸机例子里'unlocked 重复刷卡不扣费'的实现代码在哪?
本课小结
- 转移函数
(state, event) => state:状态机的心脏,纯函数,规则百分之百住在里面 - 非法转移默认
return state:自我防御一行搞定,UI 层防御补丁全下岗 - 穷尽检查两件套:不写 default(基础版)、assertNever + never(正式版)——加状态时编译器全项目点名
- 表驱动:
Record转移表,规则即文档、规则是数据;Record 自带"每个状态必须有一行"的完整性检查 - 选择标准:简单映射用表,复杂逻辑用 switch,小机器别过度设计
心脏会跳了,但还只会"变状态"。真实的机器还要看条件(余额够才能支付)和干活儿(转移时发请求)——下一课:守卫与副作用。
划选正文任意文字可高亮、批注或加入复习卡
讨论
载入中…