Published on

分层状态机(HSM)及其前端实现

Authors

状态机 (FSM - Finite-State Machine) 是计算理论中的基础概念,也是编程语言和编译器设计的基石。它描述了具有有限状态的系统,以及在不同状态之间进行转换的行为。

状态机的基本概念

以旋转栅门为例,它具有两种状态:上锁解锁。系统可以接收两种事件:投币推门。根据当前状态和事件,系统会转换到新的状态。

automatic-revolving-gate
推门
投币
推门
投币
Locked
Unlocked

状态机的关键要素:

  • 有限状态集:例如,上锁/解锁
  • 有限事件集:例如,投币/推门
  • 初始状态:例如,上锁
  • 转换器:定义状态转换规则,根据当前状态和事件确定下一个状态
  • 终态(可选): 系统最终停留的状态

状态机的应用场景

状态机广泛应用于对象状态和行为建模,例如:

  • 硬件电路系统设计
  • 编译器实现
  • 网络协议
  • 游戏 AI
state-machine-in-verilog

在 verilog 中创建的有限状态机

&
&
>
*, ?, (, ), +, |, &
value
attribute
element

一个简单的词法分析器状态机

tcp-state-diagram

TCP state diagram

game-npc-state-machine

游戏 NPC 状态机

在编程中,使用状态机可以清晰地表达实体的状态和行为,避免复杂的条件判断。

分层状态机 (HSM)

分层状态机 (HSM - Hierarchical State Machine) 在 FSM 的基础上增加了层级结构,可以更好地组织和管理复杂的状态逻辑。子状态的进入和退出会触发父状态的相应事件。

hsm

前端实现方式

目前主流的前端 HSM 实现方案有两种:

  • xstate: 以状态为中心,功能强大,支持 FSM、HSM、PSM (并行状态机) 等,并提供可视化工具 stately.ai/viz
  • javascript-state-machine: 以转换事件为中心,相对简单,但项目迭代和社区活跃度不如 xstate。

HSM 基础实现示例

以下代码展示了 HSM 的基础实现:

class State {
  constructor(config = {}, parent = null) {
    const { id, from = '*', onEnter, onExit } = config;
    this.id = id;
    if (isString(from)) {
      if (from === '*') {
        this.acceptFromAll = true;
      } else {
        this.from = [from];
      }
    } else if (isArray(from)) {
      this.from = from;
    }
    this.onEnter = isFunction(onEnter) ? onEnter : noop;
    this.onExit = isFunction(onExit) ? onExit : noop;
    this._parent = null;
    this.children = [];
    this.setParent(parent);
  }
  
  get parent() { return this._parent; }
  get root() {
    let node = this;
    while (node._parent instanceof State) {
      node = node._prarent;
    }
    return node;
  }
  get isRoot() { return this === this.root; }
  get ancestors() {
    const ancestors = [];
    let node = this._parent;
    while (node.parent instanceof State) {
      ancestors.push(node);
      node = node.parent;
    }
    return ancestors;
  }
  
  setParent(parent) {
    if (!(parent instanceof State)) return;
    if (parent.ancestors.indexOf(this) > -1) {
      throw new Error(`cannot set child state ${parent.id} as parent`)
    }
    if (this.parent instanceof State && this.parent !== parent) {
      this.parent.removeChild(this);
    }
    this._parent = parent;
    parent.children.push(this);
  }
  
  removeChild(child) {
    removeFrom(this.children, child);
    child._parent = null;
  }
}

module.exports = State;
const State = require('./state');
const EVENTS = {
  TRANSITION_COMPLETE: 'TRANSITION_COMPLETE',
  TRANSITION_DENIED: 'TRANSITION_DENIED',
};
class StateMachine extends EventEmitter {
  static EVENTS = EVENTS;
  static State = State;
  
  constructor() {
    super();
    this._states = {};
    this._currentState = null;
  }
  
  get states() { return this._states; }
  get currentState() { return this._currentState; }
  
  hasState(id) { return keys(this.states).indexOf(id) !== -1; }
  getStateById(id) { return this.states[id] || null; }
  
  addState(data = {}, parent = null) {
    const { id } = data;
    if (this.hasState(id)) {
      throw new Error(`state ${id} exists`);
    }
    parent = parent instanceof State ? parent : this.getStateById(parent);
    const state = new State(data, parent);
    this._states[id] = state;
  }
  
  setInitialState(id) {
    if (isNil(this._currentState) && this.hasState(id)) {
      this._currentState = id;
      const state = this._states[id];
      // invoke onEnter call for all ancestors down from
      // root parent of the state we're transitioning into
      const { root, ancestors, onEnter } = state;
      if (!isNil(root)) {
        ancestors.forEach((ancestor) => {
          if (isFunction(ancestor.onEnter)) {
            ancestor.onEnter.call(this, {
              toState: id,
            });
          }
        });
      }
      // invoke onEnter call for id
      if (isFunction(onEnter)) {
        onEnter.call(this, {
          currentState: id,
        });
      }
      this.emit(EVENTS.TRANSITION_COMPLETE, {
        toState: id,
      });
    }
  }

  canChangeStateTo(id) {
    // FIXME: allow from itself?
    if (id === this._currentState) return false;
    const toState = this.getStateById(id);
    if (toState.acceptFromAll) return true;
    if (toState.from.indexOf(this._currentState) > -1) return true;
    return false;
  }

  changeState(id) {
    if (!this.hasState(id)) {
      console.warn(`cannot make transition. state: ${id} is not defined`);
      return;
    }
    const toState = this.getStateById(id);
    if (!this.canChangeStateTo(id)) {
      console.warn(`transition to state: ${id} is denied`);
      this.emit(EVENTS.TRANSITION_DENIED, {
        fromState: this._currentState,
        toState: id,
        allowedStates: toState.from,
      });
      return;
    }
    // invoke onExit callback for current state and it's ancestors
    // TODO: exceptions: if the target state is still in the same parent,
    // do not invoke onExit callback of the parent state
    const { currentState } = this;
    const allExitingStates = [currentState, ...currentState.ancestors];
    for (let i = 0; i < allExitingStates.length; i += 1) {
      const state = allExitingStates[i];
      if (isFunction(state.onExit)) {
        state.onExit.call(this, {
          currentState: state.id,
        });
      }
    }
    // invoke onEnter callback for target state and it's ancestors
    const allEnteringStates = [toState, ...toState.ancestors];
    for (let i = (allEnteringStates.length - 1); i > -1; i -= 1) {
      const state = allEnteringStates[i];
      if (isFunction(state.onEnter)) {
        state.onEnter.call(this, {
          currentState: state.id,
        });
      }
    }
    const oldState = this._currentState;
    this._currentState = id;
    this.emit(EVENTS.TRANSITION_COMPLETE, {
      fromState: oldState,
      toState: id,
    });
  }
}

module.exports = StateMachine;
// 播放器状态机示例
const eventSM = new HSM();
eventSM.addState({ id: 'playing' });
eventSM.addState({ id: 'stopped' });
eventSM.addState({ id: 'paused', from: 'playing' });
eventSM.setInitialState('stopped');
// ...

前端生态上的状态机管理工具

xstate

使用 stately.ai/viz 可视化 xstate 定义的状态机。xstate 支持 FSM、HSM、PSM(并行)等,是目前最流行的状态机管理工具

老牌的状态机管理工具,还有 ifandelse/machina.js、matthewp/robot 等,但项目迭代和社区都不是很活跃

regexper

正则表达式的状态机可视化(ip 地址识别正则表达式的轨道图可视化)

PS:感兴趣的同学,可以尝试做两个课后作业: