videojs v10 源代码系列解读:34 · `createComposition`:类型安全的冲突检测

卷五核心篇的收官。createComposition 把 behavior 列表组装成引擎:运行时构建信号映射、跑 setup、收 cleanup;编译期做跨 behavior 的类型冲突检测 。那套 ValidateComposition/HasConflict 类型体操是全 v10 最精妙的 TS 代码------两个 behavior 对同一个键声明了冲突类型,错误以字符串字面量类型出现在参数位置。这篇完整拆它。

运行时部分:三个步骤

先看简单的运行时(L306-358)。

步骤 1:buildSignalMap------从键并集构建信号(L295-304)

ts 复制代码
function buildSignalMap<S>(keys: Iterable<PropertyKey>, initial: Partial<S>) {
  const uniqueKeys = new Set(keys);                          // L300 --- 去重,首现优先
  return Object.fromEntries(
    [...uniqueKeys].map((key) => [key, signal(init[key])])   // L301 --- 每键一个信号
  ) as { [K in keyof S]-?: Signal<S[K]> };
}

输入所有 behavior 的 stateKeys 并集 ,new Set 去重(保持插入序),每个唯一键建一个 signal(),从 initialState[key] 播种,缺省即 undefined。L278-281 注释坦白:per-key 类型只在 TS 层,runtime 全是 Signal<unknown>。

步骤 2:跑 setup 收 cleanup(L323-337)

ts 复制代码
const state = buildSignalMap(validBehaviors.flatMap((b) => b.stateKeys), options?.initialState ?? {});   // L323-326
const context = buildSignalMap(validBehaviors.flatMap((b) => b.contextKeys), options?.initialContext ?? {});  // L327-330
const deps = { state, context, config: options?.config ?? {} };   // L332-336
const cleanups = validBehaviors.map((behavior) => behavior.setup(deps));   // L337

所有 behavior 共享同一份 deps(同一套信号映射)。setup 按数组顺序同步执行,返回值收进 cleanups。

步骤 3:destroy------异步清理 + 信号重置(L342-358)

ts 复制代码
async destroy() {
  const results = [];
  for (const cleanup of cleanups) {
    if (cleanup == null) continue;                       // L345
    if (typeof cleanup === 'function') results.push(cleanup());         // L346-347
    else if ('destroy' in cleanup) results.push(cleanup.destroy());     // L348-349
  }
  await Promise.all(results);                             // L352 --- 等全部清理完成
  for (const sig of Object.values(state)) sig.set(undefined);    // L356 --- 重置所有信号
  for (const sig of Object.values(context)) sig.set(undefined);
}

两类 cleanup(函数 / destroy 对象)分类收集,await Promise.all 等所有清理完成(异步清理如「等最后一个 append 完成」),最后把每个信号重置为 undefined (L356-357)。重置的意义:销毁后任何残留的 effect/读侧立刻看到「空」,不会读到陈旧数据。L353-355 注释说这对应旧版 owners.set({}) 的语义。

编译期部分:冲突检测的完整链路

现在到重头戏。目标是:behavior A 声明 preload: Signal<'auto'|'metadata'>,behavior B 声明 preload: Signal<number>------组合时编译报错。

第一环:InferBehaviorState------从 setup 签名推导(L107-136)

ts 复制代码
type DepsOf<B> = B extends { setup: (deps: infer D, ...args: any[]) => any } ? D : never;  // L107

type UnwrapSignals<M> = M extends object
  ? { [K in keyof M]: M[K] extends { get(): infer V } ? V : never }
  : Empty;                                                                                  // L127

type InferBehaviorState<F> = UnwrapSignals<DepsOf<F>['state']>;   // L130

从 behavior 的 setup 函数签名 抽出 deps 类型,再从 state 字段解包每个槽位的值类型。L120-126 注释解释了一个关键选择:经由 { get(): infer V } 而非 Signal<infer V> 推断------绕开 Signal 类型的 nominal/不变性,得到协变的 V。

第二环:IntersectBehaviors------递归交集(L146-151)

ts 复制代码
type IntersectBehaviors<Behaviors extends readonly AnyBehavior[], Project> =
  Behaviors extends readonly [infer First, ...infer Rest extends readonly AnyBehavior[]]
    ? Apply<Project, First> & IntersectBehaviors<Rest, Project>
    : Empty;

对元组递归 :第一个的投影 & 剩下的递归结果,空元组基例 Empty。L139-145 注释解释为什么不用 UnionToIntersection 的逆变技巧------避免空 {} 成员导致塌缩 。三个投影 marker(L166-180)分发到 InferBehaviorState/Context/Config,最终得到 ResolveBehaviorState<Behaviors>(L175-176)------所有 behavior 状态类型的交集。

第三环:HasConflict------检测塌缩(L189-193)

ts 复制代码
type HasConflict<T extends object> = true extends {
  [K in keyof T]: [T[K]] extends [undefined] ? true : never
}[keyof T] ? true : false;

这是检测器本体。原理:TS 的交叉类型遇到冲突会塌缩。

  • 必选冲突:{v: number} & {v: string} → {v: never}。[never] extends [undefined]?是 → true。
  • 可选冲突:{v?: number} & {v?: string} → {v?: undefined}。直接命中 → true。

L184-188 注释列出这两条捕获路径。交叉类型的塌缩行为从 bug 变成了特性------冲突检测不需要逐对比较,直接看交集里有没有字段塌成 never/undefined。

第四环:ValidateComposition------闸门(L211-218)

ts 复制代码
type ValidateComposition<Behaviors> =
  HasConflict<ResolveBehaviorState<Behaviors>> extends true
    ? 'Error: behaviors have conflicting state types'
    : HasConflict<ResolveBehaviorContext<Behaviors>> extends true
      ? 'Error: behaviors have conflicting context types'
      : HasConflict<ResolveBehaviorConfig<Behaviors>> extends true
        ? 'Error: behaviors have conflicting config types'
        : [...Behaviors];

依次检查 state → context → config。冲突时返回错误字符串字面量类型,全部通过返回原元组。

闸门怎么生效(L306-321)

ts 复制代码
export function createComposition<const Behaviors extends readonly AnyBehavior[]>(
  behaviors: ValidateComposition<Behaviors>,   // L307 --- 编译期闸门!
  options?: CompositionOptions<...>
): Composition<...> {
  const validBehaviors = behaviors as unknown as readonly AnyBehavior[];   // L318-321 --- 运行时桥接
  // ...
}

L307 是闸门的全部 :behaviors 参数类型就是 ValidateComposition<Behaviors>。冲突时这个类型是 'Error: behaviors have conflicting state types'------你的 behavior 数组(不是字符串)无法赋给它,调用点直接编译报错 。类型检查通过时,函数体只在成功分支运行,L318 的 as unknown as 桥接回运行时形态。

错误即类型 ------不需要自定义错误类、不需要 @ts-expect-error,错误消息就是参数类型本身。我第一次读懂这个手法时是真的拍案。

一个演示

ts 复制代码
// behavior A 的 setup 参数声明了 state.preload: Signal<PreloadMode>
// behavior B 的 setup 参数声明了 state.preload: Signal<number>

createComposition([behaviorA, behaviorB]);
// 编译错误:Argument of type '[BehaviorA, BehaviorB]' is not assignable to
// parameter of type '"Error: behaviors have conflicting state types"'.

错误消息直接出现在参数类型位置。修复方式:让两个 behavior 对 preload 的类型声明一致------类型冲突逼你回到设计:谁拥有这个键的写权、它的类型到底是什么。

类型体操的两个防御性细节

这套类型代码里有两个「防坑」注释值得单独说:

  1. Empty = {} 而非 object (L109-118):object & {x: T} 在 union-to-intersection 转换下会塌缩成 {x: never}(TS 的怪癖),{} & {x: T} 则干净地简化成 {x: T}。biome-ignore 注释都写了。
  2. [T[K]] extends [undefined] 的方括号:包一层元组防止分配条件类型(distributive conditional)把检查拆散到联合成员上。

类型体操不是炫技------每一步都在防御 TS 推理的边角行为。

三个 composition 级别的清理语义对比

至此 v10 有三层清理机制,对比一下:

层 清理机制 时机
store AbortControllerRegistry(base/supersede/clear) detach/destroy 时 abort
element DestroyMixin 双 rAF + hostDestroyed 永久离开 DOM 后
SPF composition setup 返回 cleanup,destroy 时 await Promise.all + 信号重置 显式 destroy()

SPF 的特点:清理可以是异步的 (L352 await)------MSE 的操作必须等真正完成。而信号重置(L356)是其他两层没有的:销毁后读侧立刻看到空。

小结

  • 运行时三步:键并集 → buildSignalMap → setup 收 cleanup;destroy 时 await 全部清理再重置信号。
  • 编译期四环:DepsOf 推导 → UnwrapSignals(经 get() 绕 nominal)→ 递归交集 → HasConflict 检测塌缩。
  • 错误即类型:冲突时参数类型变成错误字符串,调用点报错。
  • 交叉塌缩从 bug 变特性 :{v:number} & {v:string} → {v:never} 就是冲突信号。
  • 类型体操处处防御 :Empty = {}、[T[K]] 方括号,都是 TS 边角行为的解药。

至此 SPF 的框架核心(信号 → Task/Runner → Actor/Reactor → Behavior → Composition)讲完了。接下来 7 篇进入流媒体领域:HLS 解析器、ABR、缓冲数学、MSE 管线、两个核心 Actor、引擎编排。

相关推荐
hasty1 小时前
传完再检查为何太晚?LXD 迁移流的文件写入边界
安全·系统安全
每天题库1 小时前
架子工脚手架工题库:安全操作考点与模拟题练习指南
学习·安全·考试·题库·考证
utmhikari2 小时前
【AI原生】借助AI之力优化个人博客前端样式
前端·css·hexo·next·个人博客·ai-native·vibe coding
白帽攻防录2 小时前
SRC 挖洞:WSO2 JWT 算法混淆绕过深度复盘,CVE-2026-5430 不支持的算法怎么变成管理员
网络·算法·安全·网络安全·jwt
小凯在掘金3 小时前
为什么箭头函数不能作为构造函数?
前端·javascript
正经教主3 小时前
【FDE系列】阶段3:Day 58:Prompt 安全 — 注入、越狱与防护
网络·人工智能·安全·prompt·fde
hasty3 小时前
配置禁止重定向,为何请求仍继续?Axios Fetch 适配器的安全契约
安全
JustHappy4 小时前
「vConsole MCP🛠️」我让 AI 直接看见任何 H5 的日志和请求帮你 debug
前端·javascript·程序员
codeGoogle4 小时前
Vue3 UIKit 实战:把聊天、会话、主题和移动端适配全部封装好
前端