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

相关推荐
m0_敲代码的彭于晏1681 小时前
Vercel 前端部署平台深度评测
前端
2601_965619692 小时前
把开源项目Changelog自动做成更新视频,AI工作流
人工智能·开源·音视频
怕浪猫2 小时前
外包和大厂的前端代码,差在凌晨三点
前端·面试·github
m0_敲代码的彭于晏1683 小时前
Vercel 前端部署与全栈开发实战指南
前端·状态模式
徐小夕3 小时前
花了3个月,我们做了一款 AI 版的“飞书文档”
前端·算法·github
贾伟康3 小时前
【化学实验室|17】HarmonyOS ArkTS 亮暗色与视觉令牌实战:集中颜色、间距和交互状态避免页面割裂
前端·移动开发·harmonyos·arkts·arkui
Csvn4 小时前
SSR/SSG/ISR(上):峡谷与渲染策略的时间预算
前端
Csvn4 小时前
SSR/SSG/ISR(下):可交互性、新鲜度与回源账
前端
小豆庄4 小时前
视频人脸打码追踪:转场丢失、漏检和ID撞号的处理
目标检测·计算机视觉·开源·音视频·视频·python3.11
酷炫码神4 小时前
Day 10|Pinia 状态管理:把散落在各组件的数据,收进一个全局仓库
android·开发语言·javascript