这篇我们来追一次完整的播放:从调用
createPlayer(),到用户点播放按钮、媒体开始播放、UI 更新图标。我会沿着真实调用链一步步走,每一步都标上文件:行号,你可以对照源码读。读完这篇,v10 怎么运作的就不再神秘了。
先看全景:两个入口,殊途同归到 Store
v10 有两个平台入口(HTML 和 React),但它们在某一层之后汇合——都进 @videojs/store 的 createStore + attach。区别只是「谁来调 attach、谁来订阅」。
HTML: createPlayer() → ProviderMixin → store.attach() ─┐
├→ Store → Feature.attach() → 媒体事件 → set()
React: createPlayer() → Provider(useEffect) → store.attach() ─┘
│
┌─────────────────────────────┘
▼
PlayerController / useStore 订阅 → UI 元素 update() → Core.getState()
下面分七步走完整条链。
第 1 步:createPlayer() 组装播放器
先看 HTML 入口。我打开 packages/html/src/player/create-player.ts:83-109:
export function createPlayer(config: CreatePlayerConfig<AnyPlayerFeature[]>) {
const slice = combine<PlayerTarget, AnyPlayerFeature[]>(...config.features); // L84
function create(): PlayerStore {
return createStore<PlayerTarget>()(slice); // L87
}
const ProviderMixin = createProviderMixin<PlayerStore>({ ... factory: create }); // L90-95
const ContainerMixin = createContainerMixin<PlayerStore>({ ... }); // L97-100
return { context: playerContext, create, PlayerController, ProviderMixin, ContainerMixin }; // L102-108
}
这里有个反直觉的点:此刻还没创建 store。createPlayer 只干了三件事:
- 把传入的 features 用
combine()合并成一个大 slice(L84)。 - 定义一个
create()闭包,内部调createStore<PlayerTarget>()(slice)(L87)——注意这是柯里化工厂(两层函数)。 - 把这个
create闭包作为factory交给ProviderMixin(L90-95)。
返回的 ProviderMixin 是个混入——用户会把它混进自己的媒体元素类。
React 入口(create-player.tsx:72-118)结构对偶:返回 { Provider, Container, usePlayer, useMedia }。store 的创建推迟到 Provider 组件首次渲染(见第 3 步)。
第 2 步:Store 工厂——为什么是柯里化
我打开 packages/store/src/core/store.ts:13。createStore<Target>() 返回一个内层工厂,闭包里存着:
// store.ts:21-25(闭包状态)
let target: Target | null = null;
let destroyed = false;
const setupAbort = new AbortController(); // L24 — store 生命周期信号
const signals = new AbortControllerRegistry(); // L25 — attach 作用域信号
内层工厂接收 slice,做两件事:
-
构建初始状态(L35-45):调
slice.state(ctx),其中ctx暴露target()(带校验的惰性 getter)、signals注册表、get/set。结果成为initialState,然后state = createState(initialState)(L45)。每个顶层状态键通过Object.defineProperty定义为 store 的 getter(L66-71)。 -
返回 store 对象(L47-79):含
attach、destroy、subscribe。注意attach是函数声明(hoisted),所以能在return之前可用。
重点:此时 store 已创建,但 target 还是 null——状态存在,但没绑定任何媒体元素。这就是「目标附加式」的核心:状态可以脱离 target 存在。
为什么柯里化:我特意琢磨过这个两层函数。原因是类型变量要分阶段绑定:调用方先 createStore<MediaEl>() 锁定 Target,再传 slice 时让 State 自动推导。如果用一个函数同时收两个泛型,就得手动写出 State,丧失推导能力。零运行时成本,纯类型技巧。
第 3 步:attach(target) —— 连接媒体
HTML:ProviderMixin 触发
打开 packages/html/src/store/provider-mixin.ts。Provider 元素在构造时立即创建 store:
// provider-mixin.ts:38
#store: Store | null = config.factory(); // 即刻调 create()
当元素 connectedCallback(L90-101)触发时,发布三个 ContextProvider(player/media/container),然后调 #tryAttach()(L115-136):
// provider-mixin.ts:115-136(简化)
if (!this.#media) { this.#detachStore(); return; }
const target = { media: this.#media, container: this.#container }; // L124-127
if (hasMediaChanged || hasContainerChanged) {
this.#detachStore(); // L133
this.#detach = store.attach(target); // L134 ← 关键的 attach 调用
}
React:Provider 在 useEffect 触发
create-player.tsx:81-93:
const [store] = useState(() => createStore<PlayerTarget>()(combine(...config.features))); // L74
useEffect(() => {
// L87-89: 处理 React <Activity> 隐藏/重现导致的 store 被销毁
store.attach({ media, container }); // L92 ← attach 调用
return detach;
}, [...]);
attach 内部干了什么
回到 store.ts:81-120:
attach(newTarget) {
if (destroyed) throwDestroyedError(); // L82
signals.reset(); // L85 — abort 上一个 base 信号,清空 keyed 信号
target = newTarget; // L86
const attachContext = { // L89-101
target, signal: signals.base, get, set, reportError, store: { state, subscribe }
};
slice.attach?.(attachContext); // L104 ← 触发所有 feature 的 attach
options.onAttach?.(...); // L109-117
return detach; // L119
}
注意 signal: signals.base(L91)。这个 base 信号会在 signals.reset()(L85)时被 abort。而 AbortControllerRegistry.reset()(abort-controller-registry.ts:23-27)会 abort 旧 base 并建新 base。这就是 detach/重 attach 时自动清理事件监听的机制——后面会看到它有多省心。
第 4 步:combine 扇出 attach 到每个 Feature
attachContext.signal 传给 slice.attach,但这里的 slice 是 combine() 合并的。我打开 packages/store/src/core/combine.ts:31-39:
attach(ctx) {
for (const slice of slices) {
try {
slice.attach?.(ctx); // 每个 feature 共享同一个 attachContext
} catch (e) {
ctx.reportError(e); // 单个 feature 报错不影响其他
}
}
}
所以 store.ts:104 的一次 slice.attach?.(attachContext) 会触发所有 feature 的 attach,且共享同一个 base 信号。这里有个很重要的细节:单个 feature 抛错被 reportError 隔离,不会炸掉整个播放器——播放器具备渐进降级能力。
第 5 步:单个 Feature 如何绑定媒体事件
拿 playbackFeature 当例子。packages/core/src/dom/store/features/playback.ts:32-53:
attach({ target, signal, set }) {
const { media } = target;
// 能力守卫:不支持就静默跳过(哲学二)
if (!isMediaPauseCapable(media) || !isMediaSeekCapable(media) || !isMediaSourceCapable(media)) {
return;
}
const sync = () => set({ // L37-43 — 同步媒体状态到 store
paused: media.paused, ended: media.ended,
started: media.started, waiting: media.waiting,
});
sync(); // L45 — 初始推送
listen(media, 'emptied', sync, { signal }); // L47
listen(media, 'play', sync, { signal }); // L48
listen(media, 'pause', sync, { signal }); // L49
listen(media, 'ended', sync, { signal }); // L50
listen(media, 'playing', sync, { signal }); // L51
listen(media, 'waiting', sync, { signal }); // L52
listen(media, 'seeked', sync, { signal }); // L53
}
这段代码里藏着一个我觉得特别精妙的设计。listen()(utils/src/dom/listen.ts:45-53)把 options 原样转发给 addEventListener。你传了 { signal },浏览器会在信号 abort 时自动移除所有这些监听器。一行清理代码都不用写。
所以用户切媒体源或销毁播放器时:
signals.reset()→ base 信号 abort → 7 个监听器全部自动移除。- 新的 attach 创建新 base 信号,绑定新监听器。
这就是「取消作为一等公民」的收益——v10 把清理工作几乎全委托给了浏览器的 AbortSignal 机制。07 篇会专门拆它。
第 6 步:UI 元素怎么订阅 store
状态会随媒体事件更新了,但 UI 怎么知道?以 <media-play-button> 为例。
packages/html/src/ui/play-button/play-button-element.ts:9-19:
export class PlayButtonElement extends MediaButtonElement<PlayButtonCore> {
protected readonly core = new PlayButtonCore();
protected readonly mediaState = new PlayerController(this, playerContext, selectPlayback); // L14
protected override readonly hotkeyAction = 'togglePaused';
protected activate(state: MediaPlaybackState): void { this.core.toggle(state); }
}
L14 是订阅起点。PlayerController(packages/html/src/player/player-controller.ts:58-73)构造时:
// player-controller.ts:66-70
new ContextConsumer(host, {
context, // playerContext —— ProviderMixin 发布的 store
callback: (ctx) => this.#connect(ctx),
subscribe: true,
});
#connect(L99-103)拿到 store 后,创建 StoreController(host, store, selector)。后者(store/src/html/controllers/store-controller.ts:96-104)再创建 SnapshotController,订阅 store.$state,用 shallowEqual 比较——只有 playback 切片的键变化时才请求重渲染。不会因为别的 feature(比如音量)变化而重渲染播放按钮。
selectPlayback 来自 createSelector(playbackFeature)(core/src/dom/store/selectors.ts:35)。createSelector(store/src/core/selector.ts:29-47)一次性算出切片的键,返回一个从 store state 里 pick 这些键的函数。
第 7 步:状态变化 → 重渲染 → 点击 → 播放
渲染路径
媒体触发 pause 事件 → sync() 调 set({ paused: ... }) → store state 变化 → SnapshotController 检测到 playback 键变化 → 请求 host 重渲染 → MediaButtonElement.update()(media-button-element.ts:131-151):
update(changed) {
const state = this.mediaState.value; // L134 — 读 selected state
if (!media) return; // L138
this.core.setMedia(media); // L140
const attrs = this.core.getState(); // 计算 ARIA + 图标状态
applyStateDataAttrs(this, attrs); // L146-150 — 写到 data-* 属性
}
core.getState()(就是 03 篇那个 PlayButtonCore)根据 paused/ended 算出新的 aria-label(play/pause/replay)。皮肤 CSS 根据 data-* 属性切图标。整个渲染是数据驱动的。
点击路径
用户点按钮 → MediaButtonElement.handleActivate(media-button-element.ts:58-60)→ activate(L17)→ core.toggle(state)(play-button-core.ts:71-79):
async toggle(media) {
if (this.#props.disabled) return;
if (media.paused || media.ended) return media.play(); // 暂停态 → 播放
media.pause(); // 播放态 → 暂停
}
→ media.play() → 媒体触发 play 事件 → 回到第 5 步的 sync() → set({ paused: false }) → 第 7 步渲染路径 → 图标变成 pause。
闭环完成。从点击到图标更新,数据流经:UI → Core → media → Feature 监听器 → store → SnapshotController → UI。单向数据流,没有循环。
销毁路径
最后简述销毁。HTML 的 provider-mixin.ts:108-113:
destroyCallback() {
this.#detachStore(); // 调 detach()
this.#store?.destroy(); // destroy store
this.#store = null;
}
store.destroy()(store.ts:129-134):设 destroyed = true、调 detach()(abort base 信号 → 移除所有监听器、状态重置回初始)、setupAbort.abort()。React 那边 create-player.tsx:79 的 useDestroy(store) 注册了等价清理。
调用链速查表
把全链路的 文件:行号 集中放这,方便对照源码:
| 步骤 | HTML 位置 | React 位置 |
|---|---|---|
createPlayer 实现 | create-player.ts:83 | create-player.tsx:72 |
combine(features) | create-player.ts:84 | create-player.tsx:74 |
createStore()(...) | create-player.ts:87 | create-player.tsx:74 |
| store 创建 | provider-mixin.ts:38 | create-player.tsx:74 |
store.attach(target) | provider-mixin.ts:134 | create-player.tsx:92 |
signals.reset() | store.ts:85 | 同左 |
slice.attach?.(ctx) | store.ts:104 | 同左 |
combine attach 扇出 | combine.ts:31 | 同左 |
| feature attach(playback) | playback.ts:32 | 同左 |
listen() ×7 | playback.ts:47-53 | 同左 |
| UI 订阅 | play-button-element.ts:14 | create-player.tsx:107 |
selectPlayback | selectors.ts:35 | 同左 |
渲染 update() | media-button-element.ts:131 | React 重渲染 |
点击 toggle() | play-button-core.ts:71 | 同左 |
这么设计,图什么
走完这条链,我提炼了几个关键决策:
-
Store 与 target 解耦:状态在 target 存在前就创建,attach 是显式动作。这让播放器可以在「无媒体」状态下存在(比如先渲染 UI 再加载源),也让切源变得干净(detach 重置 + attach 新源)。
-
AbortSignal 作为通用清理机制:从 feature 事件监听到 onSetup 的资源,全靠 signal abort 清理。不用手写清理代码,不用担心内存泄漏。
-
HTML 和 React 在 store 层汇合:两个平台入口的差异被限制在「谁来调 attach、谁来订阅」,store 及以下的逻辑完全共享。这是哲学一(无头 Core)在生命周期层面的体现。
-
Feature 错误隔离:
combine的 try/catch 确保一个 feature 的 attach 失败不影响其他。播放器具备渐进降级能力。
至此,v10 的运作骨架就清晰了。后面各卷都是对这条链上某个环节的深入:卷二拆 store,卷四拆 feature,卷五拆 spf 引擎,卷六七拆平台层。

199

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



