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的定义类型:
constdef={initial:'idle',// L54monitor:[// 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)
typeEffectDescriptor={fn:()=>void;shouldSkip:(snapshot:{value:string})=>boolean;// 门控:这个状态下要不要跑toFnCall?:(baseCall)=>EffectCall;// 包装(entry 用它加 untrack)};descriptors 数组的构建(L148-163)——顺序是保证
constdescriptors=[// 1. monitor descriptors 排最前(L149-155)...toArray(def.monitor).map((fn)=>({fn:()=>{consttarget=fn();// tracked 求值 deriveFnif(target!==(getState()asState))transition(target);// 不等才转},shouldSkip:isTerminal,// 终态下 monitor 停转})),// 2. per-state descriptors(L156-162)...Object.entries(def.states).flatMap(([state,stateDef])=>{constisNotState=(snapshot)=>snapshot.value!==state;// L157return[{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)
consttoEffect=({fn,shouldSkip,toFnCall=(baseCall)=>baseCall})=>effect(()=>{constsnapshot=snapshotSignal.get();// L167 — tracked 读!if(shouldSkip(snapshot))return;// 门控不通过,跳过constbaseCall=()=>fn();returnwrapResult(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)
constwrapResult=(result)=>{// 函数 → 原样;{abort()} 对象 → 包装成 () => result.abort();falsy → undefined};effect 返回fn | {abort()} | void三种形态,归一化成() => void交给 effect 机制。{abort()}形态很实用——直接把 Task 的 controller 或 DOM 句柄交出去。
两步销毁(L180-189)
destroy(){if(isTerminal())return;// 幂等transition('destroying');// L186 — 第一步transition('destroyed');// L187 — 第二步(同步紧跟)for(constdisposeofeffectDisposals)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 篇会遇到的场景写个概念示例:
createMachineReactor({initial:'idle',monitor:[()=>{constmediaSource=state.mediaSource.get();// tracked 读constsource=state.source.get();// tracked 读if(!mediaSource||!source)return'idle';return'active';},],states:{idle:{},active:{effects:[()=>{// 订问当前时间,决定加载窗口constmedia=context.media.get();constunlisten=media.addEventListener('timeupdate',()=>{loader.send({type:'checkBuffer'});});returnunlisten;// 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 全部统一成一种可组合单元。