上一篇讲了能力契约的类型设计,但光有类型不够------TS 的类型在运行时消失。你还需要在运行时安全地探测 「这个媒体对象到底支不支持某能力」。这就是谓词(predicate)的工作。这篇讲
predicate.ts的实现模式,特别是那几个排除空哨兵的「复合谓词」------不读源码很难想到这层。
谓词的基本模式
packages/media/src/core/predicate.ts(134 行)全是 type guard(value is X)。实现模式高度统一,我拆出来看:
ts
// predicate.ts:25-29
export function isMediaPauseCapable(media: unknown): media is MediaPauseCapability {
if (!isObject(media)) return false;
const { paused, ended, pause } = media as Record<string, unknown>;
return !isUndefined(paused) && !isUndefined(ended) && isFunction(pause);
}
三步走:
isObject(media)守卫(排除 null/undefined)。- cast 成
Record<string, unknown>(拿属性)。 - 逐个检查:字段用
!isUndefined(x),方法用isFunction(x)。
isObject/isFunction/isUndefined 都来自 @videojs/utils/predicate(01 篇讲过的工具包)。
这个模式贯穿所有谓词,但具体查什么有讲究。比如 isMediaSeekCapable(predicate.ts:31-35)查 currentTime/duration/seeking,但故意不查 loop ------因为 loop 在 capability 里,但谓词宽松,loop 不影响 seek 能力的判定。
复合谓词:排除空哨兵
这是最值得讲的部分。有三个谓词不只查字段存在,还额外排除空哨兵对象:
isMediaBufferCapable(predicate.ts:60-69)
ts
export function isMediaBufferCapable(media: unknown): media is MediaBufferCapability {
if (!isObject(media)) return false;
const { buffered, seekable } = media as Record<string, unknown>;
return !isUndefined(buffered)
&& media.buffered !== EMPTY_TIME_RANGES // L66 --- 排除哨兵!
&& !isUndefined(seekable)
&& media.seekable !== EMPTY_TIME_RANGES; // L67
}
为什么排除 EMPTY_TIME_RANGES?这要去 constants.ts:4-8 看:
ts
export const EMPTY_TIME_RANGES = Object.freeze({
length: 0, start: () => 0, end: () => 0,
}) as Readonly<TimeRangeLike>;
MediaBufferCapability.buffered 的类型是非空 TimeRangeLike(不是 ... | null)。所以没 buffer 数据的宿主不能给 null ,必须给一个合法但空的占位------这就是 EMPTY_TIME_RANGES。
但这带来一个问题:宿主类型上满足契约,运行时却给了个空占位。谓词必须区分「占位」和「真能用」。所以 isMediaBufferCapable 额外检查 !== EMPTY_TIME_RANGES------只有真有 buffer 数据才算「有这个能力」。
isMediaTextTrackCapable(predicate.ts:77-81)和 isMediaRemotePlaybackCapable(L101-105)
同理:
ts
// isMediaTextTrackCapable 排除 EMPTY_TEXT_TRACKS
!isUndefined(textTracks) && media.textTracks !== EMPTY_TEXT_TRACKS
// isMediaRemotePlaybackCapable 排除 EMPTY_REMOTE
isObject(media.remote) && media.remote !== EMPTY_REMOTE
EMPTY_TEXT_TRACKS(constants.ts:11-15)是 Object.assign(new EventTarget(), { length: 0, *[Symbol.iterator](){}, getTrackById: () => null })------基于 EventTarget,因为 TextTrackListLike extends EventTargetLike 需要能监听事件。EMPTY_REMOTE(L17)最粗暴:new EventTarget() as unknown as RemotePlaybackLike。
这套哨兵的存在理由:让宿主对象类型上满足契约、但运行时表达「我没有这个东西」,同时让谓词能识别它不是真能用。这是个很务实的设计------TS 的非空类型逼着你给个占位,但占位不能骗过谓词。
谓词只覆盖「需要运行时探测」的能力
我读的时候注意到:不是每个 Capability 都有对应谓词。比如:
- 没有
isMediaPlaybackCapable------因为Media本身就是它(任何媒体都能 play)。 - 没有
isMediaFullscreenCapable/isMediaPictureInPictureCapable------这些在 Video 接口里是必选的(你 implements Video 就承诺了)。 - 没有
isMediaPosterCapable/isMediaPlaysInlineCapable/isMediaControlsCapable/isMediaAutoplayCapable------简单的布尔标志位,不需要专门探测。
谓词只覆盖那些「宿主可能没有、需要运行时探测」的能力。这是个有意识的选择------不为一眼能看穿的能力写谓词,保持 API 表面精简。
hasMetadata:唯一的非 guard 函数
有个例外:hasMetadata(predicate.ts:21-23)不是 type guard:
ts
export function hasMetadata(media: MediaSourceCapability): boolean {
return media.readyState >= 1;
}
它不是 value is X,参数已是 MediaSourceCapability。返回 media.readyState >= 1(对应 HAVE_METADATA)。这查的不是「有没有能力」,是「能力处于什么状态」------所以不是 guard。
完整谓词表
方便查阅,列全:
| 谓词 | 行号 | 检查什么 |
|---|---|---|
| isMediaPauseCapable | 25-29 | paused + ended + pause() |
| isMediaSeekCapable | 31-35 | currentTime + duration + seeking(不查 loop) |
| isMediaSourceCapable | 37-46 | src + currentSrc + readyState + load() |
| isMediaVolumeCapable | 48-52 | volume + muted(不查 defaultMuted) |
| isMediaPlaybackRateCapable | 54-58 | 只查 playbackRate |
| isMediaBufferCapable | 60-69 | buffered + seekable,排除 EMPTY_TIME_RANGES |
| isMediaErrorCapable | 71-75 | 只查 error |
| isMediaTextTrackCapable | 77-81 | textTracks,排除 EMPTY_TEXT_TRACKS |
| isMediaVideoRenditionCapable | 83-87 | 只查 videoRenditions |
| isMediaAudioTrackCapable | 89-93 | 只查 audioTracks |
| isMediaVideoDimensionsCapable | 95-99 | videoWidth + videoHeight |
| isMediaRemotePlaybackCapable | 101-105 | remote,排除 EMPTY_REMOTE |
| isMediaStreamTypeCapable | 107-111 | 只查 streamType |
| isMediaLiveCapable | 113-117 | liveEdgeStart + targetLiveWindow |
注意复合谓词(加粗的三个)的特殊性------它们排除了哨兵。写文章时要特别强调这层,因为不读源码根本想不到。
谓词怎么被使用
04 篇追生命周期时讲过,feature 的 attach() 用谓词做前置检查。再看 playbackFeature:
ts
// core/src/dom/store/features/playback.ts:32-35
attach({ target, signal, set }) {
const { media } = target;
if (!isMediaPauseCapable(media) || !isMediaSeekCapable(media) || !isMediaSourceCapable(media)) {
return; // 不支持就跳过
}
// ... 绑定事件
}
能力不支持时静默跳过,不报错。这是谓词的核心价值:让 Feature 具备渐进降级能力。Vimeo 这种部分能力实现,不支持的 Feature 自动失效,播放器其他部分照常工作。
这个模式在 v10 的 feature 里到处都是。卷四讲播放器核心时会看到每个 feature 都这么干。
State 层的 *Availability:三态可用性
谓词是布尔(有/没有),但 UI 需要更细的三态。13 篇提过 MediaFeatureAvailability = 'available' | 'unavailable' | 'unsupported'。这个三态在 state.ts 里被 volume/fullscreen/pip/remotePlayback 复用。
它和谓词的关系:谓词判「unsupported」(能力不存在),三态额外判「unavailable」(能力存在但这一刻用不了,如 iOS 上调音量)。UI 用它决定按钮显示/置灰/隐藏。这是谓词之上的 UX 细化层。
小结
谓词看似简单(就是类型守卫),但有几个值得带走的洞察:
- 统一模式 ------
isObject守卫 +!isUndefined/isFunction逐个查。 - 复合谓词排除哨兵 ------
EMPTY_TIME_RANGES/EMPTY_TEXT_TRACKS/EMPTY_REMOTE,这是 TS 非空类型逼出的务实设计。 - 只覆盖需要探测的能力------简单布尔标志位不写谓词。
- Feature 用它做渐进降级------不支持就 return,不报错。
- 三态可用性是谓词的 UX 细化------布尔判 unsupported,三态额外判 unavailable。
下一篇我们看 HTMLMediaElementHost------v10 怎么用「主机-组件」模型把能力契约落到一个可委托、可覆盖的对象上。