什么是状态机

有限状态机(Finite State Machine,FSM)是指在任意时刻只会处于有限个状态中的一种数学模型。它由一组状态、初始状态、转移条件和输出行为组成。

具体而言,有限状态机包括以下要素:

  1. 状态 (State):表示系统所处的特定情况或阶段。
  2. 转移条件 (Transition Condition):描述从一个状态到另一个状态转变时所需满足的条件。
  3. 输出 (Output):当发生转换时产生的动作或结果。
  4. 初始状态 (Initial State):系统开始运行时最先进入的状态。

有限状态机可以用来建模和描述诸如自动控制系统、编程语言解析器、协议实现等各种问题。通过定义不同的状态以及它们之间的关系,能够清晰地表达系统可能出现的所有情况,并提供了一种直观且形式化的方法来对系统进行分析与设计。

常见的红绿灯🚥就可以抽象成一个简单的状态机。在无人为干预的状态下,红灯、绿灯、黄灯三种是按照既定的顺序变换的,红灯结束一定是绿灯,不会跳到黄灯。

image-20240418212417440

  • 初始状态:绿灯(Green)
  • 事件和动作:
    • 当检测到车辆需要停车时触发事件TransitionToRed,红灯亮起
    • 当等待时间结束或者通过某种方式确认没有交通阻塞时触发事件TransitionToGreen,绿灯亮起
  • 状态转移规则:
    • 绿灯(Green):如果接收到TransitionToRed事件,则从绿灯切换到红灯。
    • 红灯(Red):如果接收到TransitionToGreen事件,则从红灯切换回绿灯。
  • 最终状态:循环运行这些过程

基于这个,我们可以手动实现一个简单的状态控制

enum TrafficLightStatus {
  Red,
  Yellow,
  Green
}

export class TrafficLightMachine {
  private trafficLightStatus: TrafficLightStatus;

  constructor() {
    this.trafficLightStatus = TrafficLightStatus.Red;
  }

  toGreen() {
    if (this.trafficLightStatus === TrafficLightStatus.Red) {
      this.trafficLightStatus = TrafficLightStatus.Green;
    } else {
      this.throwError()
    }
  }

  toYellow() {
    if (this.trafficLightStatus === TrafficLightStatus.Green) {
      this.trafficLightStatus = TrafficLightStatus.Yellow;
    } else {
      this.throwError()
    }
  }

  toRed() {
    if (this.trafficLightStatus === TrafficLightStatus.Yellow) {
      this.trafficLightStatus = TrafficLightStatus.Red;
    } else {
      this.throwError()
    }
  }

  throwError() {
    throw new Error('不合法的状态切换');
  }
}

我们通过类定义了一个简单的状态机,通过if-else来控制了状态流转的正确性。但是这仅限于使用在一些简单的场景,当遇到一些复杂的业务场景时,使用自定义状态机是很难维护的。

XState

这时候我们需要引入一些状态机库来帮助我们编排状态机,JavaScript阵营最流行的状态机工具就是XState了。

我们先用XState的语法来实现一下上面红绿灯的状态机(别忘了安装,npm i state @state/react)。

import { createMachine } from "xstate";

export const machine = createMachine({
  id: 'trafficLight',
  initial: 'red',
  states: {
    red: {
      on: {
        NEXT: 'green'
      }
    },
    green: {
      on: {
        NEXT: 'yellow'
      }
    },
    yellow: {
      on: {
        NEXT: 'red'
      }
    }
  }
})

可以看到比我们自定义类来实现要简洁不少,通过一个对象描述了状态机的初始状态以及状态变化的事件,那么效果如何,通过页面来看下。

import { useMachine } from '@xstate/react';
import { trafficLightMachine } from './xstate';
import styles from './index.less'

export default function XState() {
  const [state, send] = useMachine(trafficLightMachine);

  return (
    <div>
      <ul className={styles.light}>
        {
          ['red', 'green', 'yellow']
            .map(i =>
              <li key={i} className={state.matches(i) ? styles.active : ''} />
            )
        }
      </ul>

      <button onClick={() => send({ type: 'NEXT' })}>
        Next
      </button>
    </div>
  )
}

涉及到的API后面会介绍,先看下效果,可以看到能够正常切换红绿灯状态。

trafficLight

Tips:我们可以使用可视化工具或者编辑器来查看及编辑状态机,例如上面红绿灯的状态机渲染效果如下。

状态节点

上面👆看完了xstate怎么使用,下面来看下xstate的API是怎么定义的。定义一个状态机通过createMachine这个函数来创建,第一个参数是一个对象,用于定义状态机的基本状态

createMachine({
  // 状态机标识
  id: 'trafficLight',
  // 初始状态
  initial: 'red',
  // 状态机的本地 context
  context: {
  },
  // 状态
  states: {
  }
})

id是当前状态机的唯一标识;initial是状态机的初始状态;context时一个上下文(拓展状态),作用是存储一些非状态的数据,比如上面的红绿灯,如果context加一个count用于统计切换次数(没有任何实际意义);states就是状态机最核心的状态节点了,描述了状态机的所有状态集合,比较复杂,详细介绍。

状态节点有五种类型:

  1. Atomic(原子状态):原子状态是状态机中的基本状态,它表示状态机的一个单一状态。在原子状态中,不会有子状态,原子状态是最基本的状态。
  2. Compound(复合状态):复合状态是一个可以包含子状态的状态。复合状态本身可以是活动的或非活动的,它可以具有不同的子状态。当进入复合状态时,可以同时进入该复合状态的子状态。
  3. Parallel(并行状态):并行状态允许多个子状态同时存在,它们相互独立,不会相互影响。并行状态通常用于表示系统中并行运行的多个任务或功能。
  4. History(历史状态):历史状态允许状态机记住先前的状态,以便在重新进入复合状态时恢复到先前的状态。历史状态有两种类型:浅历史状态和深历史状态。浅历史状态只记住当前级别的状态,而深历史状态会记录当前级别及其所有子状态的状态。
  5. Final(最终状态):最终状态表示状态机已经完成其工作,并且不会再有任何状态转换。一旦状态机达到最终状态,它就会终止。在状态机完成了所有必要的任务后,通常会进入最终状态。

事件及状态转换

事件是导致状态机状态变更的原因,在定义状态节点的时候通过on属性来监听事件,例如

createMachine({
  // ... 
  on: {
    EVENT_1: 'state2',
    '': 'state3', // Null事件,立即切换
    '*': 'state4', // 通配事件,任何事件都可以触发
  },
  // ...
})

事件通常是一个字符串或者一个包含type属性的对象,例如下面两个事件是等价的,只不过对象形式可以携带与事件相关的参数。

const stringEvent = 'TIMER';
const objectEvent = {
  type: 'TIMER',
  param: 'param1'
};

const lightMachine = createMachine({
  /* ... */
});
const greenState = lightMachine.initialState;
lightMachine.transition(greenState, stringEvent);

或者触发事件的也可以是时钟(定时器),通过after对象定义延迟事件触发事件的毫秒数,例如之前的红绿灯就可以完全通过延迟事件实现

export const trafficLightMachine = createMachine({
  id: 'trafficLight',
  initial: 'red',
  states: {
    red: {
      after: {
        10000: { target: 'green' }
      }
    },
    green: {
      after: {
        10000: { target: 'yellow' }
      }
    },
    yellow: {
      after: {
        3000: { target: 'red' }
      }
    }
  }
})

又或者通过always对象配置无事件的状态转换(当状态切换到某一状态时立即切换到另一状态)

export const shoppingMachine = createMachine({
  id: "always",
  initial: "a",
  states: {
    a: {
      on: {
        CHANGE: 'b'
      }
    },
    b: {
      always: {
        target: 'c'
      }
    },
    c: {}
  }
});

如果不想进行状态切换,而只是执行一些个动作,可以通过禁止转换的形式实现,将事件声明为undefined即可。

还有一种自转换,当状态到达自身时,可以退出然后重新进入自身,有内部转换和外部转换两种形式,内部转换不会退出也不会重新进入自身,但可能会进入不同的子状态,外部转换将退出并重新进入自身,也可能退出/进入子状态。说白了就是内部转换不会触发entry和exit动作,两种转换的形式如下。

const wordMachine = createMachine({
  id: 'word',
  initial: 'left',
  states: {
    left: {},
    right: {},
    center: {},
    justify: {}
  },
  on: {
    LEFT_CLICK: 'word.left', // 外部转换
    RIGHT_CLICK: '.right', // 内部转换
    CENTER_CLICK: { target: '.center', internal: false }, // 同 'word.center'
    JUSTIFY_CLICK: { target: 'word.justify', internal: false } // 同 'word.justify'
  }
});

触发事件

动作

动作(Action)说白了就是一个可执行的函数,有三种类型:entry动作、exit动作和状态转换时执行的动作,执行顺序为退出节点的exit、所选转换上定义的actions,进入节点的entry。

动作有两种定义方式,直接定义在事件中或者在options中声明,action函数接收两个参数当前的上下文数据和触发动作的事件。

export const shoppingMachine = createMachine(
  {
    id: "shopping",
    initial: "a",
    states: {
      a: {
        on: {
          CHANGE_B: {
            target: "b",
            actions: (context, event) => console.log("transform b"),
          },
          CHANEG_C: {
            target: "c",
            actions: "actionC",
          },
        },
      },
      b: {},
      c: {},
    },
  },
  {
    actions: {
      actionC: (context, event) => console.log("transform c"),
    },
  }
);

如果想在状态改变的同时立即为新的状态执行事件,可以通过raise来发送升高动作。

states: {
  entry: {
    on: {
      RAISE: {
        target: 'middle',
        // 立即为“middle”调用 NEXT 事件
        actions: raise({ type: 'NEXT' })
      }
    }
  },
  middle: {
    on: {
      NEXT: { target: 'last' }
    }
  },
  last: {},
}

如果想在两个状态机之间进行交互,可以通过发送动作和响应动作来实现,使用sendTo方法来声明一个发送动作,respond方法声明响应动作。⚠️两个函数执行的返回值是一个动作。

const authServerMachine = createMachine({
  initial: 'waitingForCode',
  states: {
    waitingForCode: {
      on: {
        CODE: {
          actions: respond({ type: 'TOKEN' }, { delay: 10 })
        }
      }
    }
  }
});

const authClientMachine = createMachine({
  initial: 'idle',
  states: {
    idle: {
      on: {
        AUTH: { target: 'authorizing' }
      }
    },
    authorizing: {
      invoke: {
        id: 'auth-server',
        src: authServerMachine
      },
      entry: sendTo('auth-server', { type: 'CODE' }),
      on: {
        TOKEN: { target: 'authorized' }
      }
    },
    authorized: {
      type: 'final'
    }
  }
});

和发送动作类似的有一个forwardTo转发动作,通过ID转发到对应的服务。

function alertService(_, receive) {
  receive((event) => {
    if (event.type === 'ALERT') {
      alert(event.message);
    }
  });
}

const parentMachine = createMachine({
  id: 'parent',
  invoke: {
    id: 'alerter',
    src: () => alertService
  },
  on: {
    ALERT: { actions: forwardTo('alerter') }
  }
});

invoke: 调用其他服务,后面服务部分会提到。

如果需要打印日志,可以使用log日志动作,接收两个可选参数,第一个是日志内容,可以是一个字符串或者一个函数,会在执行函数是传递context和event参数;第二个是用于标记消息的字符串。

on: {
  FINISH: {
    target: 'end',
    actions: log(
      (context, event) => `count: ${context.count}, event: ${event.type}`,
      'Finish label'
    )
  }
}

如果需要更新context的值,可以通过assign分配动作来进行context值的更新

on: {
  INCREMENT: {
    actions: assign({
      count: ({ context, event }) => context.count + event.value,
    }),
  },
},

当某些场景下需要取消延迟事件的时候,可以使用cancel取消动作。

const machine = createMachine({
  on: {
    event: {
      actions: sendTo(
        'someActor',
        { type: 'someEvent' },
        {
          id: 'someId',
          delay: 1000,
        },
      ),
    },
    cancelEvent: {
      actions: cancel('someId'),
    },
  },
});

XState V5新加入了一个排队动作(Enqueue Action),可以将按顺序执行的动作排入队列。同时提供了很多工具方便操作,例如

const machine = createMachine({
  // ...
  entry: enqueueActions(({ context, event, enqueue, check }) => {
    // 更新上下文
    enqueue.assign({
      count: context.count + 1
    });

    if (event.someOption) {
      enqueue.sendTo('someActor', { type: 'blah', thing: context.thing });

      // 字符串形式
      enqueue('namedAction');
      // 对象形式,传参数
      enqueue({ type: 'greet', params: { message: 'hello' } });
    } else {
      // 函数形式
      enqueue(() => console.log('hello'));
    }

    // 通过守卫限制执行
    if (check({ type: 'someGuard' })) {
      // ...
    }
  })
});

守卫

守卫(Guard)的本质就是一个函数,作用是在状态转换之前进行的一次校验,如果通过校验则继续进行状态转换,否则进行下一个守卫的校验或者中断状态转换。

守卫的使用形式有三种,字符串、对象和函数.使用字符串和对象形式时需要在声明状态机的options中声明守卫,然后通过对应的属性名称匹配,对象形式可以通过params属性传递参数;行内守卫直接在状态转换事件中声明一个函数,可以接受context对象进行辅助判断。一个事件监听可以绑定多个守卫转换,会依次执行直到出现第一个成功的守卫。

export const guardMachine = createMachine(
  {
    // ...
    states: {
      state1: {
        on: {
          GUARD: [
            // 缩写形式
            {
              guard: "isValid",
              target: "state2",
            },
            // 行内形式
            {
              guard: ({ context, event }) => true,
              target: "state2",
            },
            // 对象形式
            {
              guard: {
                type: "isValidByParam",
                // 参数可以是一个函数,会将返回值作为函数
                // 下面是等价的,() => ({tag: "run"})
                params: {
                  tag: "run",
                },
              },
              target: "state2",
            },
          ],
        },
      },
      state2: {},
    },
  },
  {
    guards: {
      isValid: ({ context }) => {
        return context.messages.length > 0;
      },
      isValidByParam: (_, params) => {
        return params.tag === "run";
      },
    },
  }
);

一个状态转换中可以组合多个守卫实现复杂验证,通过andor函数实现条件组合

on: {
  GREEN: {
    target: 'green',
    guard: and(['returnTrue', 'isNotZero'])
  },
  RED: {
    target: 'red',
    guard: or(['returnTrue', 'isNotZero'])
  },
},

如果要通过状态值之间进行守卫行为,可以使用stateIn状态内守卫

on: {
  GREEN: {
    target: 'green',
    guard: stateIn('red')
  },
},

上下文

上下文(Context)是状态机中存储数据的方式,存储所有参与者相关的数据,初始化时直接声明context,不能直接更改,如果要更新context中的数据,需要使用assign函数来更新context中的值。

const feedbackMachine = createMachine({
  context: {
    feedback: 'Some feedback',
  },
  on: {
    'feedback.update': {
      actions: assign({
        feedback: ({ event }) => event.feedback,
      }),
    },
  },
});

上下文可以通过传递返回初始值的函数来惰性初始化context,这会使得每个参与者(Actor)拥有自己的context对象。

const feedbackMachine = createMachine({
  context: () => ({
    feedback: 'Some feedback',
    createdAt: Date.now(),
  }),
});

const feedbackActor = createActor(feedbackMachine).start();

关于XState的内容比较庞大,本文仅挑选了常用的部分,更多内容可以查看官网文档


前端小白