03 篇预告过:「Behavior 是 SPF 区别于所有其他流媒体库的核心创新」。这篇正式拆它。Behavior 把信号、Actor、Reactor、Runner 全部统一成一种可组合单元——引擎就是一个 Behavior 列表。还会讲
defineBehavior的穷尽性强制和makeShareSignals的消费者边界。下一篇讲 createComposition 的类型冲突检测。
回顾:Behavior 接口
create-composition.ts:83-93(03 篇引用过):
exportinterfaceBehavior<StateMap,ContextMap,Cfg>{stateKeys:readonly(keyofStateMap)[];// L89 — 声明需要的状态键contextKeys:readonly(keyofContextMap)[];// L91 — 声明需要的上下文键setup:(deps:BehaviorDeps)=>BehaviorCleanup;// L92}一个 behavior声明自己需要哪些键(stateKeys/contextKeys),提供一个 setup 函数,返回可选清理。BehaviorDeps(L60-64)给 setup 传{ state, context, config }——槽位映射 + 静态配置。
BehaviorCleanup(L9):void | (() => void | Promise<void>) | { destroy(): void | Promise<void> }——三种形态,函数或带 destroy 的对象,可异步。
两种 flavor:简单 vs 原语增强
设计文档(27 篇引过)的核心论断:问题的核心从来不是「用 Behavior 还是 Actor?」,而是「用 Behavior 包裹什么?」
- 简单 behavior——只用信号。stateKeys 声明槽位,setup 里
computed/effect建响应关系。 - 原语增强 behavior——内部实例化 actor/reactor/runner。actor/reactor/runner 永远活在 behavior 内部,从不出现在组合层。
这个规则我读源码验证过:setup-mediasource.ts、load-segments.ts这些 behavior 内部 new 了一堆 actor/reactor,但对外的形态仍然是标准的{ stateKeys, contextKeys, setup }。组合层只认 Behavior 一种东西——这就是「通用组合单元」的含义。
对比 hls.js:它有 StreamController、BufferController、LevelController……一堆 controller 类互相引用、互相调用方法。SPF 里这些全部变成 behavior,互相之间零直接引用——只通过信号槽位通信。
defineBehavior:穷尽性强制(L428-455)
手写 Behavior 字面量有个风险:类型声明了 state map,但 stateKeys 忘了列全某个键。运行时那个键的信号不会被创建,setup 里读它拿到 undefined,出了 bug 还很难查。
defineBehavior在编译期堵住这个漏洞。核心是ExhaustiveKeys(L375-379):
typeExhaustiveKeys<KeysextendsreadonlyPropertyKey[],Slotextendsobject,Nameextendsstring>=[keyofSlot]extends[Keys[number]]?Empty:{[Kin`Error:${Name}Keys must list every key in the typed slice`]:Exclude<keyofSlot,Keys[number]>};判定keyof Slot ⊆ Keys[number](槽位类型的每个键都在 keys 数组里)。不满足时给参数 shape 加一个 phantom 字段——错误消息是模板字面量类型(Error: stateKeys must list every key in the typed slice),值是缺失键的列表。用户提供的值不可能满足这个字段,调用点直接编译报错,错误消息里列出漏了哪些键。
这是「把运行时 bug 提前到编译期」的又一手(和 store 的UnionSliceState、capability 的谓词一个思路,但更极致——错误消息本身是类型)。
还有个贴心细节(L432-433):defineBehavior的const修饰符让stateKeys: ['preload']直接推断为readonly ['preload'],调用点免写as const。
运行时呢?恒等(L450-454 只有as unknown as桥接)——defineBehavior 是纯类型检查器,零运行时成本。
makeShareSignals:消费者边界(53 行)
Behavior 们互相通过信号槽位通信,那外部消费者(比如引擎的使用者)怎么拿到这些信号?share-signals.ts的makeShareSignals(L41-44):
exportfunctionmakeShareSignals<S,C>(inputStateKeys=[],inputContextKeys=[]){return{stateKeys:inputStateKeys,// 透传(默认 [])contextKeys:inputContextKeys,setup:({state,context,config})=>{config.onSignalsReady?.({state,context});// L47 — setup 时刻交出完整映射},};}它是个特殊 behavior:setup 不干活,只把 composition 的完整信号映射交给onSignalsReady回调。引擎使用者通过这个回调捕获信号引用,之后读写。
两个精妙点:
- 「capture refs, use later」是安全模式(L10-14 注释警告):回调触发时其他 behavior 还在 setup 阶段,读到的是初始种子值而非后续写入的值。所以正确用法是捕获引用(signal 的身份是稳定的),延迟使用——不要在回调里立刻
.get()拿业务数据。 inputStateKeys用来「物化」消费者输入槽(L29-39 注释):如果某个键没有任何 behavior 生产(比如引擎使用者要传入的userAudioTrackSelection),makeShareSignals 声明它,composition 就会为它创建信号。消费者输入也走同一套槽位机制。
还有一个类型层面的细节(L37-39):它刻意用Behavior<>字面量而非 defineBehavior——避免空/部分 keys 数组触发穷尽性检查。因为它的 setup 参数描述的是「回调收到的完整 S/C」,而非它物化的子集,穷尽性检查对它不适用。知道什么时候不用工具,和知道怎么用一样重要。
41 篇会看到 HLS 引擎用makeShareSignals作为对外的 I/O 边界。
Behavior 的读写权限模型
把 03 篇的伏笔正式收掉。behavior 的 setup 参数类型是槽位映射:
setup:(deps:{state:{presentation:ReadonlySignal<Presentation>,preload:Signal<Preload>},// ...})=>Cleanup每个槽位自己决定Signal<T>(可写)或ReadonlySignal<T>(只读)——28 篇讲过ReadonlySignal是Omit<State<T>, 'set'>。TS 编译期拒绝在只读槽上调.set()。
读写意图是每个 behavior 自己声明的类型契约。composition 不强制谁拥有哪个键——多个 behavior 可以声明同一个键(一个写、其他读),类型冲突检测(下篇)保证它们对键的类型一致。
引擎 = Behavior 列表(预览)
41 篇详讲,这里先看形态。createSimpleHlsEngine大致是:
createComposition([resolvePresentation,// 简单 behavior:fetch + 解析清单 → presentation 槽位selectTracks,// 选择轨道 → selection 槽位setupMediaSource,// 原语增强:内部建 MediaSource → mediaSource 槽位setupBufferActors,// 原语增强:SourceBufferActor(video/audio)loadSegments,// 原语增强:SegmentLoaderActor + Reactor 翻译endOfStream,// ...abr,// ABR(5.75 步)makeShareSignals(['userAudioTrackSelection'],[],{onSignalsReady}),// 消费者边界],{config,initialState,initialContext});约 20 个 behavior 按固定顺序。初始化顺序是承重的——比如syncPreload必须在resolvePresentation之前注册,两个 SourceBuffer 要在一个同步过程创建(Firefox 的mozHasAudio)。这个顺序约束是显式写在引擎代码里的知识,41 篇展开。
小结
- Behavior 是唯一组合单元——actor/reactor/runner 活在 behavior 内部,组合层只认 Behavior。
- behavior 之间零直接引用——只通过信号槽位通信(对比 hls.js 的 controller 互调)。
- defineBehavior 穷尽性强制——漏列键编译期报错,错误消息列出缺失键;运行时恒等。
- const 修饰符免 as const。
- makeShareSignals 是消费者边界——onSignalsReady 交出信号映射;「capture refs, use later」;可物化消费者输入槽。
- 读写权限是 setup 参数的类型契约——每槽位自选 Signal/ReadonlySignal。
下一篇拆createComposition的完整实现——运行时怎么构建信号映射、编译期怎么做类型冲突检测。