本文最后更新于:2021年1月19日 下午
xstate.js 官网, 中文(繁体)教程参考
场景
- 为什么要引入状态机?
- 吾辈希望使用有限状态机管理程序中的状态及状态的流转,以避免使用各种
flag
+ if/else
控制程序的运行。
- 为什么吾辈会突然觉得
flag
+ if/else
这种方式不好呢?
- 原因在于吾辈最近在看设计模式相关的书籍:JavaScript 设计模式与开发实践,其中涉及到了[状态模式],里面就提到了[有限状态机]与[状态图]的概念,在经过 Google 一下了解之后,吾辈确实感觉到可以使用它来简化程序的状态流转控制。
例如有一个开关,控制灯泡怎么变化,在指定状态下点击会触发不同的行为,然后改变状态。
基本示例
想象以下场景,有一个开关控制着一个灯泡,灯泡有三种状态:关闭、打开弱光和打开强光,轮流变化。
使用原生代码实现
首先,我们尝试简单的使用 if/else
判断进行实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 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 消除判断
一般来说,我们都会使用下面的方式去简化代码。
- 将不同的逻辑抽离为单独的方法
- 使用 Map 消除条件判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| 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
控制的状态流转则无法使用这种方式抽离出来。
使用简单的状态模式
下面是用简单的状态模式来简化代码的控制逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| 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()
|
仔细观察变化
- 主流程只是做了转发,将操作转发给当前状态的子流程执行
- 状态对应的操作都在子流程中修改
其实本质上状态模式是将控制流程分散到了各个子流程中,不再集中在一个地方控制。
结合 react 与状态模式
然后,有趣的地方来了:如何结合状态模式与现有框架?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| 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 的控制范围之内!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| 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
下面是一个使用状态机控制点击开关控制灯泡的示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| 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 概述
基本概念
常用术语, 术语参考
- 状态: 任何时候总是有且只有一种状态
- 事件: 对外暴露事件,通过事件(声明式)触发状态的变化
- 动作: 触发事件时对应的具体行为,可以以编程的形式影响状态的变化
问题