从 0 到 1 带你打造一个工业级 TypeScript 状态机

在前端开发的江湖里,状态管理是每个侠客都必须修炼的内功心法。当组件逻辑日渐复杂,isLoading, isError, isSuccess, isSubmitting 这些布尔类型的"状态"变量开始纠缠不清时,我们的代码就如同走火入魔,充满了不可预知的行为和难以修复的 Bug。

此时,一门古老而强大的武学------有限状态机 (Finite State Machine, FSM)------便能助我们理清思绪,让状态的流转如行云流水般清晰、可控。

本文将不仅仅是介绍状态机,而是带你从零开始,亲手用 TypeScript 锻造一个工业级的、可扩展的、类型安全的状态机。我们将深入其设计的每一个细节,理解其背后的设计模式、数据结构选择与思想权衡。

为什么是 TypeScript 状态机?答案是:确定性

在探讨实现之前,我们必须明确状态机的核心价值。它由四大要素构成:

  • 状态 (State) :系统在任何时刻所处的、唯一的、离散的条件。例如,数据请求的生命周期可以是 idle | loading | success | error
  • 事件 (Event):触发状态从一个到另一个的外部输入或动作。
  • 转换 (Transition) :一个规则,定义了在特定状态 下,响应某个事件 后,应该进入哪一个新状态
  • 动作 (Action):在发生转换时执行的副作用(Side Effect)。

将这四大要素与 TypeScript 结合,会产生惊人的化学反应。TypeScript 的核心优势------类型系统------能让"非法的状态无处遁形"。

告别布尔值地狱:

typescript 复制代码
// ❌ 混乱的布尔值,可能出现 isSuccess 和 isError 同时为 true 的非法状态
interface State {
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  data: any;
  error: Error | null;
}

// ✅ 使用 TypeScript 的联合类型,状态在任何时刻都必然是四者之一
type RequestState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success', data: any }
  | { status: 'error', error: Error };

通过这种方式,我们利用编译器强制保证了状态的原子性和互斥性,这是构建健壮系统(Robustness)的第一步。

知己知彼:主流 TypeScript 状态机库选型指南

在决定自己造轮子之前,了解社区中已有的优秀轮子是必修课。这能帮助我们理解不同库的设计哲学和适用场景。

核心特性 最适用场景 推荐技术栈
tyfsm 极度轻量 (<1KB),强类型(使用歧视联合),零依赖 UI 状态、网络请求等简单状态管理 React, Vue, Angular
ts-fsm 支持异步转换,语法类似 XState,功能较全面 I/O 密集型操作(如 API 队列) NestJS 后端服务, Node.js
wstate 内置 React Hooks,提供状态机工厂模式 多实例的复杂组件状态管理 React, Next.js
Stately.js (XState) 声明式 DSL,可视化状态图,功能最强大 复杂的、多层次的业务流程,跨框架逻辑 大型企业级应用

选型建议 :如果你的需求简单,tyfsm 是绝佳选择。如果你的应用(尤其是后端)有大量异步流程,ts-fsm 值得考虑。如果你是 React 重度用户,wstate 提供了很好的集成。而对于需要可视化、能清晰表达整个业务逻辑的复杂系统,XState 是当之无愧的王者。

那我们为什么还要自己实现?因为理解内部原理,才能更好地运用外部工具。我们的目标是构建一个集众家之长、设计思想清晰的"教学级"工业实现。

庖丁解牛:从核心设计到实现

现在,让我们卷起袖子,一步步解构 StateMachine 的锻造过程。我们将聚焦于关键代码,理解其背后的设计决策。

第一步:定义契约 (Interfaces & Types)

在动工之前,我们先用 TypeScript 定义好整个系统的"蓝图"。这是类型驱动开发的基石。

typescript 复制代码
// 定义基本类型
export type State = string | number;
export type StateMachineContext = Record<string, any>;

// 状态机的公开 API
export interface StateMachine<S extends State, C extends StateMachineContext> {
  get state(): S;
  transitionTo(state: S, context?: C): Promise<void> | void;
  // ... 其他方法
}

// 状态机的配置对象
export interface StateMachineConfig<S extends State, C extends StateMachineContext> {
  /** 初始化状态 */
  initialState: S;
  /** 状态转换规则配置 */
  transitions: {
    from: S;
    to: S | S[];
    action?: (from: S, to: S, context?: C) => void | Promise<void>;
  }[];
  /** 自定义状态验证器(可选) */
  validator?: (from: S, to: S) => boolean;
  /** 为每个状态定义可选的进入/离开钩子 */
  stateHooks?: {
    [key in S]?: {
      onEnter?: (state: S, context?: C) => void | Promise<void>;
      onLeave?: (state: S, context?: C) => void | Promise<void>;
    };
  };
  /** 错误处理 */
  errorHandler?: {
    handle: (error: Error) => void;
  };
}

设计细节

  • 我们使用泛型 <S, C> 贯穿始终,这保证了从配置到运行时,状态 (S) 和上下文 (C) 的类型都是连贯和安全的。
  • transitions.to 支持单个状态或状态数组 S | S[],这为设计提供了灵活性,一个状态可以合法地转换到多个目标。

状态机四要素的落地

  1. 状态 (States) :我们通过泛型 S extends string | number 来定义,允许用户使用 enum 或字符串/数字字面量联合类型,充分利用 TypeScript 的类型检查。

  2. 转换 (Transitions) :用户通过一个声明式的 transitions 数组来配置规则。

typescript 复制代码
transitions: [
    { from: 'idle', to: 'loading' },
    { from: 'loading', to: ['success', 'error'] } // 支持多目标状态
]

数据结构选择 :在内部,我们并没有在每次转换时都去遍历这个数组。为了性能,我们在构造函数中将它转换成了一个 Map<S, Set<S>> 结构,即 _transitionTable选择原因Map 的键查找时间复杂度接近 O(1),Sethas() 方法也是 O(1)。这意味着验证一个转换是否合法的操作,其性能与转换规则的数量无关,效率极高。

  1. 动作 (Actions) :我们提供了两种类型的动作,满足不同粒度的需求。
    • 转换动作 (action) : 绑定在具体的 from -> to 转换上,用于执行该转换独有的副作用。
    • 状态钩子 (onEnter/onLeave) : 绑定在某个状态 上。无论从哪个状态进入或离开,都会触发,适合执行通用逻辑(如进入 loading 就显示 Spinner)。

第二步:构建基石 (StateMachineBase)

StateMachineBase 是我们所有状态机的抽象基类,它封装了最核心、最通用的逻辑。

typescript 复制代码
export abstract class StateMachineBase<S, C> implements StateMachine<S, C> {
  protected _state: S;
  protected _transitionTable: Map<S, Set<S>>;
  // ... 其他属性

  constructor(config: StateMachineConfig<S, C>) {
    this._state = config.initialState;
    // ... 初始化 Maps
    this._buildTransitionTable(config.transitions);
    this._buildStateHooks(config);
  }

  // **数据结构选择**:性能的关键
  private _buildTransitionTable(transitions) {
    // ...
    transitions.forEach(config => {
      // ...
      toStates.forEach(toState => {
        this._transitionTable.get(from)!.add(toState);
        // ...
      });
    });
  }

  // 转换验证逻辑
  protected validateTransition(nextState: S): void {
    const allowed = this._transitionTable.get(this.state);
    if (!allowed || !allowed.has(nextState)) {
      throw new InvalidTransitionError(this.state, nextState, Array.from(allowed || []));
    }
  }
  // ...
}

实现细节与注意事项

  • _buildTransitionTable 是构造函数中的核心。它将用户友好的配置数组预处理成高效的 Map<State, Set<State>> 结构。这一步的预处理,让运行时的 validateTransition 检查速度极快 (接近 O(1)),这是一个典型的以空间换时间的优化策略。
  • 错误处理 :我们没有简单地 throw new Error(),而是定义了 InvalidTransitionError 等自定义错误类型。这允许调用者通过 instanceof 进行精确的、类型安全的错误处理,而不是依赖脆弱的错误消息文本。这是构建健壮 API 的关键一环。

第三步:同步 vs. 异步,分而治之的艺术

这是我们设计中的一个关键决策。为什么不创建一个能同时处理同步和异步的"万能" transitionTo 方法呢?答案是:避免复杂性和不必要的性能开销

  • async/await 具有"传染性"。一个 async 函数会迫使其调用链上的所有函数都返回 Promise。如果将 transitionTo 默认设为 async,那么即使用户的所有 action 和钩子都是同步的,他也必须用 await 来调用,这既不符合直觉,也带来了微小的 Promise 开销。

因此,我们提供了两个独立的实现:

  • SyncStateMachine: 纯粹的同步执行,非常适合 UI 状态管理等即时响应场景。
  • AsyncStateMachine : 专为异步操作设计,transitionTo 返回 Promise
typescript 复制代码
// AsyncStateMachine 的核心健壮性设计
export class AsyncStateMachine<S, C> extends StateMachineBase<S, C> {
  private isTransitioning = false;

  async transitionTo(state: S, context?: C): Promise<void> {
    if (this.isTransitioning) {
      throw new ConcurrentTransitionError(this.state, state);
    }
    this.isTransitioning = true;
    try {
      // ... 异步转换流程
    } finally {
      this.isTransitioning = false;
    }
  }
}

实现注意事项

  • AsyncStateMachine 中的 isTransitioning 标志至关重要。它能有效防止在前一个异步转换(如 API 请求)完成前,用户又触发了另一次转换,从而避免了竞态条件 (Race Condition)
  • try...finally 结构是绝对必要 的。它确保了无论转换流程成功与否(例如,某个异步 action 中抛出异常),isTransitioning 标志最终都会被重置为 false,从而避免状态机被永久"锁死"。

通过 createStateMachine({ async: true }) 工厂函数,我们将选择权交给了用户,让他们根据实际场景选择最合适的引擎。

第四步:设计思想升华------微内核与插件化架构

我们的核心设计哲学是**"微内核"架构**,它基于开闭原则:对扩展开放,对修改关闭。

  • 内核 (StateMachineBase):只负责最纯粹的状态转换逻辑。
  • 扩展 (Plugins) :所有其他功能,如日志、历史记录、持久化等,都作为独立的插件存在,通过监听内核暴露的 onChange 事件来工作。

这种设计带来了极高的可维护性 (Maintainability)可扩展性 (Extensibility)

我们的插件系统建立在两种经典的设计模式之上:

  1. 观察者模式 (Observer Pattern)StateMachine 内核是被观察者 (Subject) ,通过 onChange 暴露订阅接口。插件都是观察者 (Observer)
  2. 模板方法模式 (Template Method Pattern) :我们抽象了一个 StateMachinePlugin 基类,它定义了插件的生命周期骨架(attach/detach 方法),并将具体实现(onAttach, onDetach, onStateChange)留给子类。

从零到壹,打造你的第一个插件 (LoggerPlugin)

让我们亲手实现一个简单的 LoggerPlugin,它会在每次状态转换时,向控制台打印详细的日志。

插件蓝图:StateMachinePlugin 基类

typescript 复制代码
export abstract class StateMachinePlugin<S, C, Config extends object = {}> {
  // ... 封装 attach/detach 逻辑
  /**
    * (必需) 在插件附加到状态机时被调用。
    * 用于执行初始化逻辑,例如恢复持久化状态或记录初始状态。
    * 可以是同步或异步的。
    */
  protected abstract onAttach(): Promise<void> | void;
  /**
    * (必需) 在每次状态机状态变化时被调用。
    * 这是插件实现其核心功能的地方。
    */
  protected abstract onStateChange(from: S, to: S, context?: C): void;
  /**
    * (可选) 在插件从状态机分离时被调用。
    * 用于执行任何必要的清理工作。
    */
   protected onDetach(): void {
        // 默认无操作
   }
}

现在,我们来继承这个基类,实现 LoggerPlugin

typescript 复制代码
// state-machine.logger-plugin.ts

import { StateMachinePlugin } from './state-machine.plugin-base';
import { State, StateMachineContext, StateMachine } from './state-machine.core';

// 1. (可选) 为插件定义配置接口
interface LoggerPluginConfig {
  prefix?: string;
}

// 2. 继承基类
export class LoggerPlugin<
  S extends State,
  C extends StateMachineContext
> extends StateMachinePlugin<S, C, LoggerPluginConfig> {

  private readonly prefix: string;

  // 3. 实现构造函数 (如果需要处理配置)
  constructor(machine: StateMachine<S, C>, config?: LoggerPluginConfig) {
    super(machine, config);
    this.prefix = this.config?.prefix || '[FSM Logger]';
  }

  // 4. 实现 onAttach 方法 (插件附加时执行)
  protected onAttach(): void {
    console.log(`${this.prefix} Attached. Initial state: "${String(this.machine.state)}"`);
  }

  // 5. 实现 onStateChange 方法 (核心逻辑)
  protected onStateChange(from: S, to: S, context?: C): void {
    // 使用 console.group 来美化输出
    console.groupCollapsed(`${this.prefix} State Transition: ${String(from)} → ${String(to)}`);
    console.log(`Timestamp: ${new Date().toISOString()}`);
    console.log(`From State:`, from);
    console.log(`To State:`, to);
    if (context) {
      console.log('Context:', context);
    }
    console.groupEnd();
  }

  // 6. (可选) 实现 onDetach 方法 (插件分离时执行)
  protected onDetach(): void {
    console.log(`${this.prefix} Detached.`);
  }
}

如何使用我们的新插件?

typescript 复制代码
const machine = createStateMachine({
  initialState: 'idle',
  transitions: [{ from: 'idle', to: 'loading' }],
});

// 实例化插件
const logger = new LoggerPlugin(machine, { prefix: '[MyDataFetcher]' });

// 附加插件,启动监听
await logger.attach();

// 触发一次转换
machine.transitionTo('loading', { trigger: 'user_click' });

你会在控制台看到这样的输出,清晰明了:

text 复制代码
[MyDataFetcher] Attached. Initial state: "idle"
▼ [MyDataFetcher] State Transition: idle → loading
    Timestamp: 2025-08-26T...
    From State: idle
    To State: loading
    Context: { trigger: 'user_click' }

就这样,我们从零到一实现了一个功能完整、可配置的插件,而无需触碰状态机核心的任何代码。这就是开闭原则 (Open/Closed Principle) 的完美体现:对扩展开放,对修改关闭

深度剖析:action vs. hooks 和事件的隐式设计

actionhooks 的职责精准划分

在我们设计的状态机中,action(转换动作)和 hooks(状态钩子 onEnter/onLeave)都是用来执行副作用的,但它们的设计意图、粒度和生命周期完全不同。理解它们的差异是精通这个状态机库的关键。

特性 action (转换动作) hooks (onEnter/onLeave 状态钩子)
绑定对象 一个具体的 "转换" (Transition) 一个具体的 "状态" (State)
触发时机 在从 A 状态 转换到 B 状态的过程中触发 进入 (enter)离开 (leave) A 状态时触发
粒度 精细 (Fine-grained) 粗略 (Coarse-grained)
语境 "当这件事发生时,做......" "当处于这个状态时,做......"
一对一/多对一 通常是一对一的关系(一个转换对应一个动作) 通常是多对一的关系(多个转换可能进入同一个状态)

1. action (转换动作):描述"因果"

action 的核心是与一个特定的转换 绑定。它回答的问题是:"当状态从 A 变为 B 这个具体事件发生时,我应该执行什么副作用?"

代码示例

typescript 复制代码
transitions: [
  // 这个 action 只在从 'idle' 到 'loading' 时触发
  { from: 'idle', to: 'loading', action: fetchUserData },
  // 这个 action 只在从 'editing' 到 'saving' 时触发
  { from: 'editing', to: 'saving', action: saveDocument },
  // 从 'loading' 到 'idle' 没有 action
  { from: 'loading', to: 'idle' },
]

核心使用场景

  • 执行一次性、与转换强相关的任务 :最典型的就是 API 调用。fetchUserData 这个动作的起因,正是"用户触发了数据加载"这个转换。它不应该在任何其他时候被调用。
  • 传递转换特定的数据action 可以接收 context,这个 context 往往包含了触发这次转换的特定信息,例如 saveDocument(from, to, { content: '...' })
  • 描述业务流程中的"动词":支付、保存、发送、取消......这些都是典型的转换动作。

2. hooks (onEnter/onLeave):描述"状态"的固有行为

hooks 的核心是与一个特定的状态 绑定。它回答的问题是:"每当系统进入离开 X 这个状态时,应该发生什么?"它不关心你是从哪个状态过来的,也不关心你要去哪个状态。

代码示例

typescript 复制代码
stateHooks: {
  loading: {
    // 无论从 idle 还是 retrying 进入 loading,都会显示 Spinner
    onEnter: () => showSpinner(),
    // 无论从 loading 去往 success 还是 error,都会隐藏 Spinner
    onLeave: () => hideSpinner(),
  },
  error: {
    // 每次进入 error 状态,就记录一条日志
    onEnter: (state, context) => logError(context.error),
  }
}

核心使用场景

  • 管理与状态生命周期绑定的 UI : 这是最常见、最强大的用途。显示/隐藏加载指示器、禁用/启用按钮、播放/停止动画等,都应该放在 onEnter/onLeave 钩子中。这使得 UI 逻辑与状态完全同步,代码高度内聚。
  • 资源管理 (Setup/Teardown) :进入某个状态时建立连接或订阅 (onEnter),离开时断开连接或取消订阅 (onLeave)。例如,进入 live-chat 状态时建立 WebSocket 连接,离开时关闭它。
  • 状态的"副作用初始化": 进入某个状态时,需要启动一个定时器或监听某个事件。离开时,则必须清理掉,防止内存泄漏。

一个比喻来帮助理解

  • action"买票" 这个动作,你只有在决定 "从北京站" 前往 "上海站" 这个具体的行程时,才会执行"买票"这个动作。你不会在从"天津站"到"南京站"的行程中执行这张票的购买动作。
  • hooks"在上海" 的行为。onEnter: '上海站' 就是 "到达 上海站后,打开手机导航,开始游览"。你不管是从北京来的,还是从南京来的,只要你到了上海站,你都会做这件事。onLeave: '上海站' 就是 "离开上海站前,买点当地特产,发个朋友圈告别"。你不管下一站是去杭州还是回北京,只要你准备离开上海站,你就会做这件事。

隐式的"事件":一种更符合前端直觉的设计

这里来解释一下:为什么我们的实现中,没有明确的、作为一等公民的"事件 (Events)"?

在经典的状态机理论(例如 UML 状态图)中,事件通常是显式的,状态机通过一个 send('EVENT_NAME')dispatch({ type: 'EVENT_TYPE' }) 的方法来接收事件。状态机会根据当前状态 和接收到的事件类型来决定下一个状态。

为什么我们的实现中,没有明确的 send('EVENT_NAME')而我们的实现,采用了一种更直接、更符合前端函数调用习惯的"目标状态驱动 (State-driven)"模型。

typescript 复制代码
// 我们的模型
machine.transitionTo('loading');

// 传统模型
machine.send('FETCH');

而我们的实现,采用了一种更直接、更符合前端函数调用习惯的"目标状态驱动 (State-driven)"模型。

typescript 复制代码
// 我们的目标状态驱动模型
const machine = createStateMachine({
  initialState: 'idle',
  transitions: [
    { from: 'idle', to: 'loading' }
  ],
});
machine.transitionTo('loading');

我们为什么选择这种模型?

  1. 心智模型更简单 :对于许多前端开发者来说,"我想要让组件进入 loading 状态"比"我需要发送一个 FETCH 事件来让组件进入 loading 状态"要更加直接。开发者思考的是状态本身,而不是触发状态的抽象事件。这降低了学习和使用的门槛。

  2. 事件是隐式存在的 :虽然没有 send('EVENT'),但"事件"并没有消失,它只是被隐式地包含在了 transitionTo 的调用中

    • 当你调用 machine.transitionTo('loading') 时,这个调用本身就可以被理解为一个匿名的、意图为"转换到 loading"的事件。
    • context 对象进一步扮演了事件载荷 (payload) 的角色。machine.transitionTo('error', { error: new Error('...') }) 就等同于 send({ type: 'REJECT', payload: new Error('...') })
  3. 减少样板代码 :在事件驱动模型中,你需要为每个事件命名,并在配置中显式地将事件映射到转换。在我们的模型中,这个映射被简化了:转换规则 from -> to 本身就定义了所有合法的"事件"(即所有合法的 transitionTo 调用)。

  4. 与现代前端框架的编程范式更契合 :在 React 或 Vue 中,我们通常通过调用一个函数来改变状态(如 setState('loading')),而不是分发一个事件对象。我们的 transitionTo API 与这种范式无缝对接。

我们的状态机通过将"事件"设计为对 transitionTo 的调用,在保留了状态机核心确定性的前提下,提供了一个更简洁、更符合现代前端开发直觉的 API。这是一种设计上的权衡 (Trade-off),它牺牲了一部分理论上的纯粹性,换来了更高的开发效率和更低的心智负担,对于绝大多数前端应用场景来说,这是一个非常明智的选择。

总结与展望

从一个简单的状态管理需求出发,我们利用 TypeScript 的类型系统构建了一个安全的基础,通过精巧的数据结构设计保证了性能,通过分离同步与异步实现兼顾了不同场景,最终通过一个优雅的插件化架构赋予了它无限的生命力。

我们打造的不仅仅是一个状态机,更是一个软件设计的范例。它体现了单一职责、开闭原则、依赖倒置等核心原则。

未来可以探索的方向

  • 层级与并行状态机:支持更复杂的嵌套状态,实现类似 XState 的功能。
  • 可视化工具:创建一个可以读取状态机配置并自动生成可视化状态图的工具。
  • 框架深度集成:为 React, Vue, Angular 提供官方的 Hooks 或 Wrapper,进一步简化使用。

希望这篇文章能为你提供启发,让你在面对复杂的状态逻辑时,能够自信地亮出"状态机"这把利剑,斩断乱麻,让代码重归清晰与稳定。

相关推荐
伍华聪9 分钟前
基于Vant4+Vue3+TypeScript的H5移动前端
前端
Nayana10 分钟前
axios-取消重复请求--CancelToken&AbortController
前端·axios
大舔牛18 分钟前
网站性能优化:小白友好版详解
前端·面试
转转技术团队26 分钟前
你的H5页面在折叠屏上适配了吗?
前端
北辰浮光38 分钟前
[Web数据控制]浏览器中cookie、localStorage和sessionStorage
前端·vue.js·typescript
Dolphin_海豚41 分钟前
charles proxying iphone
前端·ios
用户8417948145641 分钟前
vue 如何使用 vxe-table 来实现跨表拖拽,多表联动互相拖拽数据
前端·vue.js