从状态机到 xstate

有限状态机(英语:finite-state machine,缩写:FSM)又称有限状态自动机(英语:finite-state automaton,缩写:FSA),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型

概念和术语

状态机本身是一个数学计算模型,只要作用是抽象机器可以在任何给定事件恰好处于有限数量的状态之一中。状态存储关于过去的信息,就是说:它反映从系统开始到现在时刻的输入变化。转移指示状态变更,并且用必须满足确使转移发生的条件来描述它。动作是在给定时刻要进行的活动的描述。有多种类型的动作:

  • 进入动作(entry action):在进入状态时进行
  • 退出动作(exit action):在退出状态时进行
  • 输入动作:依赖于当前状态和输入条件进行
  • 转移动作:在进行特定转移时进行

FSM(有限状态机)可以使用上面图1那样的状态图(或状态转移图)来表示。此外可以使用多种类型的状态转移表。下面展示最常见的表示:当前状态(B)和条件(Y)的组合指示出下一个状态(C)。完整的动作信息可以只使用脚注来增加。包括完整动作信息的FSM定义可以使用状态表

当前状态→ 条件↓状态A状态B状态C
条件X
条件Y状态C
条件Z

光看概念和定义我们还是很难理解这个东西到底有什么用,所以我们尝试结合示例来进行学习。

XState

状态机的实现有很多,但这次我们使用一个现成且较为成熟的方案 xstate 来进行状态机的学习。以下是官网的例子:

import { createMachine, interpret } from 'xstate';

// 无状态的状态机定义
// machine.transition(...) 是解释器使用的纯函数。
const toggleMachine = createMachine({
  // 状态机的名字
  id: 'toggle',
  // 状态机的初始值
  initial: 'inactive',
  // 状态机的状态
  states: {
    // states 下的第一层对象,每一个键名都表示的是一个状态
    inactive: {
      // on 表示的是触发什么事件的时候,相当于是一个监听器
      on: {
        // 当触发 TOGGLE 事件时,将状态值变更为 active
        TOGGLE: { target: 'active' }
      }
    },
    // 另一个状态名
    active: {
      on: {
        // 当处于 active 时,触发 TOGGLE 事件后,状态流转到 inactive
        TOGGLE: { target: 'inactive' }
      }
    }
  }
});

// 具有内部状态的状态机实例
const toggleService = interpret(toggleMachine)
    // 当状态变更的时候打印出变更的状态,包括初始值
  .onTransition((state) => console.log(state.value))
    //
  .start();
// => 'inactive'

// 在当前状态触发 TOGGLE 事件,如果这个状态已经开始
// 则会立即触发状态流转的事件,如果没有调用 start() 方法
// 这次状态流转的事件则会被放进一个内部的队列,等待状态启动的时候会被执行
toggleService.send({ type: 'TOGGLE' });
// => 'active'

toggleService.send({ type: 'TOGGLE' });
// => 'inactive'

以上例子阐述了 xstate 大致的工作流程,在这个工作流程中,展现了一个状态机从声明状态、流转状态和监听状态的过程,而以上这一整个工作流程就是一个完整的状态机流程。

有限状态机

而在官网的例子中还有着这样一个可交互的实例,它的示例如下:

import { createMachine } from 'xstate';

const lightMachine = createMachine({
  id: 'light',
  initial: 'green',
  states: {
    green: {
      on: {
        TIMER: { target: 'yellow' }
      }
    },
    yellow: {
      on: {
        TIMER: { target: 'red' }
      }
    },
    red: {
      on: {
        TIMER: { target: 'green' }
      }
    }
  }
});

const currentState = 'green';

const nextState = lightMachine.transition(currentState, { type: 'TIMER' })
  .value;
// => 'yellow'

其实这个和上面来回切换的开关是一样的,只不过在状态数量方面有一点不同,但它们做的事情是完全一致的,都是在几种预设好的状态中不断切换,但仅仅是实例我们还很难说明使用状态机的好处到底在哪,所以我们需要通过更具体的实际场景来体现使用状态机来管理状态有哪些好处。

以 Vue3 来举例,以下是一个开关的例子,它是一个可以无限循环切换状态的开关,在点击的时候状态会在 active inactive 之间来回切换,并在切换到 active 状态的时候将计数器递增,不使用状态机的代码如下:

<template>
  <button @click="toggleState">Click me ({{ state === "active" ? "✅" : "❌" }})</button>
  <p>
    <code>
      count:
      <strong>{{ count }}</strong>
      【{{ state }}】
    </code>
  </p>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)
const state = ref('inactive')

const toggleState = () => {
  if (state.value === 'inactive') {
    state.value = 'active'
    count.value += 1
  } else if (state.value === 'active') {
    state.value = 'inactive'
  }
}
</script>

toggleState() 方法内部的实现中会对当前状态进行判断,从而设置下一个状态,并且更新其他的副作用依赖值,但以上代码仅仅符合目前只有两种状态的需求,如果第三种状态假如以后则需要再添加一个分支判断。伴随着第三种状态的加入,需求也有了一点小的变更:

  1. 一共有 inactiveactivedisabled 三种状态;
  2. 初始状态为 disabled ,状态流转的过程为: disabled => inactive => active
  3. 当状态从 disabled 状态切换为 inactiveactive 状态后,则无论如何都无法将状态切换为 disabled
  4. inactiveactive 之间的状态流转规则不变,依然可以从 inactive 切换到 active ,从 active 切换到 inactive

根据以上需求,我们可以将代码修改成:

const state = ref('disabled')

const toggleState = () => {
  if (state.value === 'disabled') {
    state.value = 'inactive'
    return
  }

  if (state.value === 'inactive') {
    state.value = 'active'
    count.value += 1
  } else if (state.value === 'active') {
    state.value = 'inactive'
  }
}

如果以后再新增加状态的话,对原有代码的改动还是会慢慢变大,就会导致在一个方法中不断垒代码,但如果使用状态机来管理这个状态流转过程的话,一切会简单许多:

<template>
  <div id="app">
    <button @click="send('TOGGLE')">
      <!-- 通过匹配当前状态是否为目标状态 -->
      Click me ({{ state.matches("active") ? "✅" : "❌" }})
    </button>

    <code>
      count:
      <!-- 获取状态机中上下文所依赖的 count 变量 -->
      <strong>{{ state.context.count }}</strong>
      <!-- 读取当前状态 -->
      【{{ state.value }}】
    </code>
  </div>
</template>

<script setup>
import { createMachine } from "xstate";
import { useMachine } from "@xstate/vue";

// 创建一个状态机
const toggleMachine = createMachine({
  id: "toggle",
  predictableActionArguments: true,
  // 初始状态为 disabled
  initial: "disabled",
  context: {
    // 当前状态机中所依赖到的局部变量
    count: 0,
  },
  states: {
    disabled: {
      // 当切换状态的时候只能切换到 inactive
      on: { TOGGLE: 'inactive' }
    },
    inactive: {
      on: { TOGGLE: "active" },
    },
    active: {
      // 当进入 active 状态的时候,更新当前状态机上下文中的 count 变量
      entry: (context) => { context.count += 1},
      on: { TOGGLE: "inactive" }
    },
  },
});

// 暴露一个包含当前状态的 State ref 和一个通知状态流转的方法
const { state, send } = useMachine(toggleMachine);
</script>

状态机处理的方式是我认为还比较优雅的方式,它通过在 states 对象下设置状态变量来隔离所有状态之间的关联性,

通过 entry 钩子来实现进入状态时需要进行的操作事件,当然还可以使用 exit() 钩子来实现退出当前状态时需要执行的操作事件。而更新 context 变量还可以通过 assign() 方法来实现批量更新:

import { assign } from 'xstate'

...

const toggleMachine = createMachine({
  ...,
  states: {
     active: {
       // 当进入 active 状态的时候,更新当前状态机上下文中的 count 变量
       entry: assign({
             count: (ctx) => (ctx.count + 1),
             test1: (ctx) => (ctx.test1 || 0) + 1
       }),
       on: { TOGGLE: "inactive" }
    }
  }
}

最后

以上还只是对状态机概念的理解与实际应用,总得来说,状态机的出现就是为了解决应用程序在多个状态之间流转的繁琐处理,同时将状态与状态之间的复杂关联解耦,使得它们之间的关系更加纯粹。


  1. 有限状态机 · Wiki
  2. XState