陌上人如玉
公子世无双

3.13 XState

3.13 XState

一句话:有限状态机的价值是「用『显式的状态 + 合法的转移』替代『一堆布尔旗标』」——因为旗标能组合出大量非法状态(isLoading && isError && isSuccess),而状态机只允许穷举出的那几种状态;XState 在 FSM 之上加了「层级 / 并行 / 历史 / 延迟 / 副作用(invoke)」,所以它能表达「真实的业务流程」。

一、为什么需要状态机:从「旗标地狱」说起

// ❌ 用布尔旗标表达「一次提交」的状态
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);

// ⭐ 问题 1:非法组合是「可表达」的
setIsLoading(true);
setIsError(true);       // ⚠️ 「正在加载且出错了」是什么状态?没人定义过
setIsSuccess(true);     // ⚠️ 三个都为 true?

// ⭐ 问题 2:转移没有约束
if (isLoading) { /* 还能再次提交吗? */ }
// 「提交按钮该不该禁用」要写成:
const disabled = isLoading || isSuccess || (isError && !canRetry);
// ⚠️ 每加一个状态,这里就要改一次(容易漏)

// ⭐ 问题 3:状态数会「指数膨胀」
// 3 个旗标 = 8 种组合,其中「合法」的可能只有 4 种
// → 另外 4 种是「代码里没有处理、但运行时可能进入」的
// ✅ 用状态机表达同一个流程
import { setup, assign, fromPromise } from 'xstate';

const paymentMachine = setup({
  types: {
    context: {} as { retries: number; error: string | null; orderId: string | null },
    events: {} as
      | { type: 'SUBMIT' }
      | { type: 'RETRY' }
      | { type: 'CANCEL' },
  },
  actors: {
    authorize: fromPromise(async ({ input }: { input: { amount: number } }) => {
      const res = await api.authorize(input.amount);
      return res;
    }),
  },
  guards: {
    canRetry: ({ context }) => context.retries < 3,
  },
}).createMachine({
  id: 'payment',
  initial: 'idle',
  context: { retries: 0, error: null, orderId: null },
  states: {
    // ⭐ 每个状态「显式列出」它接受哪些事件 → 其它事件「自动被忽略」
    idle: {
      on: {
        SUBMIT: { target: 'authorizing', actions: assign({ error: null }) },
      },
    },
    authorizing: {
      // ⭐ invoke:进入这个状态时「启动异步 actor」,完成/失败时自动转移
      invoke: {
        src: 'authorize',
        input: { amount: 100 },
        onDone: {
          target: 'success',
          actions: assign({ orderId: ({ event }) => event.output.id }),
        },
        onError: {
          target: 'failed',
          actions: assign({ error: ({ event }) => String(event.error) }),
        },
      },
      // ⭐ CANCEL 只在「授权中」有意义 → 状态机保证「idle 时发 CANCEL 无效」
      on: { CANCEL: 'cancelled' },
    },
    failed: {
      on: {
        // ⭐ guard:条件转移(重试次数 < 3 才能重试)
        RETRY: {
          target: 'authorizing',
          guard: 'canRetry',
          actions: assign({ retries: ({ context }) => context.retries + 1 }),
        },
        CANCEL: 'cancelled',
      },
    },
    success: { type: 'final' },      // ⭐ final 状态:不能再转移出去
    cancelled: { type: 'final' },
  },
});
⭐⭐⭐ 状态机带来的四个「质变」:

① 【状态是「穷举的」】
   只有 `idle / authorizing / failed / success / cancelled` 五种,
   ⭐ **不存在「非法组合」**(因为它们不是布尔变量,而是「互斥的状态」)。

② 【转移是「显式的」】
   「哪个状态接受哪个事件、去哪里」全部写在结构里 →
   ⭐ **不会被遗漏**(发一个「当前状态不接受的事件」会被自动忽略)。

③ 【「不可能的状态」在写代码时就不可能存在】
   · `success` 是 `final` → **不能再被提交**(不需要 `if (isSuccess) return`)
   · `idle` 不接受 `CANCEL` → **不用写「未提交时取消失败」的处理**
   · ⭐ 于是「禁用逻辑」变成「状态推导」:`disabled = state.matches('authorizing')`

④ 【可以「可视化 + 可测」】
   状态图能被画出来(与产品/设计沟通时极其有用),
   且「每个转移」都能被单独测试(⭐ 不用「拼一堆旗标去模拟」)。

二、核心概念:七个词

⭐⭐⭐ 状态机的七个核心概念(一段话讲清):

① 【State(状态)】——「系统当前处在哪个阶段」(互斥的、有限的)
② 【Event(事件)】——「外界发生了什么」(`{ type: 'SUBMIT', ...payload }`)
③ 【Transition(转移)】——「在某个状态下、收到某个事件 → 去某个状态」
④ 【Guard(守卫)】——「这次转移的条件」(`guard: 'canRetry'`)⭐ 不满足就不转移
⑤ 【Action(动作)】——「转移时执行的副作用」(`assign` 改 context、打日志、调接口)
   区分:**entry / exit**(进入/离开状态时执行)
⑥ 【Context(上下文)】——「状态机携带的『数据』」(⭐ 注意:**它是「数据」不是「状态」**)
   ⚠️ 关键纪律:「**状态的『阶段』用 state 表达,不要用 context 里的旗标表达**」
     ❌ `context: { isLoading: true }` + `state: 'active'`
     ✅ 直接把 `loading` 做成一个 state
⑦ 【Actor(演员)】——「一个独立运行的状态机/异步任务」
   状态机可以「启动子 actor」(`invoke`)并与它通信(`sendTo`)
   ⭐ 这是 XState v5 的「统一模型」:**所有东西都是 actor**
⭐⭐⭐ 状态图(Statechart)在 FSM 之上的四个扩展:

① 【层级状态(Hierarchical)】⭐ 最常用
   loading: {
     initial: 'fetching',
     states: { fetching: {...}, processing: {...} },
     on: { CANCEL: 'idle' }         // ⭐ 父级的 on 会被「所有子状态」继承
   }
   ⭐ 好处:**共享转移**(不用在每个子状态里都写一遍 CANCEL)

② 【并行状态(Parallel)】⭐ 表达「同时进行的多个维度」
   active: {
     type: 'parallel',
     states: {
       audio: { initial: 'on', states: { on: {...}, off: {...} } },
       video: { initial: 'on', states: { on: {...}, off: {...} } },
     }
   }
   ⭐ 好处:`audio` 与 `video` **独立变化**(避免「4 个旗标」)
   ⚠️ 不是「正交分解一切」——只有当「两个维度真的独立」时才用

③ 【历史状态(History)】
   ⭐ 「回到上次离开时的那个子状态」(不用记住细节)
   states: {
     editing: {
       on: { PAUSE: 'paused' },
       states: { step1: {}, step2: {}, step3: {} },
     },
     paused: { on: { RESUME: { target: 'editing.hist' } } },   // ⭐ hist = 回到上次的子状态
   }
   ⭐ 用途:多步表单「中途离开再回来」、暂停/恢复

④ 【延迟转移(after)】
   on: { SUBMIT: 'loading' },
   states: {
     loading: {
       after: {
         5000: 'timeout',          // ⭐ 进入 loading 5 秒后自动转到 timeout
       },
       on: { DONE: 'success' },     // ⭐ 5 秒内完成 → 转到 success(并取消定时器)
     },
   }
   ⭐ XState 会自动「取消定时器」(离开状态时不需要手动 clearTimeout)
   — 这正是「手写 setTimeout + 组件卸载」容易漏的东西(见 5.13)

三、与「Reducer」的关系与差异

⭐⭐⭐ 「状态机」与「reducer」的对比(这是最常见的疑问):

【相同】
   · 都是「`(state, event) => nextState`」的纯函数(可测、可回放)
   · 都主张「状态不可变更新」
   · 都能配合 `useReducer` 使用

【关键差异】
   | 维度 | reducer | 状态机 |
   | --- | --- | --- |
   | 「当前状态」表达 | 你自定义(⚠️ 常见是「一堆旗标」) | ⭐ **显式的、穷举的状态名** |
   | 「非法组合」 | ⚠️ 可以表达出来(写代码时容易漏) | ⭐ **结构上不存在** |
   | 「事件的合法性」 | 你自己在 reducer 里判断 | ⭐ **由当前状态的 `on` 决定** |
   | 层级/并行 | ❌ 要自己组合 | ⭐ 内建 |
   | 延迟/异步 | ❌ 自己在 Effect 里做 | ⭐ `after` / `invoke` 内建 |
   | 可视化 | ❌ | ⭐ 能画状态图 |
   | 学习成本 | ⭐ 低 | ⚠️ 中高 |

   ⭐ 一句话:**「reducer 是『状态更新的函数』,状态机是『状态与转移的规格』」**——
     reducer 不阻止你写出「非法状态」,状态机**从结构上**阻止了。

【实践建议】
   · 「状态少、转移简单」(如计数器、开关)→ **`useReducer` 足够**
   · 「状态多、有非法组合风险、有异步与超时、需要和产品对齐流程」
     (支付、多步表单、媒体播放器、拖拽、向导、连接管理)→ ⭐ **状态机**

四、完整实现:一个 mini XState(270 行)

// mini-xstate.ts —— 复现 XState 的核心(层级状态 + 事件冒泡 + guard + assign + after)
// ⚠️ 教学实现:省略了「并行状态」「历史状态」「actor 模型」「延迟事件的精确调度」等

/** ============ 1. 配置类型 ============ */
type EventObject = { type: string; [key: string]: unknown };

interface ActionObject<Ctx> {
  type: 'assign';
  assignment: Partial<Ctx> | ((args: { context: Ctx; event: EventObject }) => Partial<Ctx>);
}
type Action<Ctx> = ActionObject<Ctx> | string | ((args: { context: Ctx; event: EventObject }) => void);

interface TransitionConfig<Ctx> {
  target?: string;
  guard?: string | ((args: { context: Ctx; event: EventObject }) => boolean);
  actions?: Action<Ctx> | Action<Ctx>[];
}

interface StateNodeConfig<Ctx> {
  initial?: string;
  on?: Record<string, string | TransitionConfig<Ctx>>;
  entry?: Action<Ctx> | Action<Ctx>[];
  exit?: Action<Ctx> | Action<Ctx>[];
  after?: Record<number, string | TransitionConfig<Ctx>>;
  states?: Record<string, StateNodeConfig<Ctx>>;
  /** 'final' 表示终态 */
  type?: 'final';
}

interface MachineConfig<Ctx> {
  id?: string;
  initial: string;
  context: Ctx;
  states: Record<string, StateNodeConfig<Ctx>>;
  guards?: Record<string, (args: { context: Ctx; event: EventObject }) => boolean>;
}

/** ============ 2. 运行时状态(快照) ============ */
export interface MachineSnapshot<Ctx> {
  /** ⭐ 当前状态「路径」(数组,因为可能有层级) */
  value: string[];
  context: Ctx;
  /** 是否处于终态 */
  done: boolean;
  /** 到当前状态的路径字符串(便于判断) */
  matches: (path: string) => boolean;
}

function createSnapshot<Ctx>(value: string[], context: Ctx, done: boolean): MachineSnapshot<Ctx> {
  return {
    value,
    context,
    done,
    /** ⭐ 支持 'loading'(前缀匹配)与 'loading.fetching'(精确匹配) */
    matches: (path: string) => {
      const parts = path.split('.');
      return parts.every((p, i) => value[i] === p);
    },
  };
}
/** ============ 3. 工具:解析「状态路径」并找到对应的配置 ============ */
function resolveNode<Ctx>(
  config: MachineConfig<Ctx>,
  path: string[]
): { node: StateNodeConfig<Ctx> | undefined; ancestors: StateNodeConfig<Ctx>[] } {
  const ancestors: StateNodeConfig<Ctx>[] = [];
  let node: StateNodeConfig<Ctx> | undefined = undefined;

  for (const [i, segment] of path.entries()) {
    const parentStates = i === 0 ? config.states : node?.states;
    if (!parentStates) break;
    if (node) ancestors.push(node);
    node = parentStates[segment];
    if (!node) break;
  }

  return { node, ancestors };
}

/** ⭐⭐ 「把所有子状态一路展开到叶子」——这样才能知道「当前到底在哪个具体状态」 */
function expandToLeaves<Ctx>(node: StateNodeConfig<Ctx>, prefix: string[]): string[][] {
  if (!node.states || !node.initial) return [prefix];

  const children = Object.keys(node.states);
  const initialChild = node.initial;
  if (!children.includes(initialChild)) return [prefix];

  return expandToLeaves(node.states[initialChild]!, [...prefix, initialChild]);
}

/** ⭐ 把「target 字符串」解析成「绝对路径」 */
function resolveTarget<Ctx>(
  config: MachineConfig<Ctx>,
  currentPath: string[],
  target: string
): string[] {
  // ⭐ 支持三种写法:
  //   ① '.child'      → 相对当前位置
  //   ② 'sibling'     → 相对「父级」
  //   ③ 'a.b.c'       → 绝对路径(从根开始)
  if (target.startsWith('.')) {
    return [...currentPath, target.slice(1)];
  }

  const firstSegmentIsRoot = target.split('.')[0]! in config.states;
  if (firstSegmentIsRoot) return target.split('.');

  // 相对父级
  const parentPath = currentPath.slice(0, -1);
  return [...parentPath, ...target.split('.')];
}
/** ============ 4. 转移:核心算法 ============ */
/** ⭐⭐ 返回值约定:undefined 表示「这个状态没处理这个事件」(继续向上冒泡) */
function findTransition<Ctx>(
  config: MachineConfig<Ctx>,
  path: string[],
  event: EventObject,
  context: Ctx
): { path: string[]; transition: TransitionConfig<Ctx>; handledAtDepth: number } | null {
  const { node, ancestors } = resolveNode(config, path);

  // ⭐⭐ 从「最深的状态」开始向上冒泡:
  //    这一步是「层级状态」的关键 —— 子状态没处理的事件会交给父状态
  const chain = [...ancestors, node].filter(Boolean).reverse() as StateNodeConfig<Ctx>[];

  for (let depth = 0; depth < chain.length; depth++) {
    const currentNode = chain[depth]!;
    const raw = currentNode.on?.[event.type];
    if (!raw) continue;

    const transition: TransitionConfig<Ctx> =
      typeof raw === 'string' ? { target: raw } : raw;

    // ⭐ guard:条件不满足 → 「这个状态不处理这个事件」→ 继续向上冒泡
    const guardFn =
      typeof transition.guard === 'function'
        ? transition.guard
        : transition.guard
          ? config.guards?.[transition.guard]
          : undefined;

    if (guardFn && !guardFn({ context, event })) continue;

    // ⭐ 计算目标路径
    const targetPath = transition.target
      ? resolveTarget(config, path.slice(0, chain.length - depth), transition.target)
      : path;                       // 没写 target 就是「内部转移」(只执行 actions)

    return { path: targetPath, transition, handledAtDepth: depth };
  }

  return null;
}
/** ============ 5. Actor:状态机的运行时 ============ */
export function createActor<Ctx extends object>(config: MachineConfig<Ctx>) {
  /** ⭐ 初始状态:从 root.initial 一路展开到叶子 */
  let currentPath: string[] = expandToLeaves(config.states[config.initial]!, [config.initial]);
  let context: Ctx = { ...config.context };
  let done = false;

  const listeners = new Set<(snap: MachineSnapshot<Ctx>) => void>();
  const afterTimers = new Map<string, ReturnType<typeof setTimeout>>();

  const getSnapshot = (): MachineSnapshot<Ctx> => createSnapshot(currentPath, context, done);

  const notify = () => {
    const snap = getSnapshot();
    listeners.forEach((fn) => fn(snap));
  };

  /** ⭐ 执行 actions(支持 assign / 函数 / 注册名) */
  function runActions<A extends Action<Ctx>>(actions: A | A[] | undefined, event: EventObject) {
    if (!actions) return;
    const list = Array.isArray(actions) ? actions : [actions];

    for (const action of list) {
      if (typeof action === 'function') {
        action({ context, event });
        continue;
      }
      if (typeof action === 'string') continue;          // 具名 action(本实现忽略)

      if (action.type === 'assign') {
        const patch =
          typeof action.assignment === 'function'
            ? action.assignment({ context, event })
            : action.assignment;
        // ⭐ assign 是「合并」(与 React setState 类似)
        context = { ...context, ...patch };
      }
    }
  }

  /** ⭐ 清掉「当前路径上所有状态」的 after 定时器 */
  function clearAfterTimers() {
    for (const [key, timer] of afterTimers) {
      clearTimeout(timer);
      afterTimers.delete(key);
    }
  }

  /** ⭐⭐ 为「当前路径上的每个状态」注册 after 定时器 */
  function setupAfterTimers() {
    const { node, ancestors } = resolveNode(config, currentPath);
    const chain = [...ancestors, node].filter(Boolean) as StateNodeConfig<Ctx>[];

    chain.forEach((stateNode, index) => {
      if (!stateNode.after) return;
      for (const [delayStr, raw] of Object.entries(stateNode.after)) {
        const delay = Number(delayStr);
        const transition: TransitionConfig<Ctx> = typeof raw === 'string' ? { target: raw } : raw;
        const key = `${currentPath.join('.')}::${delay}`;

        const timer = setTimeout(() => {
          afterTimers.delete(key);
          void send({ type: `__after.${delay}` }, transition, index);
        }, delay);

        afterTimers.set(key, timer);
      }
    });
  }

  /** ⭐⭐ 发送事件(核心入口) */
  async function send(
    event: EventObject,
    explicitTransition?: TransitionConfig<Ctx>,
    _depth?: number
  ): Promise<void> {
    if (done) {
      // ⭐ 终态不接受事件(这就是「状态机保证『成功不可再提交』」的地方)
      console.warn(`[xstate] 已处于终态,忽略事件 ${event.type}`);
      return;
    }

    const found = explicitTransition
      ? { path: explicitTransition.target
            ? resolveTarget(config, currentPath, explicitTransition.target)
            : currentPath,
          transition: explicitTransition,
          handledAtDepth: 0 }
      : findTransition(config, currentPath, event, context);

    if (!found) {
      // ⚠️「当前状态不处理这个事件」→ 静默忽略(⭐ 这是「状态机的好处」不是 bug)
      return;
    }

    // ⭐ ① 离开当前状态(执行 exit)
    clearAfterTimers();
    const { node: oldNode, ancestors: oldAncestors } = resolveNode(config, currentPath);
    [...oldAncestors, oldNode].forEach((n) => n && runActions(n.exit, event));

    // ⭐ ② 执行转移的 actions(⭐ 先改 context,再进新状态)
    runActions(found.transition.actions, event);

    // ⭐ ③ 进入新状态(展开到叶子 + 执行 entry)
    const newPath = found.transition.target
      ? expandToLeaves(
          (() => {
            const { node } = resolveNode(config, found.path);
            return node ?? config.states[found.path[0]!]!;
          })(),
          found.path
        )
      : currentPath;

    currentPath = newPath;

    const { node: newNode, ancestors: newAncestors } = resolveNode(config, currentPath);
    [...newAncestors, newNode].forEach((n) => n && runActions(n.entry, event));

    // ⭐ ④ 终态判断
    if (newNode?.type === 'final') done = true;

    // ⭐ ⑤ 为「新状态的 after」注册定时器
    setupAfterTimers();

    notify();
  }

  /** ⭐ 启动(触发初始状态的 entry + after) */
  function start() {
    const { node, ancestors } = resolveNode(config, currentPath);
    [...ancestors, node].forEach((n) => n && runActions(n.entry, { type: '__init' }));
    setupAfterTimers();
    notify();
  }

  return {
    start,
    send: (event: EventObject) => send(event),
    getSnapshot,
    subscribe: (fn: (snap: MachineSnapshot<Ctx>) => void) => {
      listeners.add(fn);
      return () => { listeners.delete(fn); };
    },
    /** ⭐ 停止:清理定时器(⭐ 这一步手写状态机时最容易漏,见 5.13) */
    stop: () => { clearAfterTimers(); listeners.clear(); },
  };
}
/** ============ 6. 辅助:assign 的语法糖 ============ */
export function assign<Ctx extends object>(
  assignment: Partial<Ctx> | ((args: { context: Ctx; event: EventObject }) => Partial<Ctx>)
): ActionObject<Ctx> {
  return { type: 'assign', assignment };
}
/** ============ 7. React 集成 ============ */
import { useEffect, useRef, useState, useSyncExternalStore, useCallback } from 'react';

export function useMachine<Ctx extends object>(config: MachineConfig<Ctx>) {
  const actorRef = useRef<ReturnType<typeof createActor<Ctx>> | null>(null);
  if (actorRef.current === null) actorRef.current = createActor(config);
  const actor = actorRef.current;

  // ⭐ 用 useSyncExternalStore 订阅 actor(与 11.13 的 store 是同一个模式)
  const snap = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot);

  // ⭐ 启动 + 卸载时停止(清理定时器)
  useEffect(() => {
    actor.start();
    return () => actor.stop();
  }, [actor]);

  const send = useCallback((event: EventObject) => actor.send(event), [actor]);

  // ⭐ 语法糖:state.matches('loading')
  return [snap, send, actor] as const;
}
/** ============ 8. 使用示例:一个完整的支付流程 ============ */
const paymentConfig: MachineConfig<{ retries: number; error: string | null }> = {
  id: 'payment',
  initial: 'idle',
  context: { retries: 0, error: null },
  guards: {
    canRetry: ({ context }) => context.retries < 3,
  },
  states: {
    idle: {
      on: {
        SUBMIT: { target: 'authorizing', actions: assign({ error: null }) },
      },
    },
    authorizing: {
      entry: [() => console.log('[machine] 开始授权')],
      // ⭐ after:进入后 8 秒没结果就超时
      after: { 8000: 'timeout' },
      on: {
        AUTHORIZED: {
          target: 'success',
          actions: assign({ retries: 0 }),
        },
        DECLINED: {
          target: 'failed',
          actions: assign({ error: '支付被拒绝' }),
        },
        CANCEL: 'cancelled',
      },
    },
    failed: {
      // ⭐ 只有「重试次数 < 3」才能重试(guard)
      on: {
        RETRY: {
          target: 'authorizing',
          guard: 'canRetry',
          actions: assign({ retries: ({ context }) => context.retries + 1 }),
        },
        CANCEL: 'cancelled',
      },
    },
    timeout: {
      on: {
        RETRY: {
          target: 'authorizing',
          guard: 'canRetry',
          actions: assign({ retries: ({ context }) => context.retries + 1 }),
        },
        CANCEL: 'cancelled',
      },
    },
    success: { type: 'final' },
    cancelled: { type: 'final' },
  },
};

export function PaymentFlow() {
  const [state, send] = useMachine(paymentConfig);

  // ⭐ 用「状态」推导 UI(而不是用旗标拼)
  const isBusy = state.matches('authorizing');
  const canSubmit = state.matches('idle');
  const canRetry = state.matches('failed') || state.matches('timeout');

  return (
    <div>

        当前状态:<b>{state.value.join('.')}</b>
        {state.context.error && <span style={{ color: 'red' }}> · {state.context.error}</span>}
        {state.context.retries > 0 && <span> · 已重试 {state.context.retries} 次</span>}

      {/* ⭐⭐ 关键:按钮的可用性「由状态推导」,不写 if 组合 */}
      {canSubmit && <button onClick={() => send({ type: 'SUBMIT' })}>提交支付</button>}
      {isBusy && (
        <>
          <button disabled>支付中…</button>
          <button onClick={() => send({ type: 'CANCEL' })}>取消</button>
        </>
      )}
      {canRetry && <button onClick={() => send({ type: 'RETRY' })}>重试</button>}
      {state.matches('success') && <p>✅ 支付成功</p>}
      {state.matches('cancelled') && <p>已取消</p>}

      {/* ⭐ 这些写法在状态机里「根本不需要」:
          disabled={isLoading || isSuccess || (isError && !canRetry)} */}
    </div>
  );
}
⭐⭐⭐ 这个实现复现了状态机的五个核心机制:

① 【状态路径 + 层级】
   用 `string[]` 表示「当前在哪个状态的哪一层」,
   ⭐ `matches('loading')` 用「前缀匹配」→ 所以「父状态」能被匹配到。

② 【事件「向上冒泡」】⭐ 层级状态的关键
   `findTransition` 从「最深的状态」开始,逐层向上找 `on[event.type]`:
   ⭐ 子状态没处理 → 交给父状态处理(这就是「共享转移」的实现)。

③ 【guard 不满足 = 「这个状态不处理」】
   ⭐ 注意实现细节:guard 失败时 `continue`(继续向上冒泡),
     而不是「报错」——这样「父级的兜底转移」才能生效。

④ 【`after` 的自动取消】⭐
   进入状态时注册定时器,**离开时统一清理**(`clearAfterTimers`)。
   ⭐ 这是「手写 `setTimeout` + 组件卸载」最容易漏的东西(见 5.13)——
     状态机把它变成了「结构保证」。

⑤ 【终态不接受事件】
   `done` 为 true 时 `send` 直接返回 →
   ⭐ 这就是「状态机从结构上保证『成功之后不可再提交』」的实现方式。

五、本篇特有的坑

// ① 把「数据」当成「状态」放进 context(旗标地狱换个地方继续)
context: { isLoading: false, isError: false }                 // ❌
// ✅ 状态的「阶段」用 state 表达:states: { loading: {}, error: {} }
//    context 只放「数据」(如表单内容、重试次数)

// ② 「一个巨大的状态机」管所有事(应该拆成多个)
// ⭐ 症状:状态图有 40 个状态、转移线像蜘蛛网
// ✅ 拆成「多个小状态机」(父机器 `invoke` 子机器,或用多个独立机器)

// ③ guard 写「副作用」(应该在 action 里)
guard: ({ context }) => { api.log(); return true; }            // ⚠️ guard 应纯净
// ✅ guard 只做判断;副作用放 action

// ④ 忘了「终态」→ 成功之后还能再提交
// ✅ `type: 'final'`(⭐ 或者「根本没有出去的转移」)

// ⑤ 用 `after` 做「超时」但在组件里「又写了一套 setTimeout」
// ⚠️ 两套定时器 → 状态不一致
// ✅ 统一用 `after`(它会自动取消)

// ⑥ 「事件命名」混乱(SUBMIT / submit / doSubmit / SUBMIT_FORM)
// ⭐ 事件名是「协议」,混乱会让状态图无法沟通
// ✅ 统一风格(推荐「大写蛇形」:`SUBMIT`、`PAYMENT_DECLINED`)

// ⑦ 把「UI 状态」和「业务状态」混在一个机器里
// ⚠️ 如「弹窗是否打开」与「支付是否成功」放一起
// ✅ 分开(UI 状态用 useState;业务流程用状态机)

// ⑧ 忘记「状态机的『当前状态』要能被观察」(只 send 不订阅)
// ✅ 用 `useMachine` 返回的 `state`(或 `useSelector` 精确订阅)

// ⑨ 「并行状态」用来表达「其实有依赖」的维度
// ⚠️ 如「登录状态」与「权限」不是独立的 → 用并行会让「非法组合」重新出现
// ✅ 只有「真的正交」才用 parallel

// ⑩ 事件里塞了「太多 payload」(状态机变成「接口层」)
send({ type: 'LOAD', url, method, headers, body, retries })    // ⚠️
// ✅ 事件「只描述发生了什么」(`{ type: 'RELOAD' }`),细节放 context 或 actor

// ⑪ 「invoke 的异步结果」用 state 判断但忘了「取消」
// ⚠️ 离开状态后「旧请求仍在跑」(可能把结果写进已改变的状态)
// ✅ XState 的 invoke 会「自动取消」(⭐ 手写时要自己 AbortController)
//    ⭐ 这也是「用状态机做异步」比「手写 Effect」安全的地方

// ⑫ 状态机里「读 window/localStorage」(不可测)
// ✅ 副作用通过「actor / action 注入」(便于测试时替换)

// ⑬ 用「状态机」表达「本来就简单的东西」(过度设计)
// ⚠️ 如「一个开关的 on/off」
// ✅ 判据:「**是否存在『非法状态组合』的风险**」+ 「**是否存在异步/超时/重试**」

// ⑭ 忘记「状态机需要和『产品/设计』对齐」
// ⭐ 状态图的最大价值之一就是「可沟通」
// ✅ 把状态图(XState Visualizer 的 Mermaid/SVG)放进文档/需求评审

// ⑮ 测试时「只测最终状态」(不测中间转移)
// ✅ ⭐ 状态机的测试优势正是「每个转移都能单独测」:
//    给定状态 + 事件 → 断言「新状态 + context 变化」
// ① 的完整处理(数据 vs 状态)
// ❌ 反例
const bad = createMachine({
  initial: 'active',
  context: { isLoading: false, items: [] },
  states: {
    active: {
      on: {
        LOAD: { actions: assign({ isLoading: true }) },      // ⚠️ 用 flag 表达阶段
      },
    },
  },
});

// ✅ 正例:阶段用 state,数据用 context
const good = createMachine({
  initial: 'loading',
  context: { items: [] as Item[], error: null as string | null },   // ⭐ 只有数据
  states: {
    loading: {
      invoke: {
        src: 'fetchItems',
        onDone: { target: 'ready', actions: assign({ items: ({ event }) => event.output }) },
        onError: { target: 'error', actions: assign({ error: ({ event }) => String(event.error) }) },
      },
    },
    ready: { on: { RELOAD: 'loading' } },
    error: { on: { RETRY: 'loading' } },
  },
});
// ⭐ 收益:「加载中且出错」这种非法组合「不存在」;
//    UI 直接用 `state.matches('loading')` 判断
// ⑮ 的完整处理(测试「转移」而不是「结果」)
import { describe, it, expect } from 'vitest';
import { createActor } from './mini-xstate';

describe('支付状态机', () => {
  it('SUBMIT 后进入 authorizing', () => {
    const actor = createActor(paymentConfig);
    actor.start();
    actor.send({ type: 'SUBMIT' });
    expect(actor.getSnapshot().matches('authorizing')).toBe(true);
    actor.stop();
  });

  it('重试 3 次后不再允许重试(guard 生效)', () => {
    const actor = createActor(paymentConfig);
    actor.start();
    actor.send({ type: 'SUBMIT' });
    actor.send({ type: 'DECLINED' });

    for (let i = 0; i < 3; i++) {
      actor.send({ type: 'RETRY' });        // 重试
      actor.send({ type: 'DECLINED' });      // 又失败
    }

    expect(actor.getSnapshot().context.retries).toBe(3);
    actor.send({ type: 'RETRY' });           // ⭐ 第 4 次重试 → guard 拒绝
    expect(actor.getSnapshot().matches('failed')).toBe(true);   // ⭐ 仍在 failed
    actor.stop();
  });

  it('终态不接受事件', () => {
    const actor = createActor(paymentConfig);
    actor.start();
    actor.send({ type: 'SUBMIT' });
    actor.send({ type: 'AUTHORIZED' });
    expect(actor.getSnapshot().done).toBe(true);

    actor.send({ type: 'SUBMIT' });          // ⭐ 应被忽略
    expect(actor.getSnapshot().matches('success')).toBe(true);
    actor.stop();
  });
});

六、面试延伸

  1. 「为什么要用状态机?它解决了什么问题?」
    它解决「布尔旗标导致的『非法状态组合』」:用 3 个旗标(isLoading/isError/isSuccess)表达一个流程,会「可表达出 8 种组合,而合法只有 4 种」——另外 4 种是「代码没处理、但运行时可能进入」的;而且「每加一个状态,所有 disabled/条件判断都要改一遍」。状态机的做法是「把『阶段』变成互斥的、穷举的状态名」,于是:① ⭐ 非法组合在结构上不存在;② ⭐ 转移是显式的(「哪个状态接受哪个事件、去哪里」写在结构里,不被遗漏,发一个不接受的事件会被自动忽略);③ ⭐ 不可能的状态在写代码时就不可能存在(success 是 final → 不用写 if (isSuccess) return;idle 不接受 CANCEL → 不用写「未提交时取消失败」);④ 可以画出来(与产品/设计对齐)+ 每个转移都能单独测。

  2. 「状态机(XState)和 reducer 有什么区别?」
    相同点是「都是 (state, event) => nextState 的纯函数」。关键差异:① ⭐ 「当前状态」的表达——reducer 里你自己定(⚠️ 常见就是「一堆旗标」,允许非法组合),状态机是「显式的、穷举的状态名」(从结构上阻止非法组合);② ⭐ 事件的合法性——reducer 里你自己在函数体里判断,状态机里由当前状态的 on 决定;③ 层级/并行——reducer 要自己组合,状态机内建;④ 延迟/异步——reducer 要在 Effect 里做(容易漏清理),状态机有 after/invoke(⭐ 自动取消);⑤ 可视化——状态机能画图。实践建议:「状态少、转移简单」(计数器、开关)用 useReducer 就够;「状态多、有非法组合风险、有异步与超时、需要和产品对齐流程」(支付、多步表单、媒体播放器、拖拽、向导、连接管理)→ 状态机。

  3. 「状态图(Statechart)比 FSM 多了什么?」
    四个扩展:① ⭐ 层级状态——子状态可以继承父状态的转移(如「所有子状态下按 CANCEL 都回到 idle」),⭐ 靠「事件从最深状态向上冒泡」实现(子状态不处理则交给父状态),这是避免重复的主要手段;② ⭐ 并行状态(type: 'parallel')——表达「同时进行的多个正交维度」(如「音频开关」与「视频开关」独立变化),避免「用旗标表达两个维度」;③ 历史状态(hist)——「回到上次离开的那个子状态」,⭐ 用于「多步表单中途离开再回来」「暂停/恢复」;④ 延迟转移(after)——「进入某状态 N 毫秒后自动转移」,⭐ 且 XState 会在离开状态时自动取消定时器(这正是「手写 setTimeout + 组件卸载」最容易漏的清理,见 5.13)。

  4. 「context 和 state 该怎么分工?」
    ⭐ 纪律是:「『阶段』用 state 表达,『数据』用 context 表达」:① state——loading / ready / error / success 这类「互斥的阶段」(用 state.matches('loading') 判断 UI);② context——「跨阶段需要保留的数据」(表单内容、重试次数、错误信息、已加载的列表)。❌ 最常见的反模式是「把阶段放进 context 当旗标」:context: { isLoading: false, isError: false }——⭐ 这等于「把旗标地狱搬进状态机」,非法组合又回来了。✅ 判据:「这个字段的所有取值是不是『同时只能有一个为真』」——是 → 它应该是 state;否 → 放 context。

  5. 「invoke / after 这些内建能力解决了什么手写难题?」
    解决「异步与定时的『清理』问题」——这正是「手写 useEffect + setTimeout + fetch」最容易出错的地方(见 5.13):① ⭐ invoke(异步 actor)——进入状态时启动,完成/失败自动转移,⭐ 离开状态时自动取消(不用手动 AbortController,也不会出现「旧请求的结果写进已经变化的 UI」);② ⭐ after(延迟转移)——进入状态时注册定时器,离开状态时自动清理(手写时极容易漏 clearTimeout,导致「离开页面后仍触发状态更新」);③ guard——把「条件转移」变成声明式(不用在 action 里写 if);④ assign——声明式地更新 context(且 XState 保证「action 的顺序与语义」)。⭐ 一句话:状态机把「时间维度的正确性」(超时、取消、竞态)变成了「结构保证」。

  6. 「在 React 里怎么用状态机?」
    ① useMachine(machine)(@xstate/react)——返回 [state, send, actor],state.matches('loading') 用于「推导 UI」,state.context 读数据,state.can({ type: 'SUBMIT' }) 判断「当前能否处理某事件」(⭐ 比手写 disabled 表达式可靠);② ⭐ 用状态推导 UI,而不是用旗标拼:disabled = state.matches('authorizing')(而不是 isLoading || isSuccess || ...);③ ⭐ useSelector 精确订阅(useSelector(actor, (s) => s.context.items))——避免「任何转移都重渲染整个组件」;④ ⚠️ 不要「state 与 useState 双写」(如「组件自己维护一个 isLoading」)——那会「两个真相源」。另一个常见用法:把「状态机」放在 React 之外(用 actor),只在需要的地方订阅(⭐ 这样「跨组件的流程状态」不需要 Context 层层传递)。

  7. 「什么时候『不该』用状态机?」
    四个判据:① ⭐ 「状态少、转移简单」——计数器、开关、一个 Modal 的开关(useState 足够,状态机是「杀鸡用牛刀」);② ⭐ 「不存在非法状态组合的风险」——只有一个布尔值,谈不上组合;③ 「UI 状态而非流程状态」——「侧边栏折叠」「Tab 选中项」这类(⭐ 用 useState + 必要时提升为 store);④ 「团队不熟悉状态机」——⚠️ 状态机的学习成本真实存在(observable/assign/invoke/guard 的语义、事件冒泡、actor 模型),引入前要评估团队能否维护。判据总结:「是否存在『非法状态组合』风险 + 是否有异步/超时/重试 + 流程是否需要与产品/设计对齐」——三条都有 → 状态机收益明显。

  8. 「状态机怎么做测试?」
    ⭐ 它比「旗标实现」更好测,因为「每个转移都是独立的用例」:① 单元测试「转移」——「给定初始状态 + 事件 → 断言新状态与 context 变化」(不涉及 UI);② ⭐ 测试 guard——「不满足条件时『不转移』」(这是旗标实现最容易漏的边界);③ 测试终态——「进入终态后再发事件应被忽略」;④ 测试 after——用假定时器(vi.useFakeTimers())推进时间,断言「超时转移发生」;⑤ ⭐ 测试 invoke——把异步 actor「注入成假的」(XState 的 provide/actors 支持替换,本实现里则把副作用函数化便于替换);⑥ 「模型测试(model-based testing)」——用状态机的结构自动生成测试路径(XState 有 @xstate/test),⭐ 这是状态机的独特优势。注意:测试完记得 actor.stop()(清理定时器与监听器)。

一句话速记

状态机的价值是「用显式的、穷举的状态替代布尔旗标」——因为旗标能表达非法组合(isLoading && isError && isSuccess)且「每加一个状态所有判断都要改」,而状态机「非法组合在结构上不存在」「转移显式不被遗漏」「终态不接受事件」「能画图、每个转移可单独测」;七个概念:State(阶段,互斥)/ Event(发生了什么)/ Transition(去哪)/ Guard(条件,⭐ 不满足时「继续向上冒泡」而不是报错)/ Action(assign 改 context、entry/exit)/ ⭐ Context(只放「数据」,纪律是「阶段用 state、数据用 context」——把旗标塞进 context 等于把旗标地狱搬进状态机) / Actor(统一模型);状态图四个扩展:层级(子状态继承父状态转移,靠「事件从最深状态向上冒泡」)、并行(正交维度)、历史(回到上次的子状态)、⭐ 延迟(after,且「离开状态时自动取消定时器」);与 reducer 的差异:reducer 是「状态更新的函数」(不阻止非法状态),状态机是「状态与转移的规格」(从结构上阻止)——「状态少转移简单」用 useReducer,「有非法组合风险 + 异步/超时/重试 + 需与产品对齐」才上状态机;⭐ invoke/after 的真正价值是把「时间维度的正确性(超时、取消、竞态)」变成「结构保证」;React 里「用 state.matches() 推导 UI(不拼 disabled 表达式)+ useSelector 精确订阅 + 别与 useState 双写真相源」;测试的独特优势是「每个转移都是独立用例」+ 结构驱动的「模型测试」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.13 XState

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫