课程库 › TypeScript › 状态机 · 把「乱」写成「图」 › 第 4 课
守卫与副作用 —— 有条件的转移,和转移时要干的活儿
普通课约 50 分钟
本课导读
上一课的心脏只会做一件事:换状态。真实的机器还差两块:守卫(guard)——转移带条件,"余额够才放行";副作用(effect)——转移时要干活儿,"进入 submitting 时发请求"。这两块的加法很讲究:加错了位置,纯函数的心脏就被污染,前一课攒下的好测试、好讲理全部报废。本课的主线就是把这两样东西加进机器、同时保住纯函数——秘诀一句话:决定做什么是机器的事,真去做是外面的事。预计 50 分钟。
说明
前置工具箱:上一课的转移函数与"非法转移返回原状态";第 2 课的判别联合(本课状态开始携带数据,context 正式上岗);纯函数的定义(同输入同输出、不碰外部世界)——本课全程在保卫它。
守卫:转移的条件不写在 UI 里
闸机上一课只认"刷没刷卡",现在加需求:余额不少于 3 元才放行。第一反应往往是在 UI 层拦:
// 反面教材(示意):守卫散落在调用方
if (card.balance >= 3) {
gate = transition(gate, { type: "SWIPE" });
}
问题和布尔爆炸如出一辙:每个调用点都要记得抄一遍条件——闸机 App、测试脚本、客服后台,漏一处就是免费乘车。守卫的正确住址是机器内部:
三个观察:
- 守卫就是转移函数里的 if,没有新语法——新的是纪律:条件只写这一处,所有调用方自动服从
- 状态带上了
balance,转移函数同时负责算出新数据(扣费)——注意它返回的是新对象,不去改旧 state 的字段(纯函数的另一半纪律:不改输入) - 守卫拦下时走老规矩
return state——"转移不发生"和"事件被无视"是同一件事
副作用:机器决定,外壳执行
第二块拼图。需求:余额不足刷卡时,闸机要响一声提示音;放行时要上报一条扣费记录。直觉写法是在转移函数里直接干:
正确姿势:机器决定要干什么活儿,把活儿写成数据返回;真正去干的是外面的"外壳":
这个结构有个流传很广的名字:函数式核心,命令式外壳。心脏(核心)依然纯:测试"余额不足会不会响"只需检查返回的工单里有没有 BEEP 对象——不用听声音,读数据就行。外壳很薄很笨:拿工单、照着干,没有任何决策。所有聪明的部分都集中在可测试的那一半里。
提示
注意 Effect 类型本身又是一个判别联合——本单元学的招式开始自我复用了。工单模式的额外红利:工单可以被记录(完整的行为日志)、被重放(复现 bug 现场)、被丢弃(测试时)——因为"要干什么"变成了数据,而数据比动作听话得多。
分工总表:机器管什么,UI 管什么
守卫和副作用就位后,状态机与外界(通常是 UI)的分工可以立成一张总表:
| 职责 | 归谁 | 例 |
|---|---|---|
| 判断"现在能不能做" | 机器(守卫) | 余额够不够、表单填没填 |
| 决定"变成什么状态" | 机器(转移) | paying → paid |
| 决定"要干什么活儿" | 机器(工单) | 该发请求、该响提示音 |
| 真正干活儿 | 外壳 | fetch、播放音频、写日志 |
| 发事件、渲染状态 | UI | 按钮点击发 SWIPE、按 status 显示界面 |
UI 从此薄得幸福:收到什么状态就画什么,用户干了什么就发什么事件——没有 if 网络,没有"这个按钮这时候能不能点"的现场判断(想知道能不能点?发事件,机器自会无视)。产品设计 Track UI 单元讲的按钮五状态(disabled、loading……),在这里获得了数据来源:按钮的视觉状态,就是机器状态的一个投影。
✍️ 练习
练习 1:带守卫的取款机
状态:{ status: "idle"; balance: number } 与 { status: "dispensing"; balance: number }。事件:{ type: "WITHDRAW"; amount: number } 与 { type: "DONE" }。规则:idle 收到 WITHDRAW,金额大于 0 且不超过余额才进入 dispensing 并扣款,否则原地不动;dispensing 收到 DONE 回到 idle。先只做守卫版(不带工单)。
✅ 参考答案
function transition(state: AtmState, event: AtmEvent): AtmState {
switch (state.status) {
case "idle":
if (event.type === "WITHDRAW") {
if (event.amount > 0 && event.amount <= state.balance) {
return { status: "dispensing", balance: state.balance - event.amount };
}
return state;
}
return state;
case "dispensing":
if (event.type === "DONE") {
return { status: "idle", balance: state.balance };
}
return state;
}
}
守卫两个条件缺一不可(amount 大于 0 拦住"取负数存钱"的经典漏洞)。注意 dispensing 状态收到第二个 WITHDRAW 自动无视——上一课的自我防御白送的。
练习 2:给取款机开工单
把练习 1 升级成工单版:返回 { next, effects }。守卫拦下时开 { kind: "SHOW_ERROR"; reason: string } 工单(超额与非法金额给不同 reason);成功进入 dispensing 时开 { kind: "DISPENSE_CASH"; amount: number } 工单。写一个 runEffects 外壳用 console 模拟执行,并试驾三种输入。
✅ 参考答案
function transition(
state: AtmState,
event: AtmEvent
): { next: AtmState; effects: Effect[] } {
if (state.status === "idle" && event.type === "WITHDRAW") {
if (event.amount <= 0) {
return { next: state, effects: [{ kind: "SHOW_ERROR", reason: "金额必须大于 0" }] };
}
if (event.amount > state.balance) {
return { next: state, effects: [{ kind: "SHOW_ERROR", reason: "余额不足" }] };
}
return {
next: { status: "dispensing", balance: state.balance - event.amount },
effects: [{ kind: "DISPENSE_CASH", amount: event.amount }],
};
}
if (state.status === "dispensing" && event.type === "DONE") {
return { next: { status: "idle", balance: state.balance }, effects: [] };
}
return { next: state, effects: [] };
}
function runEffects(effects: Effect[]): void {
for (const e of effects) {
if (e.kind === "SHOW_ERROR") { console.log("提示:" + e.reason); }
if (e.kind === "DISPENSE_CASH") { console.log("吐钞 " + e.amount + " 元"); }
}
}
let a: AtmState = { status: "idle", balance: 100 };
for (const ev of [
{ type: "WITHDRAW" as const, amount: -5 },
{ type: "WITHDRAW" as const, amount: 500 },
{ type: "WITHDRAW" as const, amount: 80 },
]) {
const r = transition(a, ev);
runEffects(r.effects);
a = r.next;
}
console.log("最终:" + a.status + ",余额 " + a.balance);
体会测试的姿势变化:想验证"超额会提示余额不足",不用截图不用监听弹窗——transition 的返回值里找那张 SHOW_ERROR 工单就行。读数据,不看动静。
📝 随堂测验
1. '余额够才放行'的判断写在 UI 层的问题是?
2. 转移函数返回新对象而不是修改旧 state 的字段,守的是哪条纪律?
3. '函数式核心,命令式外壳'中,外壳的正确画像是?
4. 把副作用写成工单(数据)而非直接执行,额外的红利不包括?
5. '按钮的视觉状态是机器状态的一个投影',这句话对 UI 层意味着什么?
本课小结
- 守卫 = 转移函数里的 if,唯一住址是机器内部;拦下即
return state - 状态携带 context(判别联合实战形态);转移返回新对象,不改输入
- 副作用两步走:机器决定(返回 Effect 工单,本身也是判别联合),外壳执行(薄、笨、无决策)
- 函数式核心命令式外壳:测试读工单数据,不听声音不看动静
- 分工总表:能不能/变什么/干什么 = 机器;真干活 = 外壳;发事件 + 画状态 = UI
手写机器的全部零件到齐了。下一课抬头看看工业界:XState 的配置和我们的转移表长得有多像、状态图还能嵌套成什么样,以及一个惊喜——React 的 useReducer 你其实已经会写了。
划选正文任意文字可高亮、批注或加入复习卡
讨论
载入中…