v10 十几万行代码,乍看庞杂,但我读下来发现,贯穿始终的就三条设计原则。理解这三条,你就拿到了解读任何模块的钥匙。这篇我用真实代码片段讲每条原则怎么落地——不是泛泛而谈,是带你对着源码看。
哲学一:无头 Core + 平台渲染器分离
先说 v8 的问题在哪
v8 的 Button 类把 DOM 和业务逻辑焊死了——el() 返回 DOM,handleClick() 处理逻辑,俩长在一棵继承树里。我读的时候就想:这套东西要是想渲染成 React 组件,根本做不到,只能整个重写。
v10 的解法:把逻辑抽成不碰 DOM 的纯类
拿播放按钮当例子。我打开 packages/core/src/core/ui/play-button/play-button-core.ts,里面的 PlayButtonCore 长这样:
// play-button-core.ts:19-80(节选)
export class PlayButtonCore {
static readonly defaultProps = { label: '', disabled: false };
// 响应式状态,来自 @videojs/store
readonly state = createState<PlayButtonState>({
paused: true, ended: false, started: false, label: '',
});
#props = { ...PlayButtonCore.defaultProps };
#media: MediaPlaybackState | null = null;
// 产出 ARIA 属性(给渲染器用)
getAttrs(state: PlayButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
setMedia(media: MediaPlaybackState): void { this.#media = media; }
getState(): PlayButtonState {
const media = this.#media!;
this.state.patch({ paused: media.paused, ended: media.ended, started: media.started });
this.state.patch({ label: resolveText(this.getLabel(this.state.current)) });
return this.state.current;
}
// 核心动作
async toggle(media: MediaPlaybackState): Promise<void> {
if (this.#props.disabled) return;
if (media.paused || media.ended) return media.play();
media.pause();
}
}
我读这个类的时候,特别注意了几个特征:
- 零 DOM 引用。整个文件没有
document、没有createElement、没有addEventListener。它只持有createState(来自 store)和一个MediaPlaybackState契约引用。 - 产出的是数据,不是 DOM。
getAttrs()返回一个 ARIA 属性对象,getState()返回状态快照。这些数据是交给渲染器去画的。 - 业务逻辑全在这。
toggle()决定「暂停时播放、播放时暂停、结束时重播」——这就是播放按钮的全部语义,写在这里,不在元素里。
渲染器只是「画笔」
那 HTML 平台怎么用它?我打开 packages/html/src/ui/play-button/play-button-element.ts:
export class PlayButtonElement extends MediaButtonElement<PlayButtonCore> {
protected readonly core = new PlayButtonCore(); // 实例化无头 Core
protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback);
protected override readonly hotkeyAction = 'togglePaused';
protected activate(state: MediaPlaybackState): void { this.core.toggle(state); }
}
你看这个 Web Component 就干三件事:实例化 PlayButtonCore、订阅 store、把点击转发给 core.toggle()。业务逻辑一行都没有,全在 Core 里。
React 平台呢?对应组件(packages/react/src/ui/play-button/)通过 createMediaButton 工厂包装同一个 PlayButtonCore 类。结果就是:同一份逻辑,两种渲染。这就是「无头」(headless)的含义——逻辑没有自己的 UI 头,头由平台接上去。
为什么值得这么做
这个模式贯穿整个 core/src/core/ui/ 目录(约 30 个组件,每个有 XCore 类)。我总结它带来三个收益:
- 跨平台复用:HTML、React、未来的 RN 共用同一份逻辑。
- 好测试:Core 类不依赖 DOM,Vitest 直接测逻辑,不需要 jsdom。
- 渲染解耦:换皮肤、换框架,不动业务逻辑。
哲学二:能力型(Capability)契约
先说问题:媒体源的能力差异太大了
播放器的媒体元素可能是原生 <video>、hls.js 实例、Vimeo embed、Google Cast 接收端。它们能力千差万别:Vimeo 不能任意 seek,Cast 的全屏 API 完全不一样。如果用一个臃肿的 Media 接口描述所有能力,那每个实现都得假装支持自己没有的功能——这显然不健康。
v10 的解法:细粒度能力接口 + 谓词守卫
我打开 packages/media/src/core/types.ts,发现 Media 不是一个大接口,而是许多细粒度能力接口的组合。每个能力接口配一组事件:
// types.ts:43-74(节选)
export interface MediaPlaybackEvents {
play: EventLike; playing: EventLike; waiting: EventLike;
}
export interface MediaPlaybackCapability {
play(): Promise<void>;
}
export interface MediaPauseEvents {
pause: EventLike; ended: EventLike;
}
export interface MediaPauseCapability {
pause(): void;
readonly paused: boolean;
readonly ended: boolean;
}
export interface MediaSeekCapability {
currentTime: number;
loop: boolean;
readonly duration: number;
readonly seeking: boolean;
}
注意每个能力接口都极小——MediaPlaybackCapability 只有一个 play(),MediaPauseCapability 只有 pause() 加两个只读属性。最终的 Media 聚合类型是把这些组合起来(部分必选、部分可选)。
光有接口不够,还要能运行时安全调用
这就是谓词(predicate)。packages/media/src/core/predicate.ts 导出一组类型守卫:
isMediaPauseCapable(media) // 能否暂停
isMediaSeekCapable(media) // 能否 seek
isMediaVolumeCapable(media) // 能否调音量
// ...
Feature 的 attach() 用谓词做前置检查。我打开 playbackFeature 看了一眼:
// core/src/dom/store/features/playback.ts:32-35
attach({ target, signal, set }) {
const { media } = target;
if (!isMediaPauseCapable(media) || !isMediaSeekCapable(media) || !isMediaSourceCapable(media)) {
return; // 不支持就跳过,不报错
}
// ... 绑定事件
}
这个模式我觉得极其优雅:能力不支持时静默跳过,而不是抛异常。Vimeo 这样的部分能力实现,只需要实现自己支持的接口,不支持的能力对应的 Feature 自动失效,播放器其他部分照常工作。这比 v8 的「tech 必须实现完整接口」友好太多了。
哲学三:行为(Behavior)组合
先说问题:流媒体引擎太复杂
一个 HLS 引擎要做:解析清单、选轨道、选质量、建 MediaSource、建 SourceBuffer、加载分段、追加分段、处理流结束、同步字幕轨道……这些操作「天然不属于一起」,但传统实现全塞进一个巨大类里(hls.js 的 Hls、shaka 的 Player 都这样)。我读那种代码的感受是:改一处怕动全身。
v10 的解法:一切皆 Behavior
SPF 给的答案是 Behavior 是通用组合单元。Behavior 声明自己需要哪些状态/上下文键,提供一个 setup 函数,返回可选清理句柄。我打开 packages/spf/src/core/composition/create-composition.ts:
// create-composition.ts:83-93
export interface Behavior<StateMap, ContextMap, Cfg> {
/** 该 behavior 读写的状态键 */
stateKeys: readonly (keyof StateMap)[];
/** 该 behavior 读写的上下文键 */
contextKeys: readonly (keyof ContextMap)[];
setup: (deps: BehaviorDeps<StateMap, ContextMap, Cfg>) => BehaviorCleanup;
}
setup 收到的 BehaviorDeps(create-composition.ts:60-64)有三样东西:
export interface BehaviorDeps<StateMap, ContextMap, Cfg> {
state: StateMap; // 状态信号槽位映射
context: ContextMap; // 平台对象/actor 引用的信号槽位映射
config: Cfg; // 静态配置
}
一个让我拍案的设计:读写意图是类型契约
这里有个特别精妙的点:每个槽位是 Signal<T>(可写)还是 ReadonlySignal<T>(只读)是真实的类型契约。源码注释(create-composition.ts:40-50)是这么写的:
It lets a single behavior declare a heterogeneous slot map where some slots are
Signal<T>(writable) and others areReadonlySignal<T>(read-only) — making read/write intent explicit at the call site and giving body-level enforcement.
翻译成人话:一个 behavior 的 setup 参数类型,会把每个槽位标成 Signal<T>(可写)或 ReadonlySignal<T>(只读)。TypeScript 在编译期就会拒绝你在只读槽位上调 .set()。读写权限成了类型系统的一部分——不是注释约定,是编译报错。
引擎就是一串 Behavior
所以 HLS 引擎不是一个类,而是一串 Behavior。我打开 packages/spf/src/playback/engines/hls/engine.ts,看到它把约 20 个 behavior 按固定顺序组合:解析清单 → 选轨道 → 解析轨道 → 建 MediaSource → 建 SourceBuffer → 加载分段 → 流结束 → 字幕轨道(ABR 在第 5.75 步)。这个初始化顺序是承重的(load-bearing)——比如两个 SourceBuffer 必须在一个同步过程里创建,是为了 Firefox 的 mozHasAudio 兼容性。
createComposition 在编译期还做类型冲突检测:两个 behavior 声明同一个状态键但类型冲突,TypeScript 的交叉类型会让编译失败。这是「让错误在编译期暴露」的极致。
设计文档里有一句话我觉得总结得特别好:
问题的核心从来不是「用 Behavior 还是 Actor?」,而是「用 Behavior 包裹什么?」——简单 behavior(只用信号)还是原语增强 behavior(内部实例化 actor/reactor/runner)。
三条哲学其实是统一的
乍看这三条各自独立,但我读着读着发现它们共享同一个深层信念:用组合替代继承,用声明替代命令,用类型替代运行时检查。
| 哲学 | v8 的做法 | v10 的做法 | 共同点 |
|---|---|---|---|
| 无头 Core | UI 继承树 | 纯逻辑 Core + 渲染器包装 | 组合替代继承 |
| 能力契约 | tech 注册表(全有或全无) | 细粒度 Capability 接口 + 谓词 | 声明替代命令 |
| 行为组合 | 单体引擎类 | Behavior 列表 + 类型冲突检测 | 类型替代运行时检查 |
这三条贯穿全系列。后面每篇都会回到其中一条或几条。我给你的建议是:读 v10 任何一段代码感到困惑时,先问自己「这段代码在体现哪条哲学?」——答案往往能让代码意图豁然开朗。

210

被折叠的 条评论
为什么被折叠?



