videojs v10 源代码系列解读:13 · 能力型媒体契约:`Media` 的组合接口

进入卷三·媒体契约层。这个卷 6 篇都围绕一个核心问题:v10 怎么用一套类型系统,描述从原生 <video> 到 Google Cast 这种能力千差万别的媒体源? 答案是「能力型(Capability)契约」------把 HTMLMediaElement 拆成几十个最小的、可独立检测的接口。这篇讲契约的总貌,后面几篇讲谓词、主机模型、引擎适配器。

先说问题:一个臃肿的接口不够用

播放器的媒体元素可能是原生 <video>、hls.js 实例、Vimeo embed、Google Cast 接收端。它们的能力差异巨大:

  • Vimeo 不能任意 seek(只能按帧),没有 buffered,音量在某些平台锁死。
  • Google Cast 投屏时本地元素不再直接播放,所有操作转发给接收端。
  • 原生 <video> 最全,但 Safari 和 Chrome 的全屏 API 前缀不同。

v8 的 tech 注册表要求每个 tech 实现完整接口------做不到的得假装支持(返回空值或 no-op),运行时没法安全探测。v10 的做法是正交分解 :把 HTMLMediaElement 拆成几十个最小的 Capability 接口,每个只管一件事,然后用 TS 的 interface extends 把它们拼起来。

事件原语:最小的 EventLike

一切的起点在 packages/media/src/core/types.ts:5-9

ts 复制代码
export interface EventLike<Detail = void> {
  readonly type: string;
  readonly timeStamp: number;
  readonly detail?: Detail;
}

比原生 Event 精简很多------只保留 type/timeStamp,加个可选泛型 detail。为什么不用原生 Event?因为 Cast 和 Vimeo 的事件不是真 DOM Event,但它们「长得像」------有 type 和 timeStamp 就够了。这是 05 篇讲的「鸭子类型优先」在媒体层的体现。

接下来是类型安全的事件目标(types.ts:11-19):

ts 复制代码
export interface EventTargetLike<Events extends { [K in keyof Events]: EventLike }> {
  addEventListener<K extends keyof Events & string>(
    type: K,
    listener: (event: Events[K]) => void,
    options?: { signal?: AbortSignal }
  ): void;
  // ...
}

注意 listener: (event: Events[K]) => void------做到了 per-event-type 的入参推断。监听 'play' 拿到 EventLike,监听带 detail 的自定义事件拿到带 detail 的类型。options 只支持 { signal?: AbortSignal }(砍掉了 capture/once/passive),和 07 篇的取消机制对齐。

有个精巧的工厂函数 TypedEventTarget<Events>()types.ts:21-23):

ts 复制代码
export function TypedEventTarget<Events>() {
  return EventTarget as unknown as { new (): EventTargetLike<Events> };
}

它不是 class,是返回构造器的函数。用法 class Foo extends TypedEventTarget<MyEvents>() {}------这是 TS 里「用 mixin 风格继承带泛型参数的类」的标准 hack(extends 后面不能跟带类型参数的表达式,但可以跟返回构造器的函数调用)。

能力接口:每个只管一件事

核心设计来了。types.ts 里定义了 20 多个 *Capability 接口,每个极小。我挑几个有代表性的:

ts 复制代码
// types.ts:49-51
export interface MediaPlaybackCapability {
  play(): Promise<void>;
}

// types.ts:70-74
export interface MediaPauseCapability {
  pause(): void;
  readonly paused: boolean;
  readonly ended: boolean;
}

// types.ts:88-93
export interface MediaSeekCapability {
  currentTime: number;       // 可写,seek 入口
  loop: boolean;
  readonly duration: number;
  readonly seeking: boolean;
}

注意几个点:

  1. MediaPlaybackCapability 只有一个 play() ------没有 paused/ended,那些在 MediaPauseCapability 里。播放和暂停是两个独立能力。
  2. readonly 是关键差异 ------currentTime 可写(是 seek 入口),duration 只读。这精确反映了「能做什么」和「只能观察什么」的区别。
  3. 每个能力接口配一组 *EventsMediaPauseEventspause/endedMediaSeekEventstimeupdate/durationchange/seeking/seeked/loadedmetadata)。

我把全部能力接口列个表(看文档时方便查阅):

接口 行号 核心成员
MediaPlaybackCapability 49-51 play()
MediaPauseCapability 70-74 pause() + readonly paused/ended
MediaSeekCapability 88-93 currentTime(可写) + loop + readonly duration/seeking
MediaSourceCapability 125-133 src(可写) + currentSrc + readyState + load() + canPlayType()
MediaVolumeCapability 143-147 volume + muted + defaultMuted
MediaPlaybackRateCapability 157-160 playbackRate + defaultPlaybackRate
MediaBufferCapability 176-179 readonly buffered/seekable
MediaPlayedCapability 185-187 readonly played
MediaErrorCapability 202-204 readonly error
MediaTextTrackCapability 253-256 textTracks + addTextTrack()
MediaAudioTrackCapability 314-318 audioTracks + add/removeAudioTrack()
MediaVideoTrackCapability 320-324 videoTracks + add/removeVideoTrack()
MediaFullscreenCapability 390-394 isFullscreen + request/exitFullscreen()
MediaPictureInPictureCapability 405-410 isPictureInPicture + request/exitPiP()
MediaStreamTypeCapability 438-440 streamType
MediaLiveCapability 446-464 liveEdgeStart + targetLiveWindow
MediaRemotePlaybackCapability 483-486 remote + disableRemotePlayback
MediaVideoDimensionsCapability 512-515 readonly videoWidth/videoHeight

另外还有几个简单的:MediaControlsCapability、MediaAutoplayCapability、MediaPlaysInlineCapability、MediaPosterCapability、MediaAudioRenditionCapability、MediaVideoRenditionCapability、MediaConfigCapability。

聚合:把能力拼起来

单个能力接口没用,得拼起来。v10 用 TS 的 interface extends A, B, C {}(空 body)做 mixin 组合。看 types.ts:531-533

ts 复制代码
export interface Media<Events = MediaEvents>
  extends MediaPlaybackCapability, EventTargetLike<Events> {}

任何「媒体」都必须能 play() 并能监听事件 ------这是最小要求。Events 默认 MediaEvents(只有 play/playing/waiting)。

然后是完整的 MediaFulltypes.ts:552-568):

ts 复制代码
export interface MediaFull<Events = MediaFullEvents> extends Media<Events>,
  MediaPauseCapability, MediaSeekCapability, MediaSourceCapability,
  MediaVolumeCapability, MediaPlaybackRateCapability, MediaBufferCapability,
  MediaPlayedCapability, MediaErrorCapability, MediaTextTrackCapability,
  MediaStreamTypeCapability, MediaLiveCapability, MediaRemotePlaybackCapability,
  MediaControlsCapability, MediaAutoplayCapability, MediaConfigCapability {}

14 个 capability 全部必选 。注意没有包含 AudioTrack/VideoTrack/Rendition/Fullscreen/PiP/Poster/PlaysInline/VideoDimensions------那些是 video 专属或可选增强。

Video 和 Audio 的差异就在这(types.ts:572-582):

ts 复制代码
export interface Video extends MediaFull<VideoEvents>,
  MediaPlaysInlineCapability, MediaPosterCapability,
  MediaFullscreenCapability, MediaPictureInPictureCapability,
  MediaVideoDimensionsCapability {}

export interface Audio extends MediaFull<AudioEvents> {}

Video 比 MediaFull 多 5 个能力(PiP、全屏、poster、playsInline、视频尺寸)。Audio 就是 MediaFull,不带这些。Audio 原生没有 PiP/全屏/poster,这个类型设计忠实反映了这一点。

MediaError:富错误载体与契约解耦

packages/media/src/core/media-error.ts:15-50 有个 MediaError extends Error。值得说的是它和契约的解耦。

注意 MediaErrorCapability.error 的类型是 ErrorLike | nulltypes.ts:193-196),ErrorLike 只有 {readonly code; readonly message}------极简。而 MediaError class 继承自 Error(不是浏览器原生 MediaError),扩展出 context/fatal/data

契约要最小,class 是富载体 ------两者解耦。引擎报错用富 MediaError,但契约只承诺 ErrorLike,消费者不用依赖富字段。

有个细节:MediaError 的默认 code 是 MEDIA_ERR_CUSTOM = 100media-error.ts:23)。fatal 的默认逻辑(L44):code >= MEDIA_ERR_NETWORK && code <= MEDIA_ERR_ENCRYPTED------即 code 在 2,5 区间(网络/解码/源不支持/加密)就算致命,ABORTED(1) 和自定义(100+)不算。

三态可用性:MediaFeatureAvailability

types.ts:29

ts 复制代码
export type MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported';

三态:available(能做)、unavailable(这一刻做不了,如 iOS 上调音量)、unsupported(该平台根本没这能力)。这个三态在 state.ts 里被 volume/fullscreen/pip/remotePlayback 复用------UI 用它决定按钮该显示、置灰还是隐藏。14 篇和讲 state 层时会再提。

TargetLike:目标形状用 Partial 表达「可能没有」

还有个关键的类型族:MediaTargetLike / VideoTargetLiketypes.ts:588-616)。它们描述的是引擎要适配的目标对象 (通常是 HTMLMediaElement)的形状,和 Capability 的区别是:它们用 Partial<> 表达「这些字段目标可能没有」。

ts 复制代码
export interface MediaTargetLike extends
  MediaPlaybackCapability, MediaPauseCapability, MediaSeekCapability, /* ... 13 个必选 */,
  Partial<MediaLiveCapability>, Partial<MediaStreamTypeCapability>, Partial<MediaConfigCapability> {
  title: string;
}

原生媒体元素大概率有 play/pause/seek/volume,但不一定有 streamType / live 信息------那两个用 Partial。这是务实的设计:承认现实世界里 HTMLMediaElement 的能力分布不是非黑即白。

小结

能力型契约是 media 包的灵魂。它的设计哲学可以浓缩成三句话:

  1. 正交分解------每个 Capability 只管一件事,最小化。
  2. 精确的读写语义 ------readonly 区分「能做什么」和「只能观察什么」。
  3. Partial 承认现实 ------TargetLike 用 Partial<> 表达「目标可能没有」。

这套设计让 v10 能用一套类型系统,描述从最全的原生 <video> 到最简的 Cast 远程播放。下一篇讲怎么在运行时安全地探测这些能力------谓词守卫。

相关推荐
涛涛ing1 小时前
2026年9月,前端圈同时发生了四件事,指向同一个方向
前端
艾伦野鸽ggg1 小时前
Vue2 模板语法与样式绑定
前端
Amos_Web1 小时前
Rspack 源码解析(十一):资产 Hook 与增量构建
前端·rust·源码阅读
PC2005_cloud1 小时前
Windows 数据使用量页面卡死:1097 个热点档案和 161 MB 的 SRUM 库
前端·后端
烈风逍遥1 小时前
AI大模型中fetch 和 ReadableStream为啥一起出现
前端·人工智能
PC2005_cloud1 小时前
Nginx 防盗链配置实战:用 referer 模块保护网站静态资源
前端·后端
福兮说1 小时前
Canvas 文字排版:measureText 量出来的宽度为什么总是不对
前端·javascript
夏幻灵1 小时前
从零理解 Redux:从 state、action、reducer 到 Redux Toolkit 与异步请求
前端
烈风逍遥1 小时前
第四篇:AI 模块架构设计:多 Provider 切换、RAG 知识库与 Agent 编排
前端·后端·架构