videojs v10 源代码系列解读:32 · Reactor 模型:信号驱动的状态机

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 全部统一成一种可组合单元。

相关推荐
羲云1 小时前
给页面加一个 JSON 编辑器:jsoneditor 的功能、配置和接入注意事项
javascript
福兮说1 小时前
Base64 遇上中文和 emoji:btoa 报错、解出一串 %E7、URL 里加号变空格,六个坑一次说清
前端·javascript·base64·编码
encoderlee1 小时前
ubuntu server 如何播放视频显示网页
linux·ubuntu·音视频·树莓派·ubuntu server·arm嵌入式
深圳市青牛科技实业有限公司1 小时前
GC1808|24bit‑96kHz 立体声 Δ‑Σ ADC,高性价比音频采集方案
音视频
IMPYLH2 小时前
HTML 的 <title> 元素
前端·javascript·html
开开心心就好2 小时前
视频模糊怎么修复?免费工具支持批量处理
java·前端·人工智能·智能手机·pdf·excel
变与不变8062 小时前
jspost请求详解
前端·javascript
行走的小派2 小时前
OpenHarmony 音频应用开发:实时录音、重点标记与本地回放
音视频·openharmony·香橙派