videojs v10 系列解读: 03 · 三大设计哲学:无头 Core、能力契约、行为组合

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();
  }
}

我读这个类的时候,特别注意了几个特征:

  1. 零 DOM 引用。整个文件没有 document、没有 createElement、没有 addEventListener。它只持有 createState(来自 store)和一个 MediaPlaybackState 契约引用。
  2. 产出的是数据,不是 DOMgetAttrs() 返回一个 ARIA 属性对象,getState() 返回状态快照。这些数据是交给渲染器去画的。
  3. 业务逻辑全在这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 收到的 BehaviorDepscreate-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 are ReadonlySignal<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 的做法共同点
无头 CoreUI 继承树纯逻辑 Core + 渲染器包装组合替代继承
能力契约tech 注册表(全有或全无)细粒度 Capability 接口 + 谓词声明替代命令
行为组合单体引擎类Behavior 列表 + 类型冲突检测类型替代运行时检查

这三条贯穿全系列。后面每篇都会回到其中一条或几条。我给你的建议是:读 v10 任何一段代码感到困惑时,先问自己「这段代码在体现哪条哲学?」——答案往往能让代码意图豁然开朗。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值