课程库 › TypeScript › 状态机 · 把「乱」写成「图」 › 第 1 课
布尔爆炸 —— 为什么需要状态机
普通课约 45 分钟
本课导读
新单元从一类你迟早会写出来的 bug 讲起:页面同时显示着加载动画和错误提示,或者按钮转着圈却还能再点一次。这类 bug 的根源惊人地一致——用一堆布尔变量描述状态,而布尔的组合里混着大量"不可能的状态"。本课先解剖惨案现场,再给出解药的名字:状态机——把"当前处于哪种情况"收纳成一个变量,把"什么情况下能变成什么"写成明确的规则。这个思想比 TypeScript 古老得多(红绿灯就是台状态机),但 TS 恰好是把它写出来最舒服的语言之一。预计 45 分钟。
说明
前置工具箱:unit-1 第 5 课的字面量联合("a" | "b" | "c"——本课的解药主料)与收窄。另外如果你学过产品设计 Track 的 UI 单元:弹窗、向导、拖拽这些交互的"乱",本单元会给出代码侧的治法——UI 交互本质上全是状态机。
惨案现场:三个布尔,八种组合
做一个数据加载页面,很自然地写下三个变量:
问题不在"忘了关"这一行——谁都会忘。问题在结构:3 个布尔有 2×2×2 = 8 种组合,而业务上合法的情况只有 4 种(空闲、加载中、成功、失败)。剩下 4 种"不可能的状态"(又加载又报错、又成功又加载……)在类型上全部合法,编译器帮不上任何忙,只能靠每个开发者每次都记得手工维护布尔之间的默契。变量再多一个,组合翻倍,默契迟早崩。
这类 bug 有个学名叫不可能状态可表示(impossible states are representable)。对应的治疗原则也是一句话,值得背下来:让不可能的状态不可表示(make impossible states unrepresentable)。
解药第一步:一个变量收纳所有情况
四种合法情况,就用一个变量、四个值:
变化的本质:8 种组合塌缩成 4 个值,不可能的状态在类型层直接灭绝。没有需要维护的默契,没有需要记得关的开关——不是你变细心了,是错误失去了藏身之处。
解药第二步:转移也要有规矩
光收纳还不够。看这个 bug 的另一半:
status 的每个值都合法,但从哪个值变到哪个值不是随便的:success 不该无故跳回 loading;loading 中再收到"提交"应该被无视。这些"变化的规矩"就是状态机的第二半:
**状态机 = 有限的状态集合 + 明确的事件 + "什么状态遇到什么事件变成什么状态"的转移规则。**规则之外的变化,一律不发生。
生活里你天天在用状态机:红绿灯(红→绿→黄→红,谁也别想从红直接跳黄)、地铁闸机(锁定—刷卡→放行—通过→锁定,放行时再刷卡不会开两次)、洗衣机(脱水时按"启动"没反应)。它们的共同气质:任何时刻你都说得清它处于哪个状态、接下来只有几种确定的可能——这份"说得清",正是我们要搬进代码里的东西。
提示
判断"该上状态机了"的信号,比你想的来得早:① 出现第二个相互关联的布尔(isX 和 isY 不能同时为 true——默契已经开始要人记了);② 出现"这时候点了会怎样?"答不上来的按钮;③ 修一个显示 bug 引出另一个显示 bug。三个信号有一个,就值得把状态收纳成一个联合、把变化写成规则——下一课开始动手。
状态与数据:先分清一对角色
最后铺一块下一课的地基。"success 之后拿到的列表数据"算状态吗?——不算。状态(state)是"处于哪种情况",取值有限、可枚举;数据(context)是伴随状态的内容,取值无限(列表内容千变万化)。混着放会退回布尔爆炸的老路(hasData 就是把数据问题硬编码成了状态布尔)。正确的姿势是"有限的状态 + 各状态携带各自的数据"——比如 success 状态必然带着数据、error 状态必然带着错误信息。这件事用 TS 表达得极其漂亮,主角叫判别联合,下一课整课就是它。
✍️ 练习
练习 1:数一数不可能状态
某音乐播放器用了四个布尔:isPlaying、isPaused、isBuffering、isStopped。① 一共多少种组合?② 业务上合法的情况有哪几种(列出来)?③ 写出两个"不可能却可表示"的组合,并各配一个它会导致的界面 bug。最后用一个字面量联合替换这四个布尔。
✅ 参考答案
① 2 的 4 次方 = 16 种。② 合法只有 4 种情况:播放中、已暂停、缓冲中、已停止(四个布尔恰好一真三假的 4 种)。③ 不可能组合举例:isPlaying 和 isPaused 同时 true(进度条走着、播放键却显示"继续");全部 false(界面什么都不显示,或什么都显示——没定义)。替换:type PlayerStatus = "playing" | "paused" | "buffering" | "stopped"——16 种组合塌缩成 4 个值,12 种不可能状态灭绝。
练习 2:找出身边的状态机
写出两台你今天接触过的现实状态机(电梯、微波炉、共享单车锁、外卖订单……任选),各列出:状态集合、事件集合、两条转移规则、一条"被无视的事件"(某状态下发生了也不起作用的事件——比如洗衣机脱水时按启动)。
✅ 参考答案
样例(共享单车锁):状态 = 锁定 / 已解锁 / 骑行中 / 待支付;事件 = 扫码、开锁成功、开始骑行、锁车、支付完成;转移规则如"锁定 + 扫码成功 → 已解锁""骑行中 + 锁车 → 待支付";被无视的事件如"骑行中 + 再次扫码 → 不变(不会给你开第二辆的价)"。自查标准:状态是"情况"而不是"数据"(余额多少不是状态);每条转移都是"状态 + 事件 → 状态"三元组;"被无视的事件"说得出为什么无视(这正是状态机的自我防御,第 3 课细讲)。
📝 随堂测验
1. 3 个关联布尔的真正问题是?
2. 把三个布尔换成 type Status = "idle" | "loading" | "success" | "error" 后,'转圈和报错同时出现'的 bug 为什么绝迹?
3. 状态机的完整定义是?
4. '洗衣机脱水时按启动键没反应'对应状态机的哪个特性?
5. 'success 之后拿到的列表数据'在状态机术语里属于?
本课小结
- 布尔爆炸:n 个关联布尔 = 2 的 n 次方种组合,合法的只有几种——不可能状态可表示是一类 bug 的总根源
- 治疗原则:让不可能的状态不可表示——一个字面量联合收纳所有情况
- 状态机 = 有限状态 + 事件 + 转移规则;规则之外的变化不发生(含"被无视的事件"这层自我防御)
- 上状态机的三个信号:第二个关联布尔出现、"这时候点了会怎样"答不上来、修一个显示 bug 引出另一个
- 状态是"情况"(有限),数据是"内容"(无限)——别混
下一课主菜:判别联合——让每个状态携带自己的数据,success 必有 data、error 必有 message、loading 想带 data 都带不了,编译器全程站岗。
划选正文任意文字可高亮、批注或加入复习卡
讨论
载入中…