状态机编程这名字听起来像教科书里的概念,实际上却是把一团乱麻的 if/else 理成一张清晰地图的利器。我在处理订单流转、多步表单、设备控制这类场景时,凡是觉得“状态怎么这么多、条件怎么这么绕”的时候,最后基本都靠状态机把问题降维。这篇文章不聊虚的,直接讲清楚状态机到底在解决什么问题、动手时怎么设计、核心代码怎么落地,以及我在真实项目里踩过的坑和避坑经验。适合正在被复杂业务逻辑折磨的开发者,也适合想给自己的代码找个更好组织方式的同学。
1. 状态机编程:它在解决什么问题
1.1 为什么状态会成为代码里的隐形炸弹
很多人一开始写业务代码时,对“状态”是没有概念的。订单类里放一个 status 字段,页面上放一个 isLoading,再来一个 isSubmitting,代码写起来很顺手,但等到业务流程复杂起来,这些 bool 开关就会变成定时炸弹。
我给你描述一个特别常见的崩溃现场:需求是“下载任务可以暂停、继续、取消,网络断了自动失败,失败后可以重试”。第一版代码很正常,无非是几个 if 判断。但第二周加需求:“下载中禁止取消”“暂停超过五分钟自动失败”“只有失败状态才能重试”。这个时候你再看看代码,全是 if (status === 'downloading' && !this.isPaused && retryCount < 3 && ...),读起来像在解谜。
问题不在于需求复杂,而在于这些状态的切换逻辑散落在不同的回调、按钮点击事件和定时器里。你无法一眼看出“当前处于什么状态”“下一步能做什么”。这种代码改一次怕一次,因为任何一个判断漏了,就可能让系统进入不可能出现的状态组合——比如既暂停又下载中,或者取消了还能继续。
状态机做的事情很简单:把允许的状态和状态之间的转移路径显式地定义出来,然后让所有代码只通过“事件”来触发转移,不允许任何人随便改状态字段。谁都能一眼看出系统现在在哪、能去哪、做什么才会动。代码的复杂度从“到处找逻辑”变成了“读一张表”。
1.2 状态机的最基本模型:状态、事件、转移
有限状态机(FSM)的模型非常朴素,就三个核心概念:
- 状态:系统在某个时刻所处的稳定情况。状态要互斥,同一时刻只能有一个。
- 事件:触发状态变化的输入。事件可能来自用户操作、网络回调、定时器、或者另一个模块的通知。
- 转移:在某个状态下,收到某个事件后,系统如何变化。一次转移通常由“当前状态 + 事件”决定,目标是“下一个状态”,还可能附带执行一个动作。
这个模型的强大之处在于,它强制你回答一个问题:任何事件发生在任何状态下,结果到底是怎样的?在普通写法里,这件事靠程序员自觉;在状态机里,这事是结构强制要求的。容易漏的“非法转移”会直接在运行时暴露出来,而不是等到上线后发现数据错乱。
举个例子,订单系统里“已支付”状态下收到“再次支付”事件,设计上就不该有任何反应,或者最多提示“订单已经是支付状态”。状态机可以在配置里明确写出这条规则,也可以默认拒绝任何未定义的转移。这么做的好处,不只是少写几个 if,而是让系统行为变得可预测、可推理。
1.3 什么时候该上状态机,什么时候别硬上
我见过两种极端:一种是什么都上状态机,写个登录弹窗都要搞一套;另一种是流程已经乱成一锅粥了,还在 if/else 里硬撑。这两种都走偏了。
我判断的标准很简单,符合下面任一情形我就会认真考虑状态机:
- 状态数量超过 3 个,且状态之间的转移路径超过 6 条。
- 存在多个相互影响的布尔标志,比如“是否加载中”和“是否已提交”互相制约。
- 同样的事件在不同状态下有完全不同的处理结果。
- 业务流程经常要加新状态、改转移规则,希望改起来不牵连全局。
- 你需要“可测试的流程规则”,希望每条转移都能单独写测试验证。
反过来,如果流程是直线型的,从 A 到 B 到 C,中间没有分支、没有回退,那直接用顺序逻辑更简洁。状态机不是银弹,它解决的是“状态多、跳转复杂、规则频繁变化”的问题,硬套在简单场景上只会增加阅读成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手设计一个状态机前,先做好这三步
2.1 兜底排查状态,别漏掉“边界态”
设计状态机最怕的不是状态太多,而是状态不全。很多人列状态时只写了业务主流程里的几个,比如“待支付、已支付、已发货、已完成”,结果一上线就发现,退款单进来后“已发货”状态怎么处理?支付超时怎么办?系统初始化阶段算哪个状态?
我现在的习惯是,拿到需求先不急着写代码,而是把所有可能的情况都问一遍。初态是什么?终态有哪些?哪些状态必须等外部回调?哪些状态代表异常?哪些状态是临时过渡?把这些全部列出来,宁可多列几个再合并,也不要漏掉一个导致后续全部返工。
以订单为例,真实项目的状态列表往往是这样的:新建、待支付、支付中、已支付、备货中、已发货、已签收、已完成、已取消、退款中、已退款、支付失败。里面好几个都是“边界态”:支付中是临时状态,支付失败是异常状态,已取消是终态。漏掉任何一个,你在写转移规则的时候都会发现没法收口。
列出状态后,我还会做一道判断题:两个状态之间,是否存在某些场景下需要互相转换?如果存在,把它们写进转移表;如果不存在,那就说明这俩的状态边界划分得还不够清晰。状态的互斥性越严格,整个设计越好写。
2.2 用状态转移表代替脑内想象
状态列表列完,下一步是把所有可能的转移画出来。我通常不用画图工具,而是用表格,因为表格更精确,也更容易转成代码。转移表的基本列是:当前状态、触发事件、目标状态、动作、守卫条件。
拿订单的一部分举例:
| 当前状态 | 事件 | 目标状态 | 动作 | 守卫条件 |
|---|---|---|---|---|
| 新建 | 提交订单 | 待支付 | 创建支付单 | 商品库存充足 |
| 待支付 | 支付成功回调 | 已支付 | 更新支付信息 | 支付单号匹配 |
| 待支付 | 支付超时 | 已取消 | 释放库存 | 无 |
| 待支付 | 用户取消 | 已取消 | 释放库存 | 无 |
| 已支付 | 申请退款 | 退款中 | 创建退款单 | 未发货 |
| 退款中 | 退款完成 | 已退款 | 通知账户 | 无 |
这张表一出来,整个系统的行为规则就清楚了。有没有某个状态收到事件后目标状态缺失?有没有两个事件指向同一个状态但动作不同?有没有逻辑上应该存在的转移但没写?这些都能在表上直接看出来。
我还会额外注意一行:那些“没有合法转移”的事件怎么处理。例如订单已完成状态下收到“支付成功回调”,正常情况不该发生,但万一真发生了呢?状态机引擎要么静默忽略,要么抛异常。我的一般做法是生产环境静默忽略并记日志,开发和测试环境抛异常,这样既不会因为脏数据挂掉,又能及时暴露问题。
2.3 把动作绑定到“进入、退出、转移”还是业务逻辑上更合适
转移表里除了状态变化,还有个关键点是动作放在哪个时机执行。很多人第一反应是“状态变了,就执行对应业务逻辑”,但这里有个很微妙的差别。
动作通常有三类时机:
- 进入状态时执行:比如进入“待支付”时创建支付单、进入“已发货”时生成物流单。
- 退出状态时执行:比如退出“编辑中”时自动保存草稿、退出“连接中”时清理定时器。
- 转移过程中执行:比如收到“提交订单”事件时校验表单,校验通过再进入“待支付”。
什么时候优先用哪种?我的经验是:如果某个动作和“状态刚到达”绑定,无论从哪个前置状态过来都要执行,那就放进入动作。如果某个动作必须在离开某个状态时做清理,那就放退出动作。如果动作和具体事件强相关,不同事件到达同一个状态时要执行不同的操作,才放转移动作。
这个概念有点像开关灯:你进房间时开灯,出房间时关灯,而不是纠结“从走廊走进来开灯”还是“从阳台走进来开灯”。把动作绑在状态边界上,可以让多条转移路径复用同一套逻辑,以后新增一条从“待支付”到“已支付”的路径时,进入动作自动生效,不用再补代码。
3. 用 TypeScript 从零写一个可复用的状态机引擎
3.1 先设计数据结构和接口
市面上的状态机库不少,但很多时候项目里只需要一个轻量引擎,自己写反而更可控。这里我分享一个我用着很顺手的 TypeScript 版本,核心思路是:把状态转移规则声明成数据,引擎只负责执行。
先定义基础类型:
typescript复制type State = string;
type EventType = string;
interface TransitionRule {
from: State;
event: EventType;
to?: State; // 可选,允许事件不改变状态
guard?: (context: any) => boolean; // 守卫条件
action?: (context: any) => void; // 转移动作
}
interface StateMeta {
onEnter?: (context: any) => void; // 进入状态时执行
onExit?: (context: any) => void; // 退出状态时执行
}
这里有几个设计上的考量。状态和事件都用字符串而不是枚举,是因为字符串容易打印日志、存数据库、接可视化工具。你要是为了类型安全用枚举也行,但字符串在调试和持久化时确实方便。to 是可选的,这很重要,因为有些事件是“消费”型事件,比如重复提交,状态不用动,只需要报个错或者忽略。
guard 和 action 都接收同一个 context 对象。这个 context 就是业务上下文,订单对象、表单数据、设备信息都可以塞进去。状态机不持有业务数据,只在上层传进来的 context 上做判断和修改。这样引擎本身是无状态的逻辑裁决器,多个业务实例可以共用同一套规则。
3.2 引擎核心:转移查找和守卫条件
有了类型定义,引擎主体并不复杂,核心就是查表、判断、执行:
typescript复制class StateMachine {
private current: State;
private rules: TransitionRule[];
private states: Map<State, StateMeta> = new Map();
constructor(initialState: State, rules: TransitionRule[], states: StateMeta[] = []) {
this.current = initialState;
this.rules = rules;
states.forEach((s) => {
this.states.set(s.name, s);
});
}
get state(): State {
return this.current;
}
send(event: EventType, context?: any): boolean {
const rule = this.rules.find(
(r) => r.from === this.current && r.event === event
);
if (!rule) {
// 默认行为:生产环境忽略,开发环境抛错
console.warn(`非法转移: ${this.current} 收到事件 ${event}`);
return false;
}
if (rule.guard && !rule.guard(context)) {
return false;
}
const nextState = rule.to ?? this.current;
if (nextState !== this.current) {
this.states.get(this.current)?.onExit?.(context);
this.states.get(nextState)?.onEnter?.(context);
}
this.current = nextState;
rule.action?.(context);
return true;
}
}
这段代码里有几个容易被忽略的细节。第一,rule 查找用的匹配条件是 from + event,所以在同一个状态下,同一个事件只能有一条规则。如果你发现“同一个状态、同一个事件,但不同条件要走到不同目标”,那不是状态机的问题,是你设计的状态粒度太粗了,应该把前置状态拆开,或者把事件拆成两个不同名字的事件。
第二,守卫条件执行顺序在动作之前。守卫里只做判断,不要在里面塞业务副作用,不然同一个事件触发两次守卫,副作用也会执行两次。
第三,onExit 和 onEnter 要在转移动作之前执行。原因很直观:离开旧状态先做清理,进入新状态先做初始化,然后才是这个事件本身的业务动作。顺序错了会出现“新状态刚进入又被旧状态清理逻辑污染”的问题。
3.3 在订单流程中落地这个引擎
光有引擎还说明不了问题,具体接一个订单流程你就知道怎么用了。先定义状态元信息:
typescript复制const orderStates: StateMeta[] = [
{
name: '待支付',
onEnter: (ctx) => {
// 每次进入待支付,都重新生成支付单
if (!ctx.payment) {
ctx.payment = createPayment(ctx.orderId);
}
},
},
{
name: '已取消',
onEnter: (ctx) => {
// 取消后必须释放库存
releaseStock(ctx.orderId);
},
},
];
然后定义转移规则:
typescript复制const orderRules: TransitionRule[] = [
{
from: '新建',
event: '提交订单',
to: '待支付',
guard: (ctx) => ctx.stockAvailable,
action: (ctx) => {
ctx.submittedAt = Date.now();
},
},
{
from: '待支付',
event: '支付成功',
to: '已支付',
guard: (ctx) => ctx.payment && ctx.payment.status === 'success',
},
{
from: '待支付',
event: '支付超时',
to: '已取消',
},
{
from: '待支付',
event: '用户取消',
to: '已取消',
guard: (ctx) => !ctx.isShipped,
},
{
from: '已支付',
event: '申请退款',
to: '退款中',
guard: (ctx) => !ctx.isShipped,
},
{
from: '退款中',
event: '退款完成',
to: '已退款',
},
];
你发现没有?整个订单的状态流转完全被“声明”出来了,没有任何一处业务代码去 if (order.status === 'xxx')。调用只需要在业务入口统一执行:
typescript复制const orderMachine = new StateMachine('新建', orderRules, orderStates);
// 用户点击提交
orderMachine.send('提交订单', {
orderId: '123',
stockAvailable: true,
});
// 支付回调进来
orderMachine.send('支付成功', {
payment: { status: 'success' },
});
这种写法的好处是什么?新加需求时,比如“发货后七天自动确认收货”,你只需要在规则表里加一行:
typescript复制{
from: '已发货',
event: '自动确认',
to: '已完成',
}
不需要去翻业务代码,不需要担心改动影响其他流程。规则表本身就像项目文档,任何人接手都能快速看明白系统行为。
3.4 用单元测试把转移规则焊死
状态机最爽的一点就是可测性极高,因为每条转移规则都是独立的,你可以把整个流程用测试用例钉死。我用的是一个很朴素的测试习惯:每条规则写一个用例,非法转移写一个用例,特殊情况再补充。
typescript复制test('待支付 + 支付成功 -> 已支付', () => {
const sm = createOrderMachine();
sm.send('提交订单', { stockAvailable: true });
const ok = sm.send('支付成功', { payment: { status: 'success' } });
expect(sm.state).toBe('已支付');
expect(ok).toBe(true);
});
test('待支付 + 用户取消 -> 已取消', () => {
const sm = createOrderMachine();
sm.send('提交订单', { stockAvailable: true });
sm.send('用户取消');
expect(sm.state).toBe('已取消');
});
test('已支付状态收到重复支付事件应被拒绝', () => {
const sm = createOrderMachine();
sm.send('提交订单', { stockAvailable: true });
sm.send('支付成功', { payment: { status: 'success' } });
const result = sm.send('支付成功', { payment: { status: 'success' } });
expect(result).toBe(false);
});
这类测试写起来几乎是流水线作业,但价值非常大。业务规则后面变来变去的时候,这些测试就是安全网。改一条规则,跑一次测试,立刻知道有没有影响到别的路径。
4. 状态机编程在四个典型场景里的落地姿势
4.1 前端多步表单与复杂交互
前端是状态机最容易被忽视的战场。一个多步骤表单,比如“填写资料 -> 确认信息 -> 支付 -> 完成”,看起来只是页码切换,但一旦加入“上一步保存草稿”“校验失败回退到对应步骤”“支付回调滞后”这些逻辑后,代码复杂度立刻飙升。
我之前写过一个多步骤表单,一开始用 currentStep 数字加一堆 isSubmitting hasError 控制页面渲染,结果越写越乱。改成状态机之后,步骤对应的不再是数字,而是状态名:填写中、确认中、支付中、已完成。每一步有自己的进入动作(加载草稿、初始化校验规则),退出动作(保存当前填写内容)。事件齐了:下一步、上一步、提交、支付成功、支付失败。
这个设计最舒服的地方是,用户怎么走流程是被规则显式控制的。想禁止用户从“确认中”直接跳到“已完成”?不定义这条规则就行。浏览器后退按钮导致状态异常?在状态机里处理 popstate 事件,走正常的转移逻辑。
4.2 后端审批与订单流转
后端业务系统里最典型的就是审批流和订单流转。这类流程天生就是状态机:提交、待审核、审核通过、审核驳回、重新提交、撤回、归档。代码要处理的不仅是正常路径,还有各种超时、取消、异常回调。
我做过一个审批系统,最头疼的不是正常流程,而是并发问题。审批人同时点了“通过”和“驳回”,或者系统回调重复触发。用状态机后,这种问题天然被解决:如果规则里没有定义“已通过 + 驳回”这个转移,那么第二个事件进来,引擎直接返回 false,业务上只需要提示“操作已处理,请刷新页面”。
后端用状态机,我还会特别强调一件事:状态的持久化。状态机引擎本身不负责存状态,你需要把当前状态存进数据库。但写入和事件处理之间要注意事务边界,通常是一个事件对应一个数据库事务,状态字段、业务字段一起更新,避免状态和业务数据不一致。
4.3 嵌入式设备的按键与连接态管理
嵌入式领域用状态机简直是祖传手艺,原因很简单:资源有限,不能靠复杂的抽象兜底。一个按键,按下、释放、长按、双击,每个动作在不同状态下行为不同。不用状态机写出来的回调,大概率是一堆定时器和标志位互相纠缠。
拿一个按键模块举例,状态可以划分为:空闲、按下、长按确认、释放。事件有:按下、释放、定时器超时。在空闲状态收到“按下”,进入“按下”并启动长按定时器;在“按下”状态收到“定时器超时”,进入“长按确认”;在“按下”状态收到“释放”,判断为短按,回到“空闲”。
这个设计在嵌入式里还有个额外好处:中断处理和主循环可以解耦。中断服务程序里只负责记录事件,主循环跑状态机处理事件。这样既不会在中断里做耗时操作,也不会因为逻辑复杂导致中断响应变慢。无线连接管理也是同样的套路:扫描中、已连接、重连中、断开,每个状态配合超时定时器,逻辑一目了然。
4.4 异步编排:把“业务规则”显式化
异步场景是我自己踩坑最多的领域。一个下载任务,涉及文件拉取、校验、写入、重试多个阶段,每个阶段都是异步回调。如果不用状态机,你会在回调里各种判断 task.status,然后随手把它改成另一个值,时间一长整个状态空间变成一团浆糊。
状态机处理异步的正确姿势是:每个耗时操作都是一个“事件”的发起端,操作完成后再发另一个事件。比如:
- 空闲 -> 收到“开始下载” -> 下载中(发起异步下载请求)
- 下载中 -> 收到“下载完成” -> 校验中(发起校验请求)
- 下载中 -> 收到“下载失败” -> 失败(可重试)
- 失败 -> 收到“重试” -> 下载中(重新发起下载请求)
动作里可以发起异步任务,但状态机本身是同步处理的。异步结果通过事件的形式回到状态机。这个模式避免了“状态机引擎在异步回调里迷路”的问题,也天然支持了“超时取消”“失败重试”这类策略。
5. 状态机编程的十个坑与我的避坑经验
5.1 状态表爆炸:什么时候该拆分层状态机
状态机用的时间长了,会遇到一个新问题:状态越来越多,规则表越来越长,长得像一本百科全书。比如你在做支付系统,主状态有创建、支付中、已支付、已退款,每个主状态下面还有内部子状态,比如支付中又分跳转中、确认中、银行回调中。如果全部摊平用一个状态表示,规则表会是笛卡尔积级别的膨胀。
遇到这个情况就该考虑分层状态机了。主流程是一层,每个主状态内部再嵌套子状态机。子状态机负责内部细节,主状态机只关心“什么时候从支付中变成已支付”。这个方式可以显著降低单层规则表的复杂度,但代价是实现复杂了,所以我的建议是:只有在状态表超过 20 条规则,且明显出现多个内部子流程时才做分层,否则平铺更清晰。
还有一个土办法可以缓解状态爆炸:把“状态”和“阶段”分开。阶段是业务主流程,状态是阶段内的细分情况。数据库里存两列,阶段用主状态机管理,细分情况用普通业务字段表达。很多场景根本不需要完整的分层状态机,两阶段就能解决。
5.2 把业务判断塞进 guard,导致状态机变隐形
状态机的规则表之所以清晰,是因为它能以“状态 + 事件”快速定位行为。但如果你的 guard 里塞了几十行复杂逻辑,别人看规则表的时候根本不知道这条转移什么时候能触发。
我见过最夸张的一个项目,规则表上明明写着“待支付 + 用户取消 -> 取消中”,结果代码里 guard 有二十多行判断,涉及会员等级、优惠券状态、库存锁定情况。结果这个“用户取消”十个里面有八个走不通,线上问题排查时压根没人敢碰那堆逻辑。
我的原则是:guard 只做轻量判断,判断因子尽量是 context 上的简单字段。复杂业务逻辑放在进入动作或转移动作里做,做完通过事件继续驱动。如果守卫条件实在复杂,优先考虑把这些条件纳入状态定义,比如把“待支付”拆成“待支付-可取消”和“待支付-不可取消”,让规则表自己说话。
5.3 千万不要在转移动作里做异步复杂逻辑
这个坑我得反复强调。状态机的动作函数是同步的,但你经常需要在动作里触发异步操作,比如发网络请求、写数据库、调第三方接口。如果你在动作里直接 await,状态机已经切到下一个状态了,异步逻辑还没执行完,这时候来了一个别的事件,整个状态就乱了。
正确的做法是:动作里只做两件事,一是记录事件需要的参数,二是发起异步任务,但立刻返回。异步任务完成后再通过事件进入状态机。代表示例就是下载任务:进入“下载中”的动作里 fetch 文件,同时立刻返回;等 fetch 完成,再 send('下载完成') 事件,让状态机决定下一步。
有人会问,那 fetch 失败怎么办?很好办,失败也发事件:send('下载失败', { error })。这样错误处理被纳入了统一的事件流,而不是散落在回调里。整个系统的行为在规则表里依然是完整可读的。
5.4 状态机库到底选现成的还是自己写
要不要引入第三方状态机库,是我经常被问到的问题。我的答案取决于项目复杂度。如果状态数量在五个以内,转移规则简单,自己写一个类似上面这种几十行的引擎完全够用,还省依赖。但如果状态多、要可视化调试、要时间旅行、要考虑分层状态机,现成库能省非常多开发量。
自己写的优势是零依赖,逻辑完全可控,实现也不难。缺点是生态是零,没有可视化工具,没有调试面板,所有排查都得靠日志硬扛。用成熟库的优势是功能全面,社区方案成熟,但上手成本和学习曲线也存在,而且有些复杂库对团队的抽象能力要求很高。
我的建议是,如果团队里有人已经熟练使用状态机,且业务复杂度确实高,直接引入成熟库。如果是第一次尝试,先从自己写一个小引擎开始,把概念摸透了再决定要不要换。我自己的项目经历是,早期用自研引擎,后来业务复杂度上来之后换成了开源方案,平滑过渡没有任何问题,因为核心的状态机概念是通用的。
5.5 忘记持久化和恢复,系统重启后状态全丢
这个坑看起来基础,但真的很常见。状态机跑在内存里,状态变来变去很爽,但一旦进程重启,内存里的状态就没了。你必须在每次转移成功后把当前状态持久化,要么存数据库,要么存缓存,否则用户刷新页面后发现自己的订单状态回到了“新建”,那体验就崩了。
我处理订单状态机的代码里,send 方法调用成功后,一定会跟着一次持久化操作。而且要注意,持久化和状态更新要在同一个事务里完成,否则状态先变、数据没落盘,万一崩溃就出现“代码里是已支付、数据库里是待支付”的分裂状态。
5.6 日志要带状态变化前后,不然排查问题等于盲人摸象
状态机的调试优势很大程度依赖于日志。如果日志只写“当前状态:已支付”,那排查问题时你根本不知道它是从哪个状态变过来的、是因为哪个事件变过来的。
我的日志模板是固定的:状态转移: [旧状态] --事件--> [新状态] | 守卫结果 | 动作执行结果 | 业务编号。每次线上出问题,先拉这条日志流,立刻能看到整个事件序列和时间线。哪个事件被拒绝了,哪个守卫条件没通过,一目了然。我在大型项目里排查流程问题时,超过一半的情况都是靠状态转移日志直接定位的。
5.7 不要用状态机管所有数据,它只管“流转规则”
新手容易犯的一个错,是试图把系统的所有状态都塞进状态机。比如订单状态机里放一个字段存订单金额,再来一个字段存商品列表。等状态一多,context 变成一个巨大对象,状态机变成一个大杂烩。
状态机应该只管“状态流转规则”,业务数据放业务对象,是另一回事。状态机的 context 只是把业务对象传递进来给你做判断和操作,别把它当成万能数据库。保持状态机的职责单一,它的可维护性才会持续高。
5.8 状态定义的粒度要在“清晰”和“简洁”之间反复斟酌
状态定义得太粗,比如只分成“待处理”和“已处理”,你会发现大量的业务判断还是散落在回调里,状态机只是个空壳。状态定义得太细,每个细分情况都是一个状态,规则表膨胀到没法维护。
我的实际操作是:每次抽象状态时问自己,这个状态单独存在,会不会影响事件的判断和动作的执行?如果两个状态在规则表里的所有表现一模一样,它们就该合并。如果同一个状态在不同事件下的行为差异巨大,说明它该拆成两个。
5.9 转移事件命名要像“业务命令”,而不是“代码实现”
事件命名我建议用业务语言,不要用代码视角。比如“用户点击按钮”不是好事件名,“提交订单”“确认支付”“取消申请”才是。事件名描述的是“发生了什么业务行为”,而不是“哪个控件被触发了”。这样可以做到业务和代码对齐,产品经理看的流程文档和程序员写的规则表能直接对上。
5.10 别把状态机当成万能药,它只是一把手术刀
最后一条是心态上的。状态机让复杂流程变清晰,但它无法替代你对业务的理解,也无法自动帮你判断哪些状态是合理状态。工具再强,业务梳理做不好,状态机只会把你的混乱固化成一张复杂到没法看的规则表。
我的体会是,状态机最大的价值不是它本身多高级,而是它强迫你在写代码前把业务想清楚。状态列全了吗?转移路径对吗?边界条件处理了吗?这些问题想明白了,用不用状态机,代码质量都已经提升了一大截。
我个人这几年的经验是,状态机用得越多,越能体会到“明确状态、显式转移”这句话的分量。一个流程如果能在纸面上画成一张清晰的表格,它在代码里就一定能整齐地落地。下次再遇到复杂逻辑,先把状态列出来,把转移画成表,再动手写代码,你会发现很多混乱其实一开始就可以避免。从一个小模块开始尝试吧,状态机编程这条路,值得走。
