Article
状态机
设计思路
状态机要解决的问题不是“怎么画状态图”,而是“怎么让系统只处在合法状态里”
当代码只有一个布尔值时,状态通常很好理解。比如 open = true 表示弹窗打开,open = false 表示弹窗关闭
但真实代码很快会出现多个变量共同描述一个阶段。搜索弹窗里会有 open、mounted、closing,算法里会有评估、选择、交叉、变异,连接对象里也会有关闭、监听、已连接等阶段
状态机的意义,就是把这些阶段、事件和副作用整理成明确规则。本文会在叙述中穿插 GPP 的状态模式、遗传算法引擎的设计思路,以及 UI 按钮和搜索弹窗的状态转换
状态、事件、转移
状态机可以先拆成四个元素:状态、事件、转移、副作用
- 状态:系统当前处在哪个阶段
- 事件:触发变化的输入
- 转移:当前状态收到事件后进入哪个状态
- 副作用:转移时额外执行的动作
用伪代码表达就是:
let mut state = State::Initial;
fn dispatch(state: State, event: Event) -> State {
let next_state = transition(&state, &event);
run_side_effects(&state, &event, &next_state);
next_state
}
state = dispatch(state, event);这里最重要的是 transition(state, event)。同一个事件在不同状态下可以有不同结果;同一个状态也不一定响应所有事件
因此,状态机不是把 if 全部消灭,而是让每个 if 有明确归属:它要么判断当前状态,要么判断当前事件
从变量组合到状态
很多状态机不是一开始就被设计出来的,而是从多个状态变量里“还原”出来的
以搜索弹窗为例,open、mounted、closing 三个布尔值共有 8 种组合。但真正稳定、能解释的组合只有 3 种
<script setup>
import { computed, ref } from 'vue'
const open = ref(false)
const mounted = ref(false)
const closing = ref(false)
const dialogState = computed(() => {
if (!open.value && !mounted.value && !closing.value) return 'idle'
if (open.value && mounted.value && !closing.value) return 'open'
if (!open.value && mounted.value && closing.value) return 'closing'
return 'invalid'
})
</script>idle 表示弹窗没有挂载。open 表示弹窗已经挂载并显示。closing 表示弹窗不再显示为打开态,但 DOM 仍然保留,用来播放关闭动画
其他组合就很难解释:
<script setup>
const invalidDialogStates = [
{ open: true, mounted: false, closing: false },
{ open: false, mounted: false, closing: true },
{ open: true, mounted: true, closing: true },
]
</script>这一步是状态机建模的起点:先找出合法组合,再给合法组合命名。命名之后,代码不再只是几个布尔值,而是几个可以讨论的阶段
事件推动状态变化
状态不会自己变化,必须由事件推动。对 UI 来说,事件可能是点击按钮、按下快捷键、按下 Escape、动画结束或计时器触发
搜索弹窗的打开过程可以写成:
<script setup>
let closeTimer = 0
function openSearch() {
if (closeTimer) {
window.clearTimeout(closeTimer)
closeTimer = 0
}
mounted.value = true
closing.value = false
open.value = true
}
</script>这里不只是把 open 设为 true。它还先清理旧的关闭计时器,避免用户在关闭动画尚未结束时重新打开弹窗,却被旧计时器稍后重置
关闭过程更能体现状态机的价值:
<script setup>
const motionEnabled = ref(true)
function closeSearch() {
if (!mounted.value || closing.value) return
open.value = false
closing.value = true
if (!motionEnabled.value) {
resetSearchState()
return
}
closeTimer = window.setTimeout(() => {
closeTimer = 0
resetSearchState()
}, MODAL_CLOSE_DURATION_MS)
}
</script>mounted == false 表示弹窗已经不在页面里,不需要关闭。closing == true 表示关闭流程已经启动,不应该重复启动
motionEnabled 决定是否等待动画结束。用户偏好减少动画时,组件可以直接重置状态;否则要等一小段时间,让关闭动画播放完成
这就是“转移”和“副作用”的关系:关闭弹窗的状态转移是 open -> closing -> idle,副作用是启动计时器、清理搜索词、重置选中项
<script setup>
function dialogTransition(state, event) {
if (state === 'idle' && event === 'openSearch') {
return 'open'
}
if (state === 'open' && event === 'closeSearch') {
return 'closing'
}
if (state === 'closing' && event === 'closeTimer') {
return 'idle'
}
return state
}
</script>
状态中的行为
有些状态只是阶段名,有些状态还携带行为,游戏编程模式的状态模式讲的就是后一种情况
设想一个连接对象。它可能处于 closed、listening、established 等状态。收到 open、close、acknowledge 时,行为取决于当前状态
如果把所有行为都写进连接对象,就会出现事件分支嵌套状态分支:
enum class State {
Closed,
Listening,
Established,
};
enum class Event {
Open,
Close,
Acknowledge,
};
void TCPConnection::handle(Event event) {
if (event == Event::Open) {
if (state_ == State::Closed) {
state_ = State::Listening;
} else if (state_ == State::Established) {
return;
}
}
if (event == Event::Close) {
if (state_ == State::Listening) {
state_ = State::Closed;
} else if (state_ == State::Established) {
sendFinishPacket();
state_ = State::Closed;
}
}
if (event == Event::Acknowledge) {
if (state_ == State::Listening) {
state_ = State::Established;
}
}
}这种写法的问题是增长方向太多。新增一个状态,要检查所有事件;新增一个事件,也要检查所有状态
状态模式会把行为移动到状态对象中。连接对象只保存当前状态,并把事件交给当前状态处理
class TCPState {
public:
virtual ~TCPState() = default;
virtual TCPState* handle(TCPConnection& connection, Event event) = 0;
};
class TCPClosed : public TCPState {
public:
TCPState* handle(TCPConnection&, Event event) override {
if (event == Event::Open) {
return new TCPListening();
}
return this;
}
};
class TCPListening : public TCPState {
public:
TCPState* handle(TCPConnection&, Event event) override {
if (event == Event::Acknowledge) {
return new TCPEstablished();
}
if (event == Event::Close) {
return new TCPClosed();
}
return this;
}
};
class TCPEstablished : public TCPState {
public:
TCPState* handle(TCPConnection& connection, Event event) override {
if (event == Event::Close) {
connection.sendFinishPacket();
return new TCPClosed();
}
return this;
}
};
void TCPConnection::dispatch(Event event) {
TCPState* next = state_->handle(*this, event);
if (next != state_) {
delete state_;
state_ = next;
}
}这不是为了套设计模式,而是为了让状态相关行为靠近状态本身。状态越影响行为,越适合把状态从普通枚举提升为状态对象
如果只是切换按钮图标或 CSS class,状态对象通常太重。此时用枚举、reducer 或少量状态变量就足够
流程也是状态机
状态机不只适合 UI 和连接对象,也适合描述按阶段推进的流程
遗传算法引擎就是典型流程。参考genetic-algorithm-rust的设计,一个引擎会通过配置描述种群规模、基因空间、选择策略、交叉策略、变异策略、精英保留和停止条件
这些配置不是孤立参数。它们共同决定每一代种群如何从“候选解集合”推进到“下一代候选解集合”

如果把每个阶段写成状态,流程会变得很清楚:
enum EvolutionState {
Initialized,
Evaluating,
CheckingStop,
Selecting,
Reproducing,
Terminated,
}
let config = EngineConfig::builder().build();
let mut state = EvolutionState::Initialized;
while !matches!(state, EvolutionState::Terminated) {
state = match state {
EvolutionState::Initialized => {
population = create_initial_population(&config.genes_domain);
EvolutionState::Evaluating
}
EvolutionState::Evaluating => {
evaluations = evaluate_population(&population);
stats = collect_generation_stats(&evaluations);
EvolutionState::CheckingStop
}
EvolutionState::CheckingStop if stop_condition_reached(&stats) => {
EvolutionState::Terminated
}
EvolutionState::CheckingStop => EvolutionState::Selecting,
EvolutionState::Selecting => {
elites = keep_best_individuals(&population, &evaluations);
parents = select_parents(&population, &evaluations, config.selection);
EvolutionState::Reproducing
}
EvolutionState::Reproducing => {
let children = crossover(&parents, config.crossover);
population = mutate(children, config.mutation);
population = merge(elites, population);
EvolutionState::Evaluating
}
EvolutionState::Terminated => EvolutionState::Terminated,
};
}这里的状态重点不是“不同行为属于不同对象”,而是“阶段顺序不能错”
不能在评估适应度之前选择父代,不能在检查停止条件之前盲目繁殖,也不能在满足终止条件后继续生成下一代。流程状态机把这些顺序约束写成了显式转移
let flow = [
EvolutionState::Initialized,
EvolutionState::Evaluating,
EvolutionState::CheckingStop,
EvolutionState::Selecting,
EvolutionState::Reproducing,
EvolutionState::Evaluating,
EvolutionState::Terminated,
];如果启用岛屿模型,还可以把“迁移”看成周期性插入的状态。每个岛独立完成评估、选择和繁殖,到达迁移间隔后,再把部分个体移动到其他岛
fn evolve_islands(world: &mut IslandWorld, generation: usize) {
world.evolve_each_island();
if generation % world.migration_interval == 0 {
world.migrate_individuals(world.topology, world.migration_count);
}
world.continue_next_generation();
}多目标优化也是同样思路。单目标模式通常追踪 best_fitness,多目标 NSGA-II 模式则追踪 Pareto 前沿。输出指标不同,但状态仍然围绕“评估 -> 判断 -> 生成下一代”推进
训练过程、构建流程、任务队列、数据处理 pipeline 都可以用类似方式分析。只要存在“必须先 A 再 B”的阶段顺序,就可以把它看成流程状态机
并行状态区域
一个系统经常不止一条状态线。把所有状态强行合成一个大枚举,反而会让状态数量膨胀
UI 按钮和搜索弹窗就是这种情况。弹窗是否打开、是否启用动画、搜索结果选中项、文章导航按钮模式,其实是几条相关但不同的状态线
<script setup>
const uiState = computed(() => ({
dialog: dialogState.value,
motion: motionEnabled.value ? 'enabled' : 'reduced',
selectedResultIndex: selectedResultIndex.value,
articleNav: articleNavMode.value,
}))
</script>dialog 决定弹窗是否挂载和展示。motion 决定关闭时是否等待动画。results 管理键盘选中项。articleNav 管理按钮在章节标题和导航图标之间切换
搜索结果选择可以单独看成一个小状态机:
<script setup>
const selectedResultIndex = ref(0)
function moveSelection(event, results) {
if (event.key === 'ArrowDown') {
selectedResultIndex.value = Math.min(
selectedResultIndex.value + 1,
Math.max(results.length - 1, 0),
)
}
if (event.key === 'ArrowUp') {
selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0)
}
}
watch(results, () => {
selectedResultIndex.value = 0
})
</script>它的目标很单纯:选中项不能越界。搜索结果变化时,旧索引可能不再有效,所以要回到 0
文章导航按钮也可以单独看成 reducer 状态机:
<script setup>
const articleNavMode = ref('heading')
function dispatchArticleNav(action) {
if (action === 'toggle') {
articleNavMode.value = articleNavMode.value === 'heading' ? 'icons' : 'heading'
}
if (action === 'reset') {
articleNavMode.value = 'heading'
}
}
</script>对应的状态图很小:
<template>
<button @click="dispatchArticleNav('toggle')">
{{ articleNavMode === 'heading' ? 'icons' : 'heading' }}
</button>
<button @click="dispatchArticleNav('reset')">
heading
</button>
</template>并行状态区域的判断标准是:这些状态是否可以同时存在
弹窗可以处于 open,同时动画可以是 enabled,结果可以选中第 2 项,文章导航可以处于 icons。它们不是互斥状态,因此不应该被塞进同一个枚举
状态机分类
从工程实践看,状态机可以按问题类型分类,而不是按术语堆叠分类
| 分类 | 关注点 | 适合场景 |
|---|---|---|
| 基础状态机 | 状态和事件之间的确定转移 | 开关、弹窗、播放器 |
| 行为状态机 | 不同状态下事件行为不同 | 连接、协议、编辑器模式 |
| 流程状态机 | 阶段必须按顺序推进 | 遗传算法、训练流程、任务队列 |
| 并行状态机 | 多条状态线同时存在 | 复杂组件、页面交互、异步任务 |
这些分类不是互斥的。搜索弹窗是基础状态机,也包含并行状态区域。遗传算法是流程状态机,但每个阶段内部也可以继续拆成更细的状态
分类的目的不是贴标签,而是帮助选择表达方式:简单状态用枚举,阶段流转用流程,状态携带大量行为时再考虑状态模式
如何识别状态机
判断一段代码是否适合用状态机思维整理,可以看这些信号:
- 多个布尔值共同描述一个阶段
- 某个事件在不同条件下行为不同
- 有动画、计时器、异步请求或清理动作
- 有“不能重复触发”的保护条件
- 有“必须先 A 再 B”的顺序要求
整理时可以先写伪代码,不急着引入状态机库
fn model_state_machine() {
let variables = list_state_variables();
let valid_states = name_valid_combinations(variables);
let events = list_events();
for event in events {
define_allowed_transition(valid_states, event);
attach_side_effects(event);
}
}如果这个过程能明显减少非法组合,状态机建模就是值得的。如果只是把一个简单布尔值包装成复杂对象,那就是过度设计
总结
状态机的本质,是把系统行为从散落的变量组合提升为明确的状态、事件、转移和副作用
UI 交互里,它能解释挂载、动画、键盘选择和按钮模式。算法流程里,它能约束阶段顺序。状态相关行为很重时,它还能把行为移动到状态对象中
写状态机之前,不一定要先引入库。先把合法状态写出来,把事件和转移写清楚,很多复杂逻辑就已经变得可枚举、可检查、可维护