有限状态机

本文最后更新于:2021年1月20日 凌晨

xstate.js 官网, 中文(繁体)教程参考

场景

  • 为什么要引入状态机?
  • 吾辈希望使用有限状态机管理程序中的状态及状态的流转,以避免使用各种 flag + if/else 控制程序的运行。
  • 为什么吾辈会突然觉得 flag + if/else 这种方式不好呢?
  • 原因在于吾辈最近在看设计模式相关的书籍:JavaScript 设计模式与开发实践,其中涉及到了[状态模式],里面就提到了[有限状态机]与[状态图]的概念,在经过 Google 一下了解之后,吾辈确实感觉到可以使用它来简化程序的状态流转控制。

例如有一个开关,控制灯泡怎么变化,在指定状态下点击会触发不同的行为,然后改变状态。

基本示例

想象以下场景,有一个开关控制着一个灯泡,灯泡有三种状态:关闭、打开弱光和打开强光,轮流变化。

使用原生代码实现

首先,我们尝试简单的使用 if/else 判断进行实现。

class Light {
  private state: "off" | "weak" | "strong" = "off";
  click() {
    switch (this.state) {
      case "off":
        console.log("打开弱光");
        this.state = "weak";
        break;
      case "weak":
        console.log("打开强光");
        this.state = "strong";
        break;
      case "strong":
        console.log("关灯");
        this.state = "off";
        break;
    }
  }
}

const light = new Light();
light.click();
light.click();
light.click();

然而,这种代码充斥着判断,同时代码本身也都耦合在了一起。目前只有一个 flag 的时候还没太大问题,如果有更多的 flag(例如典型的用户角色与状态同时控制指定操作的行为),代码将非常混乱。

这也是吾辈之前为什么很想要一种支持多个 key 的 Map 的重要原因之一(其实有点类似数据库中索引的概念了)。

抽离方法,使用 Map 消除判断

一般来说,我们都会使用下面的方式去简化代码。

  1. 将不同的逻辑抽离为单独的方法
  2. 使用 Map 消除条件判断
class Light {
  private state: "off" | "weak" | "strong" = "off";
  private map = {
    off: this.offClick.bind(this),
    weak: this.weakClick.bind(this),
    strong: this.strongClick.bind(this),
  };
  click() {
    this.map[this.state]();
  }

  private offClick() {
    console.log("打开弱光");
    this.state = "weak";
  }
  private weakClick() {
    console.log("打开强光");
    this.state = "strong";
  }
  private strongClick() {
    console.log("关灯");
    this.state = "off";
  }
}

const light = new Light();
light.click();
light.click();
light.click();

事实上,第一种方式是卓有成效且任何人都能够无师自通的(分离了实现和控制),但第二种,虽然这里可以简化逻辑的控制,但稍微复杂或是由多个 flag 控制的状态流转则无法使用这种方式抽离出来。

使用简单的状态模式

下面是用简单的状态模式来简化代码的控制逻辑

class Light {
  toggle: (light: Light) => void = FSM.off;
  click() {
    this.toggle(this);
  }
}

const FSM = {
  off(light: Light) {
    console.log("打开弱光");
    light.toggle = FSM.weak;
  },
  weak(light: Light) {
    console.log("打开强光");
    light.toggle = FSM.strong;
  },
  strong(light: Light) {
    console.log("关灯");
    light.toggle = FSM.off;
  },
};

const light = new Light();
light.click();
light.click();
light.click();

仔细观察变化

  1. 主流程只是做了转发,将操作转发给当前状态的子流程执行
  2. 状态对应的操作都在子流程中修改

其实本质上状态模式是将控制流程分散到了各个子流程中,不再集中在一个地方控制。

结合 react 与状态模式

然后,有趣的地方来了:如何结合状态模式与现有框架?

import React from "react";
import ReactDOM from "react-dom";
import { useState } from "react";

class Light {
  toggle: (light: Light) => void = FSM.off;
  click() {
    this.toggle(this);
  }
}

const FSM = {
  off(light: Light) {
    console.log("打开弱光");
    light.toggle = FSM.weak;
  },
  weak(light: Light) {
    console.log("打开强光");
    light.toggle = FSM.strong;
  },
  strong(light: Light) {
    console.log("关灯");
    light.toggle = FSM.off;
  },
};

function App() {
  const [light] = useState(new Light());
  return (
    <div>
      <h2>app</h2>
      <button onClick={() => light.click()}>灯的开关</button>
    </div>
  );
}

ReactDOM.render(<App />, document.querySelector("#app"));

然而,当我们想要即时显示当前状态时,却发现了问题,是的,状态模式中将状态放在 Light 类中,而它并不在 react 的控制范围之内!

// 其他代码

class Light {
  state: "off" | "weak" | "strong" = "off";
  // 其他代码
}

const FSM = {
  off(light: Light) {
    console.log("打开弱光");
    light.state = "weak";
    light.toggle = FSM.weak;
  },
  // 其他状态的代码
};

function App() {
  const [light] = useState(new Light());
  return (
    <div>
      <h2>app</h2>
      <button onClick={() => light.click()}>灯的开关</button>
      <p>{light.state}</p>
    </div>
  );
}

// 其他代码

即便这样做,仍然不会发生变化,因为 Light 是个对象,而我们并未使用 setLight 修改它,所以自然不会发生变化

尝试 react + useReducer

当然,我们可以使用 useReducer 试试,毕竟它是专门应对复杂逻辑处理的 hooks。

type LightState = 'off' | 'weak' | 'strong'
const FSM: Record<LightState, () => LightState> = {
  off(): LightState {
    console.log('打开弱光')
    return 'weak'
  },
  weak(): LightState {
    console.log('打开强光')
    return 'strong'
  },
  strong(): LightState {
    console.log('关灯')
    return 'off'
  },
}

const App: React.FC = () => {
  const [num, setNum] = useState(0)
  const [lightState, lightSend] = useReducer<(state: LightState) => LightState>(
    (state) => {
      return FSM[state]()
    },
    'off',
  )

  return (
    <div className="App">
      <header>
        <button onClick={() => lightSend()}>切换</button>
        每次从 off => weak 就改变状态
      </header>
      <p>{lightState}</p>
      <p>当前 useState 的值: {num}</p>
    </div>
  )
}

然而,可以看到,FSM 逻辑代码在 react 组件外部时,想要修改 react 组件内部的状态仍然非常困难,只能维护状态机自身的状态,而这显然是没多大用处的。解决方案很简单,使用回调的形式将具体的实现函数放在 react 组件内部,而这,正是 xstate 集成 react 实现的功能之一。

使用 xstate

react + 状态机 xstate

下面是一个使用状态机控制点击开关控制灯泡的示例

enum LightStateEnum {
  Off = 'off',
  Weak = 'weak',
  Strong = 'strong',
}

enum LightEventEnum {
  Click = 'click',
}
interface LightEvent extends EventObject {
  type: LightEventEnum.Click
  num: number
}

enum LightActionEnum {
  EntryWeak = 'entryWeak',
}

const lightStateMachine = Machine<{}, LightEvent>({
  initial: LightStateEnum.Off,
  states: {
    [LightStateEnum.Off]: {
      on: {
        [LightEventEnum.Click]: {
          target: LightStateEnum.Weak,
          actions: LightActionEnum.EntryWeak,
        },
      },
    },
    [LightStateEnum.Weak]: {
      on: {
        [LightEventEnum.Click]: LightStateEnum.Strong,
      },
    },
    [LightStateEnum.Strong]: {
      on: {
        [LightEventEnum.Click]: LightStateEnum.Off,
      },
    },
  },
})

const App: React.FC = () => {
  const [num, setNum] = useState(0)
  const [lightState, lightSend] = useMachine(lightStateMachine, {
    actions: {
      [LightActionEnum.EntryWeak](_context, event) {
        console.log('entryOff: ', event.num)
        setNum(num + event.num)
      },
    },
  })
  return (
    <div className="App">
      <header>
        <button
          onClick={() =>
            lightSend({
              type: LightEventEnum.Click,
              num: 1,
            })
          }
        >
          切换
        </button>
        每次从 off => weak 就改变状态
      </header>
      <p>{lightState.value}</p>
      <p>当前 useState 的值: {num}</p>
    </div>
  )
}

看的出来,上面多了很多模板代码,但状态机的意图我们却能以声明式的形式构造出来,具体 actions 的实现细节则被分离在 hooks 中。

xstate 概述

基本概念

常用术语, 术语参考

  • 状态: 任何时候总是有且只有一种状态
  • 事件: 对外暴露事件,通过事件(声明式)触发状态的变化
  • 动作: 触发事件时对应的具体行为,可以以编程的形式影响状态的变化

问题

  • 主要问题还是太大了,xstate 想要把所有的状态都用这种方式管理起来,而非仅限于适合的情况。。。(大而全 )

    在线示例

  • 怎么让 TypeScript 提示正确的类型?
    • 显式声明类型
  • 怎么在 react 里使用它修改状态
    • 使用 useMachine 在 react hooks 组件里添加 actions

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!