你不需要状态机库
有限状态机是计算机科学中最古老的计算模型之一。它比网络还要古老,比你能想到的任何编程语言都要古老,甚至可能比你本人还要古老。问问Mealy (1955)或Moore (1956)就知道了。有限状态机 (FSM) 可以用任何现代语言使用控制流语句来实现,但所有这些语言中很可能都有一个(甚至多个)状态机库。
那么您是否需要一个库来在程序中创建和解释状态机?
不,但还有更多事情需要考虑。
您可能需要状态机
如果您不熟悉有限状态机(FSM),它们是一种使用 3 个主要构建块对状态逻辑进行建模的可视化和数学方法:
- 有限状态,代表不同的行为
- 事件,表示发生的可以改变状态的事情
- 转换,表示状态如何变化以及收到事件时执行的操作
任何可以描述为由于事件而随时间发生状态变化的事物,从组件特定的逻辑到应用程序流,甚至多个服务的编排,都可以在某种程度上用状态机来描述。
状态机可能是一种不同的、不为人所熟知的思考应用逻辑的方式,但它却极其有用。它并非从“自下而上”的角度(基于事件命令式地执行操作)来处理逻辑,而是采用“自上而下”的方法,主要考虑行为loading
,这些行为描述了逻辑在给定有限状态下(例如、editing
、等)将如何响应事件disabled
。
由于状态机具有显式声明的特性,它会迫使你思考整个逻辑流程(包括所有边缘情况),并且只要你的模型不允许,几乎不可能陷入“不可能状态”。只有定义的转换才会发生;如果发生了意外的转换,则意味着存在一个隐式状态机来处理该转换。状态机的目标是消除隐式转换,以便我们能够准确地知道在任何状态下,对于任何潜在事件,可能发生的情况。
状态机并非万能的解决方案——就像其他任何东西一样,它们适用于某些用例(工作流、流程、模式、状态等),但并非适用于所有用例。您不应该在所有情况下都使用状态机,甚至不应该总是显式地实现它们(抽象就是为此而生的)。它们是很好的重构目标,并且非常适合用纸笔直观地建模逻辑,即使您最终决定不在代码中使用它们。但是,当处理处理显式状态、事件和转换的逻辑时(令人惊讶的是,这些往往是应用程序逻辑的主体),状态机是一个绝佳且自然的解决方案。
从状态、事件和转换的角度思考还有很多其他好处,但这不是本文的重点(这是我写的另一篇文章的重点)。假设你已经确信在应用程序的某些部分使用状态机。你应该使用库吗?
你不需要状态机库
由于状态机并非新概念,并且可以使用任何现代语言的内置语言功能来实现,因此状态机库并非必需。同样,您只需要以下三个构建块:
- 有限状态
- 活动
- 过渡
转换将一切联系在一起。转换由状态转换函数表示,其数学形式如下:
𝛅 : 𝑆 𝗑 𝛴 → 𝑆
……这可能不太合理(即使你会希腊语)。下面这句话可能更容易理解:
转换:(状态,事件)=> 下一个状态
在 JavaScript 中,我们可以将其表示为reducer,它是一个将值(事件)减少为单个累积值(状态)的函数:
function transition(state, event) {
// state machine goes here, which
// determines the next state based on the
// current state + received event
// ...
return nextState;
}
现在,让我们
画出猫头鹰的其余部分实现状态机的其余部分!
使用switch
语句
通常,当我们确定行为(“接下来会发生什么”)时,我们倾向于根据事件来决定接下来会发生什么。有限状态是事后才考虑的,即使它真的被考虑过。这会导致逻辑不通,if
到处都是 语句:
// ❌ Event-first approach
switch (event.type) {
case 'DATA_RECEIVED':
// defensive programming
if (state.isLoading) {
// do something
} else {
// ...
}
}
// ...
}
相比之下,状态机按有限状态对行为进行分组,并根据收到的事件缩小下一步发生的情况:
// ✅ Finite-state-first approach
switch (state.status) {
case 'loading':
// narrow based on event
switch (event.type) {
case 'DATA_RECEIVED':
// do something, and possibly
// change the finite state
// ...
}
// ...
}
作为代码作者,事件优先(自下而上)的方法可能对您来说没什么问题;毕竟,如果它有效,它就有效。采用“有限状态优先”(自上而下)方法并使用状态机的主要优势之一是,逻辑不仅更清晰(因为它按有限状态分组),而且更健壮:您可以确保事件不会在不该处理的状态下被错误处理。换句话说,您可以避免不可能的状态和不可能的转换,而无需在代码中堆砌if
大量语句和过度的防御性编程。
我也喜欢把状态机看作是一种正式的逻辑沟通方式。如果你要描述上述逻辑,那么采用事件优先的方法,它听起来会是这样的:
当接收到数据时,执行某些操作,但前提是“加载”标志为真。
采用有限状态优先方法:
在“加载”状态下,当接收到数据时,执行某些操作。
哪一个听起来更自然、更容易理解?对我来说,第二种说法的认知负担更小。对事件的反应是按行为(有限状态)分组的,而不是不分组的。
使用switch
带有函数的语句
由于有限状态可以被视为一种对行为进行分组的方式,因此组织switch
语句的另一种方法是将每个有限状态的行为“分组”为一个函数:
// 'loading' behavior
function loadingState(state, event) {
// switch only on the event
switch (event.type) {
case 'DATA_RECEIVED':
return {
...state,
status: 'success'
}
}
// ...
}
}
function dataMachine(state, event) {
switch (state.status) {
case 'loading':
// handle the event with 'loading' behavior
return loadingState(state, event);
}
// ...
}
}
Redux 风格指南建议中概述了这种方法:将 Reducer 视为状态机。这是一种非常有组织的方法,每个“行为函数”都可以单独测试,因为它们是独立的、纯粹的 Reducer。
使用对象
使用嵌套switch
语句可能会显得冗长,而使用函数来组织这些switch
语句虽然看起来更简洁,但却更加繁琐。毕竟,状态转换可以被认为是基于接收到的事件的(至少)两件事的配置:
- 下一个有限状态,如果它改变
- 已执行的任何操作(如果有)
一种简单的内置表示此类配置的方式是使用对象。我们可以创建一个对象结构,其中每个“状态节点”代表一个有限状态,并且该状态接受的每个事件都对应一个转换:
const machine = {
initial: 'loading',
states: {
// A finite "state node"
loading: {
on: {
// event types
DATA_RECEIVED: {
target: 'success',
// actions: [...]
}
}
},
// ...
}
};
// ...
这比嵌套语句简洁多了switch
!从这里开始,根据当前有限状态和接收到的事件确定下一个状态只需要两个关键的查找(有限状态和事件类型):
// ...
function transition(state, event) {
const nextStateNode = machine
// lookup configuration for current finite state
.states[state.status]
// lookup next finite state based on event type
.on?.[event.type]
// if not handled, stay on current state
?? { target: state.status };
return {
...state,
status: nextStateNode.target
}
}
transition({ status: 'loading' }, { type: 'DATA_RECEIVED' });
// => { status: 'success', ... }
您可能想知道为什么我没有在这里使用更简单的对象,您肯定可以这样做:
const transitions = {
loading: {
DATA_RECEIVED: 'success'
},
success: {/* ... */}
};
function transition(state, event) {
const nextStateTarget = transitions[state.status][event.type]
?? state.status;
return {
...state,
status: nextStateTarget
};
}
事实上,我鼓励将上述实现视为一种“转换表查找”;它有效,而且足够简单。然而,状态机处理的不仅仅是下一个有限状态;如果我们想要编码动作(状态机术语,指效果),我们需要一个地方来存放它们,因此需要更多的结构。
例如,如果我们的DATA_RECEIVED
事件返回我们想要保存在整体状态中的数据,那么将“分配给状态”操作直接放置在机器中可能会很方便:
const machine = {
initial: 'loading',
states: {
loading: {
on: {
// event types
DATA_RECEIVED: {
target: 'success',
// represents what "effects" should happen
// as a result of taking this transition
actions: [
{ type: 'saveData' }
]
}
}
},
// ...
}
};
function transition(state, event) {
const nextStateNode = machine
.states[state.status]
.on?.[event.type]
?? { target: state.status };
const nextState = {
...state,
status: nextStateNode.target
};
// go through the actions to determine
// what should be done
nextStateNode.actions?.forEach(action => {
if (action.type === 'saveData') {
nextState.data = event.data;
}
});
return nextState;
}
上面的实现非常小,实现了我们想要的状态机的所有功能(至少对于这个用例而言),而且还有一个额外的好处,你可以将machine
对象代码直接复制粘贴到XState Visualizer中,即使它根本没有使用 XState 或任何库!(提示:将对象包装进去Machine({ ... })
就可以正常工作)。
Kent C. Dodds 在他的文章《用 JavaScript 实现一个简单的状态机库》中做了类似的实现。它也利用了对象来描述状态机结构。
国家机器还不够
因此,如果我们可以通过小型、声明式、无库的状态机实现(使用语句或对象)来满足我们的基本状态管理需求,那么我们为什么还需要XStateswitch
之类的库呢?
这话出自我口中,或许会让人有些震惊,但我还是要说:状态机不足以应对大规模状态的管理和编排。状态机存在一个根本性的问题,叫做“状态爆炸”:当状态机中的状态数量增长时,状态之间的转换也趋于指数级增长。
值得庆幸的是, David Harel 教授发明了一种扩展状态机传统形式的方法,称为状态图 (statecharts ),并将其发表在他的论文《状态图:复杂系统的可视化形式主义》(Statecharts: A Visual Formalism for Complex Systems)中。这篇论文图表丰富,可读性强;我强烈建议您阅读。
您可以将状态图视为本质上是状态机(状态图可以分解为 FSM),它具有一些基本功能,可以更好地组织状态并用于实际用例:
- 层次结构(嵌套状态)
- 正交性(平行状态)
- 历史(记忆状态)
- 状态动作(进入、退出)
- 受保护的转换
- 扩展状态(上下文数据)
值得注意的是,前两个特性(层次结构和正交性)通过允许以减少完全表达所有可能转换所需的转换数量的方式对状态节点进行分组,从而缓解了状态爆炸问题。
例如,如果您正在创建一个状态机来表示编辑和异步保存某些数据,并且您希望在某些“空闲”(保存之前)和“错误”(保存后失败)状态之间共享行为(例如,SUBMIT
尝试/重试),那么不要使用平面状态机:
{
idleNormal: {
on: {
SAVE: {
target: 'saving',
actions: [{ type: 'saveAsync' }]
}
}
},
saving: {/* ... */},
idleError: {
on: {
SAVE: {
target: 'saving',
actions: [{ type: 'saveAsync' }]
}
}
},
// ...
}
您可以在同一个父状态下表示共享行为:
{
idle: {
// if child states don't handle these events,
// handle it here, in the parent state
on: {
SAVE: {
target: 'saving',
actions: [{ type: 'saveAsync' }]
}
},
initial: 'normal',
states: {
normal: {/* ... */},
error: {/* ... */}
}
},
saving: {/* ... */},
// ...
}
总的来说,状态图的特性在许多不同的情况下都非常有用:
- 嵌套状态对于分组和细化行为非常有用。不同的“有限状态”可以共享行为,同时又各自拥有特定的行为。
- 并行状态对于表示可以同时发生且不会直接相互影响的行为很有用。
- 历史状态对于回忆机器之前处于哪个嵌套状态很有用,而不必指定所有可能的“记忆”转换。
- 状态动作对于指定在进入/退出状态的任何转换时应始终执行的动作很有用,而不必在所有传入/传出转换中指定这些动作。
- 受保护的转换对于基于状态和事件类型以外的条件执行转换非常重要。它们还可以考虑其他数据(扩展状态)和/或事件数据。
- 扩展状态是绝对必要的。并非所有状态都是有限的;“无限”状态也需要量化。状态图可以让你区分有限状态和扩展状态。
经典状态图还有更多功能,例如“活动”(在整个状态中发生的动作)、延迟、无事件转换、通配符转换等等。你使用状态图越多,就越能意识到这些功能实际上有多么重要。
听起来在我们的状态机上实现这些功能会很有趣,对吧?
实现状态图
我希望你有很多空闲时间。
由于状态图比状态机更强大,因此实现起来也更困难。如果您真的好奇或渴望自己实现它们,我强烈建议您遵循W3 SCXML(状态图 XML)规范。它们甚至包含一个伪代码算法,用于正确解释 SCXML。
即使是实现像嵌套状态这样看似简单的事情,也是一项艰巨的任务。关于选择转换、解决冲突转换、遍历状态节点树以确定要退出/进入哪些节点、在叶节点不处理事件的情况下选择复合状态中的转换、确定操作顺序等等,都有许多规则。
这并不容易,就像您使用日期库来处理时区一样,您肯定希望使用状态图库来处理状态图支持的所有优秀功能。
那么您需要一个状态图库吗?
是的。
总结
如果您满足于随时操作状态并通过添加if
语句来修补边缘情况,那么您可能不需要显式状态机。
如果您想使用简单的状态机来帮助组织应用程序行为和逻辑,则不需要库。
如果您有复杂的逻辑并希望利用更强大的状态机功能来更好地管理此逻辑,则需要状态图。
你肯定需要一个状态图库。😉
如果你想了解我庄严的沉思和漫谈:
- 📬 订阅The Stately 简讯
- 💬 加入Stately Discord
- 🐦 在 Twitter 上关注我@davidkpiano
感谢阅读!
封面图片由Susan Yin 在 Unsplash 上提供(我记得在斯德哥尔摩参观过这个图书馆!🇸🇪)
文章来源:https://dev.to/davidkpiano/you-don-t-need-a-library-for-state-machines-k7h