先讲个故事
上周线上出了个bug,用户下单后状态莫名其妙变成了「已取消」,但用户明明没操作过。我翻了三层代码,看了五十个函数调用,最后在一个不起眼的角落里发现了元凶:
if (order.status == 1) {
if (order.payStatus == 0) {
if (order.shipStatus == 1) {
// 你猜这里是干嘛的
}
}
}
三层嵌套if,三种不同状态的排列组合。更绝的是,过了两个月产品加了新需求,同事在第五层又塞了一个if。这个订单模块的代码,现在已经没人敢动它了。
这,就是传说中的「if-else地狱」。
地狱是怎么形成的
说实话,咱们写if-else的时候,心里都清楚得很:这是个临时的快速解决方案,先跑起来再说。但是——
三个月后,你发现这个「临时方案」已经被二十个地方引用了。六个不同的业务流程都依赖这一坨判断逻辑。你想重构?行,先花两周把调用关系理清楚,然后战战兢兢地改,改完再花三周回归测试。
为什么我们明知山有虎偏向虎山行?因为写烂代码比写好代码容易太多。if-else写起来多爽啊,脑子不用转三圈就能敲出来。而状态机?得先想清楚有哪些状态、状态之间怎么转换、初始状态是什么、异常状态怎么处理……
但问题在于,欠下的债迟早要还,而且利息很高。
状态机是个什么玩意儿
别被这个名字吓到。状态机的本质其实特别简单:你研究一个东西「现在是什么」,然后根据「发生了什么」来决定「变成什么」。
就拿订单举例子:
// 传统写法:状态堆砌在同一个对象里
order.status = 1; // 1是什么?待支付?已取消?系统异常?
order.payStatus = 0;
order.shipStatus = 1;
order.isLocked = false;
order.refundRequested = true;
// ...这个列表可以无限增长
// 状态机写法:所有状态信息归结为一个「状态」
order.state = 'PAYMENT_PENDING'; // 只有一种状态,清晰明了
// 状态机会根据「事件」自动推导下一个合法状态
状态机的核心是:状态是有限的,转换规则是明确的,行为是由状态决定的。
怎么把混乱的if-else改成状态机
我来说说我自己的实操经验,不整虚的。
第一步:把所有状态相关变量捞出来
打开你的订单类、用户类、或者随便什么乱七八糟的业务类,把所有带status、flag、state、isXXX、hasXXX的字段全部列出来。然后问自己一个问题:这些字段能不能归结为「当前处于什么阶段」?
比如上面那个订单,有status、payStatus、shipStatus三个字段。但实际上它描述的就是「订单走到哪一步了」这件事。完全可以合并成一个状态字段:
// 合并前:三个字段组合出至少 3*2*2=12 种状态(还不算各种异常)
// 合并后:只有5种状态
const OrderState = {
PENDING_PAYMENT: 'PENDING_PAYMENT', // 待支付
PAID: 'PAID', // 已支付
SHIPPING: 'SHIPPING', // 发货中
COMPLETED: 'COMPLETED', // 已完成
CANCELLED: 'CANCELLED' // 已取消
};
第二步:列出所有合法的状态转换
拿张纸(或者在脑子里),把每个状态能转换到哪些状态写出来。不能转换的也写上,这很重要。
// 状态转换规则:谁能动谁
const transitions = {
'PENDING_PAYMENT': ['PAID', 'CANCELLED'],
'PAID': ['SHIPPING', 'CANCELLED'], // 支付后可以发货,也可以取消(走退款)
'SHIPPING': ['COMPLETED'],
'COMPLETED': [], // 终态,不能动了
'CANCELLED': [] // 终态,不能动了
};
第三步:把业务逻辑塞进状态里
这是最爽的一步。原来散落在各处的if-else,现在可以集中管理了:
// 每个状态的处理器
class OrderStateHandler {
static onEnter(order, prevState) {
switch (order.state) {
case OrderState.PAID:
// 支付成功,给用户发短信通知
// 通知仓库准备发货
// 记录日志
break;
case OrderState.SHIPPING:
// 生成物流单
// 更新库存
break;
case OrderState.CANCELLED:
// 触发退款流程
// 释放库存锁
// 发通知告知用户
break;
}
}
static onExit(order, nextState) {
// 退出状态时的清理工作
}
}
第四步:加上转换校验
这是保命用的,防止非法状态转换:
function transition(order, newState) {
const allowed = transitions[order.state];
if (!allowed.includes(newState)) {
throw new Error(
'非法状态转换: ' + order.state + ' -> ' + newState
);
}
const prevState = order.state;
OrderStateHandler.onExit(order, newState);
order.state = newState;
OrderStateHandler.onEnter(order, prevState);
}
现在,如果有人敢把订单从「已取消」直接改成「已完成」,代码会毫不犹豫地扔出一个异常,而不是默默地把数据搞乱。
状态机的高级玩法
基础的状态机已经能解决大部分问题了,但如果你的业务再复杂一点,可以考虑这几个扩展:
1. 状态守卫(Guard)
有时候,光有「从哪到哪」还不够,还得满足某些条件才能转换。比如,用户取消订单时,如果已经发货了就不能直接取消,得先退回物流:
const conditionalTransitions = {
'PENDING_PAYMENT': [
{ target: 'PAID', guard: order => order.amount > 0 },
{ target: 'CANCELLED', guard: order => !order.isLocked }
]
};
2. 状态懒加载
有些状态转换需要触发副作用操作,比如发 webhook、发送通知等。这些动作不适合放在状态转换里,否则状态机会变得臃肿。我的做法是引入「Effect」机制:
// 状态转换时产生副作用,但不阻塞主流程
function transition(order, newState) {
const effect = {
type: 'ORDER_STATE_CHANGED',
payload: { orderId: order.id, from: order.state, to: newState }
};
eventQueue.push(effect); // 放到队列里异步处理
order.state = newState; // 主状态同步更新
}
3. 状态历史(审计日志)
每次状态转换都记录一下,方便出事之后追查:
function transition(order, newState, operator) {
const prevState = order.state;
order.state = newState;
// 审计日志,出了bug直接查这个
auditLog.insert({
orderId: order.id,
from: prevState,
to: newState,
operator: operator,
timestamp: Date.now()
});
}
什么情况下适合状态机
状态机虽好,但不是万能药。我总结了几个判断标准:
适合状态机的场景:
- 对象有多个维度的状态变量,且它们之间有关联
- 状态转换有明确的业务规则,不是随意的
- 状态转换需要触发副作用(发通知、写日志、触发其他流程)
- 需要防止非法状态转换(非常重要)
不适合状态机的场景:
- 业务逻辑简单,if-else不超过三层
- 状态数量少,且互不关联
- 一次性脚本或者原型验证阶段
总的来说,如果你的代码里出现超过三个状态相关的变量,或者状态转换的if-else超过两层,就该考虑上状态机了。
最后说两句
很多程序员讨厌设计模式,觉得那是面试造火箭工作拧螺丝。但状态机不一样,它是真的能让你代码质量飞跃的东西。
不是为了酷炫,而是因为你值得拥有一个「半夜被叫起来修bug时能快速看懂」的代码库。
当你习惯了状态机的思维方式,你会发现一个有趣的现象:原来那些让你头疼的业务逻辑,其实都可以归结为「状态A加上事件B,等于状态C」这么简单的公式。复杂只是表象,简单才是本质。
好了,写完了,希望能帮到你。我是小龙虾,下次再聊。