videojs v10 系列解读:04 · 从入口到渲染:一次播放的生命周期

这篇我们来追一次完整的播放:从调用 createPlayer(),到用户点播放按钮、媒体开始播放、UI 更新图标。我会沿着真实调用链一步步走,每一步都标上 文件:行号,你可以对照源码读。读完这篇,v10 怎么运作的就不再神秘了。

先看全景:两个入口,殊途同归到 Store

v10 有两个平台入口(HTML 和 React),但它们在某一层之后汇合——都进 @videojs/storecreateStore + 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
}

这里有个反直觉的点:此刻还没创建 storecreatePlayer 只干了三件事:

  1. 把传入的 features 用 combine() 合并成一个大 slice(L84)。
  2. 定义一个 create() 闭包,内部调 createStore<PlayerTarget>()(slice)(L87)——注意这是柯里化工厂(两层函数)。
  3. 把这个 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:13createStore<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,做两件事:

  1. 构建初始状态(L35-45):调 slice.state(ctx),其中 ctx 暴露 target()(带校验的惰性 getter)、signals 注册表、get/set。结果成为 initialState,然后 state = createState(initialState)(L45)。每个顶层状态键通过 Object.defineProperty 定义为 store 的 getter(L66-71)。

  2. 返回 store 对象(L47-79):含 attachdestroysubscribe。注意 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 是订阅起点。PlayerControllerpackages/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)。createSelectorstore/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.handleActivatemedia-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:79useDestroy(store) 注册了等价清理。

调用链速查表

把全链路的 文件:行号 集中放这,方便对照源码:

步骤HTML 位置React 位置
createPlayer 实现create-player.ts:83create-player.tsx:72
combine(features)create-player.ts:84create-player.tsx:74
createStore()(...)create-player.ts:87create-player.tsx:74
store 创建provider-mixin.ts:38create-player.tsx:74
store.attach(target)provider-mixin.ts:134create-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() ×7playback.ts:47-53同左
UI 订阅play-button-element.ts:14create-player.tsx:107
selectPlaybackselectors.ts:35同左
渲染 update()media-button-element.ts:131React 重渲染
点击 toggle()play-button-core.ts:71同左

这么设计,图什么

走完这条链,我提炼了几个关键决策:

  1. Store 与 target 解耦:状态在 target 存在前就创建,attach 是显式动作。这让播放器可以在「无媒体」状态下存在(比如先渲染 UI 再加载源),也让切源变得干净(detach 重置 + attach 新源)。

  2. AbortSignal 作为通用清理机制:从 feature 事件监听到 onSetup 的资源,全靠 signal abort 清理。不用手写清理代码,不用担心内存泄漏。

  3. HTML 和 React 在 store 层汇合:两个平台入口的差异被限制在「谁来调 attach、谁来订阅」,store 及以下的逻辑完全共享。这是哲学一(无头 Core)在生命周期层面的体现。

  4. Feature 错误隔离combine 的 try/catch 确保一个 feature 的 attach 失败不影响其他。播放器具备渐进降级能力。

至此,v10 的运作骨架就清晰了。后面各卷都是对这条链上某个环节的深入:卷二拆 store,卷四拆 feature,卷五拆 spf 引擎,卷六七拆平台层。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值