← 中文资源

简体中文版本

带转移守卫的类型化状态机

用类型化状态与事件建模有限状态机,支持守卫、转移历史与非法转移的明确报错。

阅读英文原文

功能说明

该示例用泛型约束状态名与事件名,构建一个小型有限状态机。每条转移声明起点、事件、终点与可选守卫,守卫可以读取上下文并决定是否允许转移。can 用于预判,send 执行转移并把新状态压入历史;非法事件或守卫拒绝都会抛出带上下文的错误。适合订单状态、上传流程、连接生命周期等需要严格状态约束的逻辑。

关键代码

构造器校验转移数组、起点、事件与终点非空,并确认守卫是函数;转移被复制到内部数组,避免外部修改。can 与 send 都按当前状态和事件查找第一条匹配转移,保证两者结论一致。守卫接收上下文与事件,可在运行时依赖余额、权限等条件。转移成功后先更新状态,再追加历史并调用回调,回调抛错不会回滚状态。

使用注意

守卫应保持纯函数语义,若在 can 中产生副作用,随后 send 会再次调用它。历史只记录成功到达的状态,不包含初始状态,也没有回滚或撤销接口。同一状态与事件存在多条转移时只有第一条生效,守卫拒绝后不会继续尝试后续转移。类型只在编译期约束,运行时仍可能传入非法事件字符串。onTransition 抛错会阻止 send 返回,但状态已经改变。

验证方式

定义三态流程,测试 send 依次到达终点并检查 previousStates;用守卫让某条转移在上下文不满足时拒绝,断言 can 返回 false 且 send 抛错;发送未声明事件断言错误信息包含当前状态与事件;测试自转移会被记录;构造缺少字段或守卫非函数的转移断言 TypeError;确认修改外部转移数组不影响实例。

代码示例

export interface Transition<State extends string, Event extends string, Context> {
  from: State;
  event: Event;
  to: State;
  guard?: (context: Context, event: Event) => boolean;
}

export interface StateMachineOptions<State extends string, Event extends string, Context> {
  context: Context;
  onTransition?: (next: State, previous: State, event: Event) => void;
}

export class StateMachine<State extends string, Event extends string, Context = undefined> {
  private current: State;
  private readonly transitions: ReadonlyArray<Transition<State, Event, Context>>;
  private readonly context: Context;
  private readonly onTransition?: (next: State, previous: State, event: Event) => void;
  private readonly history: State[] = [];

  constructor(
    initial: State,
    transitions: ReadonlyArray<Transition<State, Event, Context>>,
    options: StateMachineOptions<State, Event, Context>,
  ) {
    if (!Array.isArray(transitions)) throw new TypeError("transitions must be an array");
    for (const transition of transitions) {
      if (!transition.from || !transition.event || !transition.to) {
        throw new TypeError("transition requires from, event and to");
      }
      if (transition.guard !== undefined && typeof transition.guard !== "function") {
        throw new TypeError("guard must be a function");
      }
    }
    this.current = initial;
    this.transitions = [...transitions];
    this.context = options.context;
    this.onTransition = options.onTransition;
  }

  get state(): State {
    return this.current;
  }

  get previousStates(): readonly State[] {
    return [...this.history];
  }

  can(event: Event): boolean {
    return this.transitions.some((transition) =>
      transition.from === this.current &&
      transition.event === event &&
      (!transition.guard || transition.guard(this.context, event)));
  }

  send(event: Event): State {
    const transition = this.transitions.find((candidate) =>
      candidate.from === this.current && candidate.event === event);
    if (!transition) {
      throw new Error("no transition from " + this.current + " on " + event);
    }
    if (transition.guard && !transition.guard(this.context, event)) {
      throw new Error("guard rejected " + this.current + " -> " + transition.to + " on " + event);
    }
    const previous = this.current;
    this.current = transition.to;
    this.history.push(this.current);
    this.onTransition?.(this.current, previous, event);
    return this.current;
  }
}