Reactor 是 Actor 的镜像兄弟:由信号驱动而非消息驱动 。它的核心是三个概念------
monitor(派生状态自动转换)、entry(进状态跑一次)、effects(依赖变化重跑)。实现只有 192 行,但把 28 篇的信号机制用到了极致:所有生命周期都搭载在 effect 的 cleanup 契约上。读完这篇你会理解「薄翻译器」到底薄在哪。
什么时候用 Reactor 而不是 Actor
回忆 27 篇的决策规则:由消息驱动 → Actor;由信号驱动 → Reactor。
具体判断(来自 conventions 文档):一个单元需要 Actor,当它拥有资源 + 串行工作 + 离散事件输入 (SourceBuffer:MSE 资源,append/remove 必须串行,输入是离散命令)。一个单元适合 Reactor,当它观察状态变化并做出反应------「源变成了 X,我该发消息告诉 Y」。
设计文档对 Reactor 的定位是薄翻译器:只回答「要不要发消息、发什么消息」,不含业务逻辑。业务逻辑在 behavior 的其他部分或纯函数里。
ReactorDefinition:声明一台反应器
create-machine-reactor.ts 的定义类型:
ts
const def = {
initial: 'idle', // L54
monitor: [ // L60 --- 单函数或数组
() => selectStateFromSignals(), // deriveFn:体内读信号自动成为依赖
],
states: { // L66 --- Record(不是 Partial!每个状态必须声明)
idle: { entry: [...], effects: [...] },
loading: { effects: [...] }, // 空效果传 {}
},
};
三个部件的语义(L33-35 文档):
monitor------deriveFn 在 tracked effect 中求值;依赖变化重新求值,返回值 ≠ 当前状态时自动 transition。entry------进状态时跑一次,函数体自动 untrack(适合一次性 setup)。effects------进状态时跑,体内 tracked 信号变化时重跑 (要排除追踪需手写untrack())。
注意 L66 的 states: Record<State, ...> 不是 Partial ------每个合法状态都必须声明(空效果传 {})。这是刻意为之:漏声明一个状态就漏掉它的行为,类型系统强制完整性。(对比 Actor 的 Partial<Record<...>>------Actor 允许「这个状态不处理任何消息」,Reactor 不允许「这个状态什么效果都没有」不声明。其实语义都允许空,但 Reactor 要求显式写出来。)
实现骨架:descriptors + toEffect(L133-173)
Reactor 的实现思路很统一:把 monitor/entry/effects 全部编译成「descriptor 数组」,再用 28 篇的 effect() 统一执行。
descriptor 结构(L133-137)
ts
type EffectDescriptor = {
fn: () => void;
shouldSkip: (snapshot: { value: string }) => boolean; // 门控:这个状态下要不要跑
toFnCall?: (baseCall) => EffectCall; // 包装(entry 用它加 untrack)
};
descriptors 数组的构建(L148-163)------顺序是保证
ts
const descriptors = [
// 1. monitor descriptors 排最前(L149-155)
...toArray(def.monitor).map((fn) => ({
fn: () => {
const target = fn(); // tracked 求值 deriveFn
if (target !== (getState() as State)) transition(target); // 不等才转
},
shouldSkip: isTerminal, // 终态下 monitor 停转
})),
// 2. per-state descriptors(L156-162)
...Object.entries(def.states).flatMap(([state, stateDef]) => {
const isNotState = (snapshot) => snapshot.value !== state; // L157
return [
{ fn: entry, shouldSkip: isNotState, toFnCall: untracked }, // L159 --- entry 加 untrack
{ fn: effect, shouldSkip: isNotState }, // L160 --- effects 不加
];
}),
];
L144-147 的注释明确了这个顺序保证:
monitor descriptors are built first --- the ordering guarantee ensures transitions they trigger take effect before per-state effects re-evaluate in the same flush
monitor 在前,per-state effects 在后------同一个 flush 里,monitor 触发的 transition 先生效,per-state effects 再按新状态重求值。如果顺序反了,effects 会用旧状态跑一轮再被新状态重跑,浪费且可能出 bug。
toEffect:统一的执行包装(L165-171)
ts
const toEffect = ({ fn, shouldSkip, toFnCall = (baseCall) => baseCall }) =>
effect(() => {
const snapshot = snapshotSignal.get(); // L167 --- tracked 读!
if (shouldSkip(snapshot)) return; // 门控不通过,跳过
const baseCall = () => fn();
return wrapResult(toFnCall(baseCall)()); // cleanup 归一化
});
L167 是整个 Reactor 的枢纽 :snapshotSignal.get() 是 tracked 读 (在 effect 的 computed 内),因此每次 transition 都重触发该 effect ;shouldSkip 门控决定这个 descriptor 归不归当前状态管。
「进入状态 X 时跑 entry」的机制 = transition 改变 snapshot signal → 所有 descriptor 的 effect 失效重跑 → shouldSkip(isNotState)对 X 的 descriptor 放行、其他跳过 → X 的 entry/effects 执行。「退出状态时清理」= 同一机制:effect 重跑前先执行上次的 cleanup(28 篇 effect 的 L34)。
状态生命周期完全搭载在 effect 的 cleanup 契约上------Reactor 自己没有写一行「退出状态时怎么清理」的代码。这是「薄」的极致。
wrapResult:cleanup 归一化(L125-129)
ts
const wrapResult = (result) => {
// 函数 → 原样;{abort()} 对象 → 包装成 () => result.abort();falsy → undefined
};
effect 返回 fn | {abort()} | void 三种形态,归一化成 () => void 交给 effect 机制。{abort()} 形态很实用------直接把 Task 的 controller 或 DOM 句柄交出去。
两步销毁(L180-189)
ts
destroy() {
if (isTerminal()) return; // 幂等
transition('destroying'); // L186 --- 第一步
transition('destroyed'); // L187 --- 第二步(同步紧跟)
for (const dispose of effectDisposals) dispose(); // L188 --- dispose 所有 effect
}
L183-185 注释解释了为什么两步:先 'destroying' 为将来异步 teardown 预留 ,随后立即 'destroyed' 覆盖当前的同步场景。目前两步是同步连转------但类型系统里 'destroying' 的存在让未来加异步清理不用改 API。
对比 Actor 的单步 destroy(31 篇)------Reactor 有 effect 需要逐个 dispose(L188),dispose 内部是 watcher.unwatch(c) + 执行最后一次 cleanup(28 篇 effect.ts L39-42)。
一个完整例子:加载分段反应器
用 41 篇会遇到的场景写个概念示例:
ts
createMachineReactor({
initial: 'idle',
monitor: [
() => {
const mediaSource = state.mediaSource.get(); // tracked 读
const source = state.source.get(); // tracked 读
if (!mediaSource || !source) return 'idle';
return 'active';
},
],
states: {
idle: {},
active: {
effects: [
() => {
// 订问当前时间,决定加载窗口
const media = context.media.get();
const unlisten = media.addEventListener('timeupdate', () => {
loader.send({ type: 'checkBuffer' });
});
return unlisten; // cleanup:退出 active 或依赖变化时自动执行
},
],
},
},
});
mediaSource 或 source 变 undefined → monitor 返回 'idle' ≠ 'active' → 自动 transition → active 的 effect cleanup 自动跑(解绑 timeupdate)→ idle。整个退出逻辑零手写。
Actor 与 Reactor 对照表
| Actor | Reactor | |
|---|---|---|
| 驱动 | 消息(send) | 信号(依赖变化) |
| 状态表 | Partial<Record> |
Record(必须全声明) |
| per-state 钩子 | on(消息→handler)、onSettled |
entry(一次)、effects(重跑) |
| context | 有(双读语义) | 无(纯 value) |
| runner | 可选集成 | 无 |
| destroy | 单步 'destroyed' |
两步 'destroying'→'destroyed' |
| 典型用途 | SourceBufferActor、SegmentLoaderActor | loadSegments 的翻译层、track 同步 |
Reactor 无 context 这个差异值得注意------它真的只是「状态翻译器」,数据都在外部的 composition 信号里(33 篇)。
小结
- monitor 自动转换------deriveFn tracked 求值,返回值 ≠ 当前状态即 transition。
- entry untracked / effects tracked------一次性 setup vs 依赖驱动重跑。
- descriptors 顺序是保证------monitor 先于 per-state effects,同 flush 里 transition 先生效。
- L167 的 tracked snapshot 读是枢纽------transition 重触发 effect,shouldSkip 门控。
- cleanup 全搭载 effect 契约------退出状态的清理零手写,「薄」的极致。
- 两步销毁------为异步 teardown 预留。
下一篇是组合层------Behavior,把信号、Actor、Reactor 全部统一成一种可组合单元。