卷五核心篇的收官。
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 的类型声明一致------类型冲突逼你回到设计:谁拥有这个键的写权、它的类型到底是什么。
类型体操的两个防御性细节
这套类型代码里有两个「防坑」注释值得单独说:
Empty = {}而非object(L109-118):object & {x: T}在 union-to-intersection 转换下会塌缩成{x: never}(TS 的怪癖),{} & {x: T}则干净地简化成{x: T}。biome-ignore 注释都写了。[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、引擎编排。