进入卷三·媒体契约层。这个卷 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;
}
注意几个点:
MediaPlaybackCapability只有一个play()------没有 paused/ended,那些在MediaPauseCapability里。播放和暂停是两个独立能力。readonly是关键差异 ------currentTime可写(是 seek 入口),duration只读。这精确反映了「能做什么」和「只能观察什么」的区别。- 每个能力接口配一组
*Events(MediaPauseEvents有pause/ended,MediaSeekEvents有timeupdate/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)。
然后是完整的 MediaFull(types.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 | null(types.ts:193-196),ErrorLike 只有 {readonly code; readonly message}------极简。而 MediaError class 继承自 Error(不是浏览器原生 MediaError),扩展出 context/fatal/data。
契约要最小,class 是富载体 ------两者解耦。引擎报错用富 MediaError,但契约只承诺 ErrorLike,消费者不用依赖富字段。
有个细节:MediaError 的默认 code 是 MEDIA_ERR_CUSTOM = 100(media-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 / VideoTargetLike(types.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 包的灵魂。它的设计哲学可以浓缩成三句话:
- 正交分解------每个 Capability 只管一件事,最小化。
- 精确的读写语义 ------
readonly区分「能做什么」和「只能观察什么」。 - Partial 承认现实 ------TargetLike 用
Partial<>表达「目标可能没有」。
这套设计让 v10 能用一套类型系统,描述从最全的原生 <video> 到最简的 Cast 远程播放。下一篇讲怎么在运行时安全地探测这些能力------谓词守卫。