状态机的兴起
已发表: 2022-03-10已经是 2018 年了,无数前端开发人员仍在与复杂性和固定性作斗争。 一个月又一个月,他们一直在寻找圣杯:一个无错误的应用程序架构,可以帮助他们快速、高质量地交付。 我是这些开发人员之一,我发现了一些有趣的东西可能会有所帮助。
我们使用 React 和 Redux 等工具向前迈出了一大步。 然而,它们在大规模应用中是不够的。 本文将在前端开发的背景下向大家介绍状态机的概念。 您可能已经在没有意识到的情况下构建了其中的几个。
状态机简介
状态机是计算的数学模型。 这是一个抽象的概念,机器可以有不同的状态,但在给定的时间只满足其中一个状态。 有不同类型的状态机。 我相信最著名的一个是图灵机。 它是一个无限状态机,这意味着它可以有无数个状态。 图灵机不适合当今的 UI 开发,因为在大多数情况下,我们的状态数量是有限的。 这就是为什么有限状态机(例如 Mealy 和 Moore)更有意义的原因。
它们之间的区别在于,摩尔机器仅根据其先前的状态更改其状态。 不幸的是,我们有很多外部因素,比如用户交互和网络进程,这意味着摩尔机器对我们来说也不够好。 我们正在寻找的是 Mealy 机器。 它有一个初始状态,然后根据输入及其当前状态转换到新状态。
说明状态机如何工作的最简单方法之一是查看旋转栅门。 它具有有限数量的状态:锁定和解锁。 这是一个简单的图形,向我们展示了这些状态,以及它们可能的输入和转换。

旋转门的初始状态是锁定的。 无论我们推多少次,它都保持在锁定状态。 但是,如果我们将硬币传递给它,它就会转换到解锁状态。 此时另一枚硬币将无济于事。 它仍将处于解锁状态。 从另一边推一下就行了,我们就可以通过了。 此操作还将机器转换到初始锁定状态。
如果我们想实现一个控制旋转门的函数,我们可能会得到两个参数:当前状态和一个动作。 如果你使用 Redux,这对你来说可能听起来很熟悉。 它类似于著名的 reducer 函数,我们接收当前状态,并根据动作的有效负载,决定下一个状态是什么。 reducer 是状态机上下文中的转换。 事实上,任何具有我们可以以某种方式改变的状态的应用程序都可以称为状态机。 只是我们一遍又一遍地手动实现所有内容。
状态机如何更好?
在工作中,我们使用 Redux,我对此非常满意。 但是,我开始看到我不喜欢的模式。 我所说的“不喜欢”并不是说它们不起作用。 更多的是它们增加了复杂性并迫使我编写更多代码。 我不得不承担一个我有空间进行实验的副项目,我决定重新考虑我们的 React 和 Redux 开发实践。 我开始对我关心的事情做笔记,我意识到状态机抽象确实可以解决其中的一些问题。 让我们跳进去看看如何在 JavaScript 中实现状态机。
我们将解决一个简单的问题。 我们希望从后端 API 获取数据并将其显示给用户。 第一步是学习如何在状态而不是转换中思考。 在我们进入状态机之前,我构建这样一个特性的工作流程曾经看起来像这样:
- 我们显示一个获取数据按钮。
- 用户单击获取数据按钮。
- 将请求发送到后端。
- 检索数据并解析它。
- 将其展示给用户。
- 或者,如果出现错误,则显示错误消息并显示 fetch-data 按钮,以便我们可以再次触发该过程。

我们正在线性思考,基本上试图涵盖最终结果的所有可能方向。 一个步骤导致另一个步骤,很快我们将开始分支我们的代码。 用户双击按钮,或者在我们等待后端响应时用户单击按钮,或者请求成功但数据损坏等问题怎么办? 在这些情况下,我们可能会有各种标志来告诉我们发生了什么。 拥有标志意味着更多的if
子句,并且在更复杂的应用程序中,更多的冲突。

这是因为我们正在考虑过渡。 我们专注于这些转变是如何发生的以及以什么顺序发生的。 相反,关注应用程序的各种状态会简单得多。 我们有多少个状态,它们可能的输入是什么? 使用相同的示例:
- 闲置的
在这种状态下,我们显示 fetch-data 按钮,坐等。 可能的行动是:- 点击
当用户单击按钮时,我们将请求发送到后端,然后将机器转换为“获取”状态。
- 点击
- 获取
请求正在进行中,我们坐下来等待。 行动是:- 成功
数据成功到达并且没有损坏。 我们以某种方式使用数据并转换回“空闲”状态。 - 失败
如果在发出请求或解析数据时出现错误,我们将转换到“错误”状态。
- 成功
- 错误
我们显示一条错误消息并显示 fetch-data 按钮。 这个状态接受一个动作:- 重试
当用户单击重试按钮时,我们再次触发请求并将机器转换为“获取”状态。
- 重试
我们已经描述了大致相同的过程,但带有状态和输入。

这简化了逻辑并使其更具可预测性。 它还解决了上面提到的一些问题。 请注意,当我们处于“获取”状态时,我们不接受任何点击。 因此,即使用户单击按钮,也不会发生任何事情,因为机器未配置为在该状态下响应该操作。 这种方法自动消除了我们代码逻辑的不可预测的分支。 这意味着我们将在测试时覆盖更少的代码。 此外,某些类型的测试,例如集成测试,可以自动化。 想想我们如何对我们的应用程序做什么有一个非常清晰的想法,我们可以创建一个脚本来遍历定义的状态和转换并生成断言。 这些断言可以证明我们已经达到了每一个可能的状态或经历了一段特定的旅程。
事实上,写下所有可能的状态比写下所有可能的转换更容易,因为我们知道我们需要或拥有哪些状态。 顺便说一句,在大多数情况下,状态将描述我们应用程序的业务逻辑,而转换在开始时通常是未知的。 我们软件中的错误是在错误状态和/或在错误时间调度的操作的结果。 他们让我们的应用程序处于我们不知道的状态,这会破坏我们的程序或使其行为不正确。 当然,我们不希望处于这样的境地。 状态机是很好的防火墙。 它们保护我们免于到达未知状态,因为我们为可能发生的事情和时间设定了界限,而没有明确说明如何发生。 状态机的概念非常适合单向数据流。 它们共同降低了代码的复杂性,并解开了状态起源之谜。
在 JavaScript 中创建状态机
说得够多了——让我们看看一些代码。 我们将使用相同的示例。 根据上面的列表,我们将从以下内容开始:
const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } } }
我们将状态作为对象,将它们可能的输入作为函数。 但是,初始状态丢失了。 让我们把上面的代码改成这样:
const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } } }
一旦我们定义了所有对我们有意义的状态,我们就可以发送输入并更改状态。 我们将通过使用下面的两个辅助方法来做到这一点:
const machine = { dispatch(actionName, ...payload) { const actions = this.transitions[this.state]; const action = this.transitions[this.state][actionName]; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ... }
dispatch
函数检查在当前状态的转换中是否存在具有给定名称的动作。 如果是这样,它会使用给定的有效负载触发它。 我们还将machine
作为上下文调用action
处理程序,以便我们可以使用this.dispatch(<action>)
调度其他动作或使用this.changeStateTo(<new state>)
更改状态。
按照我们示例的用户旅程,我们必须调度的第一个操作是click
。 这是该操作的处理程序的样子:
transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ... } machine.dispatch('click');
我们首先将机器的状态更改为fetching
。 然后,我们将请求触发到后端。 假设我们有一个服务,其方法getData
返回一个承诺。 一旦解决并且数据解析OK,我们发送success
,如果不是failure
。
到目前为止,一切都很好。 接下来,我们要在fetching
状态下实现success
和failure
的动作和输入:
transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ... }
请注意我们是如何让我们的大脑从不得不考虑之前的过程中解放出来的。 我们不关心用户点击或 HTTP 请求发生了什么。 我们知道应用程序处于fetching
状态,我们只需要这两个操作。 这有点像孤立地编写新逻辑。
最后一位是error
状态。 如果我们提供重试逻辑以便应用程序可以从故障中恢复,那就太好了。
transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } } }
在这里,我们必须复制我们在click
处理程序中编写的逻辑。 为了避免这种情况,我们应该将处理程序定义为两个操作都可以访问的函数,或者我们首先转换到idle
状态,然后手动分派click
操作。
工作状态机的完整示例可以在我的 Codepen 中找到。
使用库管理状态机
无论我们使用 React、Vue 还是 Angular,有限状态机模式都有效。 正如我们在上一节中看到的,我们可以轻松地实现状态机而没有太多麻烦。 但是,有时库提供了更大的灵活性。 其中一些不错的是 Machina.js 和 XState。 然而,在本文中,我们将讨论 Stent,这是我的类似 Redux 的库,它包含了有限状态机的概念。
Stent 是状态机容器的实现。 它遵循 Redux 和 Redux-Saga 项目中的一些想法,但在我看来,它提供了更简单且无样板的流程。 它是使用自述驱动的开发方式开发的,而我实际上只在 API 设计上花费了数周时间。 因为我正在编写库,所以我有机会解决我在使用 Redux 和 Flux 架构时遇到的问题。
创建机器
在大多数情况下,我们的应用程序涵盖多个领域。 我们不能只用一台机器。 因此,Stent 允许创建许多机器:
import { Machine } from 'stent'; const machineA = Machine.create('A', { state: ..., transitions: ... }); const machineB = Machine.create('B', { state: ..., transitions: ... });
稍后,我们可以使用Machine.get
方法访问这些机器:
const machineA = Machine.get('A'); const machineB = Machine.get('B');
将机器连接到渲染逻辑
在我的例子中,渲染是通过 React 完成的,但我们可以使用任何其他库。 归结为触发我们触发渲染的回调。 我开发的第一个功能是connect
功能:

import { connect } from 'stent/lib/helpers'; Machine.create('MachineA', ...); Machine.create('MachineB', ...); connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });
我们说哪些机器对我们很重要并给出它们的名字。 我们传递给map
的回调最初会触发一次,然后每次某些机器的状态发生变化时都会触发一次。 这是我们触发渲染的地方。 此时,我们可以直接访问连接的机器,因此我们可以检索当前状态和方法。 还有mapOnce
,用于只触发一次回调,还有mapSilent
,用于跳过初始执行。
为方便起见,专门为 React 集成导出了一个帮助程序。 它与 Redux 的connect(mapStateToProps)
非常相似。
import React from 'react'; import { connect } from 'stent/lib/react'; class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... } } // MachineA and MachineB are machines defined // using Machine.create function export default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });
Stent 运行我们的映射回调并期望接收一个对象——一个作为props
发送到我们的 React 组件的对象。
支架背景下的状态是什么?
到目前为止,我们的状态一直是简单的字符串。 不幸的是,在现实世界中,我们必须保持不止一个字符串的状态。 这就是为什么 Stent 的 state 实际上是一个内部有属性的对象。 唯一的一个保留属性是name
。 其他一切都是特定于应用程序的数据。 例如:
{ name: 'idle' } { name: 'fetching', todos: [] } { name: 'forward', speed: 120, gear: 4 }
到目前为止,我使用 Stent 的经验告诉我,如果状态对象变大,我们可能需要另一台机器来处理这些附加属性。 识别各种状态需要一些时间,但我相信这是在编写更易于管理的应用程序方面向前迈出的一大步。 这有点像预测未来和绘制可能行动的框架。
使用状态机
与开始的示例类似,我们必须定义机器的可能(有限)状态并描述可能的输入:
import { Machine } from 'stent'; const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } } });
我们有初始状态idle
,它接受run
动作。 一旦机器处于running
状态,我们就可以触发stop
动作,这使我们回到idle
状态。
您可能还记得我们之前实现中的dispatch
和changeStateTo
助手。 这个库提供了相同的逻辑,但它是隐藏在内部的,我们不必考虑它。 为方便起见,基于transitions
属性,Stent 生成以下内容:
- 检查机器是否处于特定状态的辅助方法——
idle
状态产生isIdle()
方法,而running
我们有isRunning()
; - 调度操作的辅助方法:
runPlease()
和stopNow()
。
所以,在上面的例子中,我们可以使用这个:
machine.isIdle(); // boolean machine.isRunning(); // boolean machine.runPlease(); // fires action machine.stopNow(); // fires action
将自动生成的方法与connect
实用程序功能相结合,我们能够闭合圆圈。 用户交互触发机器输入和动作,从而更新状态。 由于该更新,传递给connect
的映射函数被触发,并且我们被告知状态更改。 然后,我们重新渲染。
输入和动作处理程序
可能最重要的一点是动作处理程序。 这是我们编写大部分应用程序逻辑的地方,因为我们正在响应输入和更改的状态。 我在 Redux 中真正喜欢的东西也集成在这里:reducer 函数的不变性和简单性。 Stent 的动作处理器的本质是一样的。 它接收当前状态和动作负载,它必须返回新状态。 如果处理程序不返回任何内容( undefined
),则机器的状态保持不变。
transitions: { 'fetching': { 'success': function (state, payload) { const todos = [ ...state.todos, payload ]; return { name: 'idle', todos }; } } }
假设我们需要从远程服务器获取数据。 我们触发请求并将机器转换为fetching
状态。 一旦数据来自后端,我们就会触发一个success
操作,如下所示:
machine.success({ label: '...' });
然后,我们回到idle
状态,并以todos
数组的形式保存一些数据。 还有几个其他可能的值可以设置为操作处理程序。 第一个也是最简单的情况是我们只传递一个成为新状态的字符串。
transitions: { 'idle': { 'run': 'running' } }
这是使用run()
操作从{ name: 'idle' }
到{ name: 'running' }
的转换。 当我们有同步状态转换并且没有任何元数据时,这种方法很有用。 因此,如果我们将其他东西保持在状态中,那么这种类型的转换会将其清除。 同样,我们可以直接传递一个状态对象:
transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: [] } } }
我们正在使用deleteAllTodos
操作从editing
过渡到idle
。
我们已经看到了函数处理程序,动作处理程序的最后一个变体是生成器函数。 它的灵感来自 Redux-Saga 项目,它看起来像这样:
import { call } from 'stent/lib/helpers'; Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } } });
如果您没有生成器的经验,这可能看起来有点神秘。 但是 JavaScript 中的生成器是一个强大的工具。 我们可以暂停我们的动作处理程序,多次更改状态并处理异步逻辑。
发电机的乐趣
当我第一次接触 Redux-Saga 时,我认为这是一种处理异步操作的过于复杂的方式。 事实上,它是命令设计模式的一个非常聪明的实现。 这种模式的主要好处是它将逻辑的调用和它的实际实现分开。
换句话说,我们说的是我们想要的,而不是它应该如何发生。 Matt Hink 的博客系列帮助我了解了 sagas 是如何实现的,我强烈推荐阅读它。 我将相同的想法带入了 Stent,为了本文的目的,我们将说,通过产生东西,我们给出了关于我们想要什么的说明,而没有实际执行它。 一旦执行了操作,我们就会收到控制权。
目前,可能会发送(生成)一些东西:
- 用于更改机器状态的状态对象(或字符串);
-
call
helper 的调用(它接受一个同步函数,这是一个返回 promise 或另一个生成器函数的函数)——我们基本上是在说,“为我运行这个,如果它是异步的,请等待。 完成后,给我结果。”; -
wait
助手的调用(它接受代表另一个动作的字符串); 如果我们使用这个实用函数,我们会暂停处理程序并等待另一个动作被调度。
这是一个说明变体的函数:
const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... }); } ... transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const [ data, isError ] = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } } }
正如我们所见,代码看起来是同步的,但实际上并非如此。 只是 Stent 完成了等待已解决的承诺或迭代另一个生成器的无聊部分。
Stent 如何解决我对 Redux 的担忧
太多的样板代码
Redux(和 Flux)架构依赖于在我们系统中循环的动作。 当应用程序增长时,我们通常会拥有很多常量和动作创建者。 这两件事通常位于不同的文件夹中,跟踪代码的执行有时需要时间。 此外,在添加新功能时,我们总是要处理一整套动作,这意味着定义更多的动作名称和动作创建者。
在 Stent 中,我们没有动作名称,库会自动为我们创建动作创建者:
const machine = Machine.create('todo-app', { state: { name: 'idle', todos: [] }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } } }); machine.addTodo({ title: 'Fix that bug' });
我们将machine.addTodo
动作创建者直接定义为机器的方法。 这种方法还解决了我面临的另一个问题:找到响应特定操作的减速器。 通常,在 React 组件中,我们会看到动作创建者的名称,例如addTodo
; 然而,在 reducer 中,我们使用一种恒定的动作。 有时我必须跳转到动作创建者代码,这样我才能看到确切的类型。 在这里,我们根本没有类型。
不可预测的状态变化
一般来说,Redux 在以不可变的方式管理状态方面做得很好。 问题不在于 Redux 本身,而在于允许开发人员随时调度任何操作。 如果我们说我们有一个打开灯的动作,那么连续两次触发该动作是否可以? 如果不是,那么我们应该如何用 Redux 解决这个问题? 好吧,我们可能会在 reducer 中放置一些代码来保护逻辑并检查灯是否已经打开——也许是一个检查当前状态的if
子句。 现在的问题是,这不是超出了reducer的范围吗? 减速器应该知道这种边缘情况吗?
我在 Redux 中缺少的是一种根据应用程序的当前状态停止分派操作的方法,而不会用条件逻辑污染 reducer。 而且我也不想将这个决定交给视图层,动作创建者被触发的地方。 使用 Stent,这会自动发生,因为机器不会响应当前状态下未声明的操作。 例如:
const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } } }); // this is fine machine.run(); // This will do nothing because at this point // the machine is in a 'running' state and there is // only 'stop' action there. machine.jump();
机器在给定时间只接受特定输入的事实可以保护我们免受奇怪的错误的影响,并使我们的应用程序更可预测。
状态,而不是转换
Redux 和 Flux 一样,让我们从转换的角度进行思考。 使用 Redux 进行开发的心智模型很大程度上是由动作以及这些动作如何转换我们的 reducer 中的状态来驱动的。 这还不错,但我发现用状态来思考更有意义——应用程序可能处于什么状态以及这些状态如何代表业务需求。
结论
编程中的状态机概念,尤其是在 UI 开发中,让我大开眼界。 我开始随处看到状态机,我有一些愿望总是转向这种范式。 我绝对看到了更严格定义的状态和它们之间的转换的好处。 我一直在寻找使我的应用程序简单易读的方法。 我相信状态机是朝这个方向迈出的一步。 这个概念很简单,同时也很强大。 它有可能消除很多错误。