状态机设计理念与实现
状态机的核心不是简单地修改一个状态字段,而是先定义状态之间的合法关系,再让所有状态变更都经过这套关系校验。它解决两个问题:
- 当前状态能否转换成目标状态。
- 状态变更时应该如何统一校验、记录和执行后续逻辑。
可以把状态机理解为一张有向图:每个状态是图里的节点,状态之间允许发生的转换是节点之间的边。只要某条边存在,就说明这个转换是合法的;如果边不存在,就说明不能从当前状态直接变更到目标状态。
第一步:设计状态机
设计状态机时,先不要急着写 if/else。应该先回答三个问题:
- 系统里有哪些明确的状态。
- 每个状态可以流转到哪些状态。
- 发生状态变更时,是否需要校验条件、执行动作或记录日志。
例如存在四个状态:
text
A, B, C, D
如果它们之间可以互相转换,但不能转换到自身,那么状态关系可以表示为:
text
A -> B
A -> C
A -> D
B -> A
B -> C
B -> D
C -> A
C -> B
C -> D
D -> A
D -> B
D -> C
这里的设计重点是:状态变更必须被规则驱动。业务代码不应该到处直接写 status = nextStatus,而是应该调用统一的状态机入口,让状态机判断这次变更是否合法。
状态机的职责可以拆成三层:
- 状态定义:列出所有可能出现的状态。
- 流转规则:描述哪些状态之间可以转换。
- 流转执行:在合法的前提下完成状态变更,并处理校验、日志、事件等副作用。
这样做的好处是状态关系集中、可读、可测试,后续新增状态或调整规则时,不需要在业务代码里到处寻找分散的判断逻辑。
第二步:设计数据结构
状态机可以使用图的数据结构进行描述。最直接的方式是使用邻接矩阵。
假设横向表示目标状态,纵向表示当前状态,那么矩阵中第 A 行第 B 列的值代表:
text
当前状态 A 是否允许转换成目标状态 B
示例矩阵如下:
| 当前状态 \ 目标状态 | A | B | C | D |
|---|---|---|---|---|
| A | 0 | 1 | 1 | 1 |
| B | 1 | 0 | 1 | 1 |
| C | 1 | 1 | 0 | 1 |
| D | 1 | 1 | 1 | 0 |
其中:
1表示允许转换。0表示不允许转换。- 横看代表状态的出度。
- 纵看代表状态的入度。
因此:
- 从横向看,能看到某个当前状态可以转入哪些目标状态。
- 从纵向看,能看到哪些当前状态可以转入某个目标状态。
在代码里,矩阵可以这样表示:
js
const states = ["A", "B", "C", "D"];
const transitionMatrix = [
[0, 1, 1, 1],
[1, 0, 1, 1],
[1, 1, 0, 1],
[1, 1, 1, 0],
];
为了便于查询,需要建立状态和矩阵下标之间的映射:
js
const stateIndex = {
A: 0,
B: 1,
C: 2,
D: 3,
};
这样判断 A 是否可以转换成 B 时,只需要读取:
js
transitionMatrix[stateIndex.A][stateIndex.B] === 1;
如果状态数量不多、规则固定,邻接矩阵非常直观。它的优点是判断速度快,结构清晰,适合表达"任意两个状态之间是否允许转换"的关系。
如果状态数量较多,或者每个状态只允许转换到少量状态,也可以使用邻接表:
js
const transitions = {
A: ["B", "C", "D"],
B: ["A", "C", "D"],
C: ["A", "B", "D"],
D: ["A", "B", "C"],
};
邻接表更节省空间,也更接近业务语义。它表达的是:每个状态后面列出的数组,就是该状态允许转换到的目标状态。
第三步:实现状态机
状态机实现时,核心入口通常只需要两个能力:
- 判断能否转换:
canTransition(from, to)。 - 执行状态变更:
transition(entity, to)。
一个基于邻接矩阵的基础实现如下:
js
class StateMachine {
constructor(states, transitionMatrix) {
this.states = states;
this.transitionMatrix = transitionMatrix;
this.stateIndex = states.reduce((indexMap, state, index) => {
indexMap[state] = index;
return indexMap;
}, {});
}
hasState(state) {
return Object.prototype.hasOwnProperty.call(this.stateIndex, state);
}
canTransition(from, to) {
if (!this.hasState(from) || !this.hasState(to)) {
return false;
}
const fromIndex = this.stateIndex[from];
const toIndex = this.stateIndex[to];
return this.transitionMatrix[fromIndex][toIndex] === 1;
}
transition(entity, to) {
const from = entity.status;
if (!this.canTransition(from, to)) {
throw new Error(`invalid state transition: ${from} -> ${to}`);
}
entity.status = to;
return entity;
}
}
使用方式:
js
const machine = new StateMachine(states, transitionMatrix);
const order = {
id: 1,
status: "A",
};
machine.transition(order, "B");
console.log(order.status); // B
这个实现的关键点在于,业务对象本身只保存当前状态,例如 entity.status。真正的状态规则不散落在业务代码里,而是由状态机统一维护。业务代码只提出"我要从当前状态变更到目标状态",状态机负责判断这件事是否允许发生。
在真实业务中,状态变更通常还会包含更多逻辑,例如:
- 变更前校验:检查权限、数据完整性、前置条件。
- 变更中处理:更新数据库中的状态字段。
- 变更后动作:记录日志、发送通知、触发事件。
可以在基础状态机上继续扩展钩子:
js
class StateMachineWithHooks extends StateMachine {
constructor(states, transitionMatrix, hooks = {}) {
super(states, transitionMatrix);
this.hooks = hooks;
}
transition(entity, to, context = {}) {
const from = entity.status;
if (!this.canTransition(from, to)) {
throw new Error(`invalid state transition: ${from} -> ${to}`);
}
const transitionKey = `${from}->${to}`;
const hook = this.hooks[transitionKey];
if (hook?.before) {
hook.before(entity, context);
}
entity.status = to;
if (hook?.after) {
hook.after(entity, context);
}
return entity;
}
}
示例:
js
const machine = new StateMachineWithHooks(states, transitionMatrix, {
"A->B": {
before(entity) {
if (!entity.id) {
throw new Error("entity id is required");
}
},
after(entity) {
console.log(`entity ${entity.id} changed to ${entity.status}`);
},
},
});
这样,状态机就从一个单纯的合法性判断器,变成了状态流转的统一执行入口。
总结来看,状态机的核心实现路径是:
- 先设计状态和流转规则,把状态关系抽象成有向图。
- 再选择合适的数据结构,使用邻接矩阵或邻接表描述状态之间的边。
- 最后提供统一的转换入口,在入口中完成合法性判断、状态变更和扩展动作。
只要所有状态变更都经过这个入口,状态机就能保证系统中的状态始终按照预先定义好的规则流转。