来源：https://ui.docs.xihanfun.com/guide/machine

# 状态机运行时

`@xihan-ui/core` 中的状态机是一台轻量的有限状态机运行时：零第三方运行时依赖。组件的全部行为写在这里，因此它必须与框架无关：响应式由宿主框架经 `ReactiveRuntime` 接口注入。

## 状态机的形态

```ts
import type { AccordionSchema } from "./accordion.types";
import { setup } from "@xihan-ui/core";

const { createMachine } = setup<AccordionSchema>();

export const accordionMachine = createMachine({
  name: "accordion",
  context: ({ prop, cell }) => ({
    value: cell<string[]>(() => ({
      value: prop("value"),
      defaultValue: prop("defaultValue") ?? [],
      onChange: value => prop("onValueChange")?.({ value }),
    })),
  }),
  initialState: () => "idle",
  states: {
    idle: {
      on: {
        "ITEM.TOGGLE": { actions: ["toggleItem"] },
        "VALUE.SET": { actions: ["setValue"] },
      },
    },
  },
  implementations: {
    actions: {
      toggleItem: ({ context, prop, event }) => { /* … */ },
      setValue: ({ context, prop, event }) => { /* … */ },
    },
  },
});
```

`setup<Schema>()` 是类型锚：一次绑定 schema，之后 `prop()` / `context.get()` / 事件类型全部自动推断，不需要在每处重复写泛型。它同时给出 `guards`，即 `and` / `or` / `not` 三个组合子。

## 配置的各个面

| 字段 | 作用 |
| --- | --- |
| `context` | 状态机自身的状态。用 `cell()` 声明的格子自带受控语义 |
| `refs` | 不参与响应式的引用（DOM 取值器、宿主注入的配置、层注册器等） |
| `computed` | 由 context / props 派生的只读值 |
| `props` | 宿主传入的属性，经 `prop('x')` 读取 |
| `initialState` | 初始状态，可读 props 决定 |
| `states` | 状态节点：`on` 转移表、`entry` / `exit` 动作、`effects` 副作用、`tags` |
| `watch` | 追踪外部值变化并触发动作，受控回写走这里 |
| `implementations` | `actions` / `guards` / `effects` 的具名实现 |

具名实现是硬约束。`states` 中只能出现动作名、守卫名、副作用名的字符串；裸内联函数会在 `createMachine` 时直接抛出 `MachineError`（守卫只放行 `and` / `or` / `not` 组合子的产物）。`createMachine` 在开发与生产模式都会检查静态列表并递归审计组合 guard：转移表中引用了 `implementations` 中不存在的名字同样报错。实现必须是对应分组自身的数据属性函数；原型链成员、访问器和非函数值都不构成实现，审计也不会为了检查而调用 getter。这条约束的收益是状态图成为可静态分析的数据，测试可以计算转移覆盖率，而不是一组闭包。

函数形态的 `entry`、`exit`、`effects` 以及实现内部调用的 `action()`、`guard()` 只能在运行时得知名字。运行时会先解析整份 action 或 effect 列表，再通过自有数据属性一次取得全部函数快照，之后才执行 action 或初始化 effect；任一名字缺失都会用 `MISSING_ACTION`、`MISSING_GUARD` 或 `MISSING_EFFECT` 上报、停止服务并抛出原错误，开发与生产行为一致。缺失 guard 不会按 `false` 继续选择分支，缺失 action 不会被跳过，列表后部缺失 effect 也不会让前部 effect 先取得资源。缺项发生在 effect 初始化中且 cleanup 也失败时，抛出的 `AggregateError` 同时保留缺项错误与全部回滚异常。

`inspect` 只观察这些运行事件，不参与错误决策；观察器自身抛错会被隔离，不能遮蔽 `MISSING_*`、阻止停机或替换诊断中的原错误对象。

## 受控与非受控：`cell`

值类组件的取值语义统一在 `cell` 一处，不由各组件自行判断：

```ts
value: cell<string[]>(() => ({
  value: prop("value"), // 传了就是受控
  defaultValue: prop("defaultValue") ?? [], // 只传它就是非受控
  onChange: value => prop("onValueChange")?.({ value }),
}));
```

规则：

- `value !== undefined` → 受控。`context.set()` 不修改内部值，只调用 `onChange` 通知宿主；实际值始终从 `prop('value')` 读取。
- `value === undefined` → 非受控。内部持有值，变更时同样调用 `onChange`。
- 从受控变回 `undefined` 表示转为非受控，不会强制复位。

浮层类组件的开关不经过 `cell`，而是采用意图与回写两段式，因为状态本身就是状态机的状态节点：

<!-- eslint-skip -->

```ts
'OPEN': [
  { guard: 'isOpenControlled', actions: ['invokeOnOpen'] }, // 受控：只发意图
  { target: 'open', actions: ['invokeOnOpen'] }, // 非受控：直接转移
],
'CONTROLLED.OPEN': { target: 'open' }, // 宿主写回 open 后由 watch 派发
```

`watch` 追踪 `prop('open')`，宿主写回新值时才派发 `CONTROLLED.OPEN` / `CONTROLLED.CLOSE`。受控组件因此不会自行变更状态。

## 转移表的匹配顺序

一个事件可以配置一组转移，按书写顺序取第一条守卫通过的：

<!-- eslint-skip -->

```ts
'OPEN': [
  { guard: 'isOpenControlled', actions: ['invokeOnOpen'] },
  { target: 'open', actions: ['invokeOnOpen'] }, // 无 guard = 兜底
]
```

没有任何一条匹配时事件被丢弃，不报错。

## 副作用

`effects` 声明在状态节点上，进入该状态时启动、离开时清理。对话框的遮罩装配就是一个 effect：

<!-- eslint-skip -->

```ts
states: {
  open: {
    effects: ['trackOverlay'], // 装配消隐层 → 焦点域 → 滚动锁 → 背景失活
  },
}
```

副作用可以获取 `refs`（宿主注入的 DOM 取值器与运行时配置）、`send`、`flush`，返回一个清理函数。`setTimeoutEffect` / `setIntervalEffect` 是两个现成的定时器 effect 工厂。

同一状态节点的一批 effect 在执行前会先建立在途路径登记，整批成功后才保留为活动批次。如果后一项初始化抛错或挂载期间丢失路径所有权，在途登记会被摘除，本批已取得的 cleanup 会立即按资源取得逆序全部回滚；某项 cleanup 抛错不会阻断其余回滚。无回滚异常时保留原初初始化异常；回滚也失败时通过 `AggregateError` 同时携带初始化异常和全部回滚异常。最外层的 `MachineError` 会把这份原始异常链保留在 `cause` 中。

事务只能回滚 effect 已经返回给服务的 cleanup。单个 effect 如果在返回前已取得多项资源，它自身必须在内部初始化抛错时逆序回滚；服务无法释放从未交出的句柄。

成功挂载的 cleanup 同样按资源取得逆序执行。每条状态路径只能存在一份活动 effect 登记，返回 `void` 的 effect 也会占用该路径；重复挂载会抛出 `DUPLICATE_EFFECT_PATH` 不变式错误，而不是把两批 cleanup 隐式合并。退出状态时，服务会先从清理表摘除该路径，再调用 cleanup，因此 cleanup 抛错也不会在崩溃停机时被重复执行。

停机一开始就进入 `Stopped`、关闭事件入口并清空待处理事件与 tracker，然后继续清完所有 effect 并运行 machine exit。清理、exit 与原始崩溃同时抛错时，抛出和诊断通道都收到同一份完整聚合异常；正常停机只有 cleanup 或 exit 失败时也遵守同一规则。cleanup 或 exit 里同步调用 `send` 不会再推进机器；服务停止后宿主重复触发 mount 也不会把它复活。

effect 执行前路径就已处于“挂载中”。setup 里同步重入宿主 mount 会在服务边界命中 `DUPLICATE_SERVICE_MOUNT`，并按崩溃停机释放当前全部资源；同一状态路径在机器内部非法重复挂载时才报告 `DUPLICATE_EFFECT_PATH`。setup 里同步停机后，该 effect 迟到返回的 cleanup 会当场按逆序释放，当次 choreography 立即终止，不会再运行后续 entry 或根 effect；这次紧急清理的异常仍会暴露。

首次 mount 的完整 choreography 是一个不可重入的初始化边界，顺序固定为 state effect → machine entry → state entry → 根 effect：根 effect 是整个生命周期的资源（层、焦点域、观察器），建起那一刻读到的是进入完毕的初态，初态 entry 挑好的锚点（高亮项、焦点格）对它可见。这四步全部提交前，其中的 `send` 只会按调用顺序进入 FIFO；提交后先处理挂载前累积的 tracker，再统一消费事件队列。初始化失败或停机会清空该队列，不会用半提交的状态继续转移。初始化来源和根 effect 使用与用户状态路径不相交的内部标识，因此 `__init__` 是合法的用户状态名，也可以与根 effect 同时使用。

状态机允许 JavaScript 抛出任意值。`null`、`undefined` 和其他非 `Error` 值会使用安全格式化的崩溃信息，并在 `MachineError.cause` 中保留原值；即使单个 cleanup 或 exit 抛出 `undefined`，也会被记录为真实异常并向外抛出。抛出值自身无法转为文本时，诊断使用稳定的“无法格式化的异常”文案。

## 服务与响应式运行时

状态机配置是纯数据，运行需要一个服务：

```ts
import { createService } from "@xihan-ui/core";

const service = createService(accordionMachine, {
  props: () => ({ multiple: true, defaultValue: ["a"] }),
  runtime, // ReactiveRuntime，由宿主提供
  scope, // DOM 环境抽象
});
```

`ReactiveRuntime` 是状态机与框架之间唯一的接口，需要实现的成员不多：`cell`（受控格子）、`track`（依赖追踪）、`flush`（微任务冲刷）、`onMount` / `onCleanup`。

| 运行时 | 来源 | 用途 |
| --- | --- | --- |
| Vue | `@xihan-ui/vue` 内部的 `createVueRuntime()` | Vue 适配器 |
| vanilla | `@xihan-ui/core/vanilla` 的 `createVanillaRuntime()` | Web Components 适配器、测试、benchmark |

vanilla 运行时是一个同步 dirty 循环的微 signal 实现，也是这套契约的参考实现：接入新框架时按它实现一份即可。

## 服务的对外接口

`connect` 获得的 `service` 包含以下成员：

| 成员 | 用途 |
| --- | --- |
| `state` | `get()` / `previous()` / `matches(...)` / `hasTag()` |
| `context` | 读写 context 格子 |
| `computed` | 读派生值 |
| `prop` | 读 props |
| `refs` | 读写非响应式引用 |
| `event` | 读当前 / 上一个事件 |
| `send` | 派发事件 |
| `scope` | DOM 环境：`getDoc()` / `getWin()` / `partId()` / `getActiveElement()` 等 |
| `machine` / `getStatus()` | 状态机配置本身与生命周期状态 |

没有直接修改状态的入口。状态只能由事件驱动转移，这条限制使当前状态始终可以由事件序列复现：跨适配器一致性测试正是依靠这一点比对各适配器。

调试时可以给 `createService` 传入 `inspect`，逐条获取转移、动作、副作用与事件：

```ts
createService(dialogMachine, {
  props: () => ({}),
  runtime,
  inspect: e => console.log(e.type, e.state, e.detail),
});
```

## 相关

- [connect 与属性产出](./connect)：从服务到 DOM 属性
- [行为原语](./behavior)：effects 中调用的原语

- [测试与质量门禁](./testing)：转移覆盖率与一致性判据
