这篇讲 v10 整个清理机制的基石——AbortSignal。我读 v10 最大的震撼之一,就是它把「取消」当成了系统的一等公民。从 feature 的事件监听、到 store 的 attach 生命周期、到 SPF 的异步任务,全靠 AbortSignal 统一清理。这一切的起点,是 utils 里的两个小函数。
为什么「取消」这么重要
先说背景。播放器有大量异步操作:事件监听、fetch 分段、MediaSource 操作、seek 动作。这些操作随时可能需要取消——用户切了媒体源、销毁了播放器、或者新的 seek 抢占了旧的 seek。
传统做法是手动管理:每个操作记一个 id,取消时遍历清理。这种代码很容易漏、很容易写出内存泄漏。v10 的做法是:把所有「可能需要取消的操作」都绑到一个 AbortSignal 上,abort 一下全清。浏览器原生支持,零成本。
这个机制有两层:utils 提供基础的 signal 组合,store 在此之上建了 AbortControllerRegistry。我们先看 utils 这层。
anyAbortSignal:把多个信号合成一个
最常见的需求是:「这几个信号里任意一个 abort,合成信号就 abort」。events/abort.ts:6-25 的 anyAbortSignal:
export function anyAbortSignal(signals: readonly AbortSignal[]): AbortSignal {
if ('any' in AbortSignal) { // L7 — 特性检测
return AbortSignal.any(signals); // L8 — 原生可用就用
}
// 手动 fallback(老浏览器)
const controller = new AbortController(); // L11
for (const signal of signals) {
if (signal.aborted) { // L14-17 — 已 abort 立即短路
controller.abort(signal.reason);
return controller.signal;
}
signal.addEventListener('abort', () => controller.abort(signal.reason), {
signal: controller.signal, // L20 — 关键!
});
}
return controller.signal;
}
现代浏览器有 AbortSignal.any,直接用(L7-8)。老浏览器(Chromium ≤115)走手动 fallback(L11-24)。
这个 fallback 里藏着一个我觉得非常精妙的设计。看 L20:每个输入 signal 的 abort 监听器,自身用 { signal: controller.signal } 注册。这是什么意思?
意思是:一旦合成信号(controller.signal)被 abort,浏览器会自动移除挂在各个输入 signal 上的监听器。不用手动清理,零泄漏。
这是「生命周期委托给原生 API」模式(05 篇讲过)的又一次体现。如果不用这个技巧,你就会有一堆孤儿监听器挂在那些长寿的输入 signal 上——经典内存泄漏。v10 一个 { signal: controller.signal } 就解决了。
还有个细节:L14-17 遍历时发现某个 signal 已 aborted,立即 controller.abort(signal.reason) 并返回(短路)。不会傻等。
abortable:让 Promise 也能被取消
Promise 本身没有 cancel 概念——这是 JS 异步编程的老大难。events/abort.ts:31-47 的 abortable 给你一个「能被 signal 取消的 Promise」:
export function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason); // L32-34 — 快速失败
let onAbort: () => void; // L36 — 外层 let
return Promise.race([
promise,
new Promise<never>((_, reject) => { // L38 — 永不 resolve
onAbort = () => reject(signal.reason); // L40
signal.addEventListener('abort', onAbort, { once: true }); // L41
}),
]).finally(() => { // L44-46
signal.removeEventListener('abort', onAbort); // 清理监听器
});
}
核心思路是 Promise.race:让原始 promise 和一个「abort rejecter」赛跑。signal abort 时 rejecter 赢,整个 race 就 reject。
几个我读时特别注意的点:
- L32-34 快速失败:signal 已 abort 时直接返回 rejected promise,不挂任何监听器。
- L36 外层
let onAbort:在 rejecter executor 内赋值(L40),finally 闭包再读取(L45)。这保证无论哪个先 settle,监听器都被清掉。 new Promise<never>(L38):类型标注表明这个 promise 永远不 resolve,只会 reject。.finally清理(L44-46):无论 race 结果如何,都removeEventListener。这是防泄漏的关键。
这个函数在 SPF 里大量使用——fetch 一个分段时,abortable(fetchPromise, signal) 让分段请求可以被取消。
store 在此之上建的 AbortControllerRegistry
utils 的这两个函数是基础积木。store 在上面建了一套更结构化的取消注册表:AbortControllerRegistry(store/src/core/abort-controller-registry.ts,37 行)。它提供三招,对应三种取消场景:
第一招:base —— attach 作用域信号
// abort-controller-registry.ts:6, 10-12
#base = new AbortController();
get base(): AbortSignal { return this.#base.signal; }
这个 base 就是 AttachContext.signal 传给 slice.attach 的那个信号(04 篇讲过,store.ts:91)。它在 detach 或 re-attach 时 abort。所有 feature 的 listen(media, ..., { signal }) 都用它——abort 一下,所有监听器自动清。
第二招:reset() —— 全量重置(attach/detach 时调)
// abort-controller-registry.ts:23-27
reset(): void {
this.clear(); // abort 所有 keyed 信号
this.#base.abort(); // abort 旧 base
this.#base = new AbortController(); // 建新 base
}
store.ts:85(attach 时)和 store.ts:124(detach 时)都调它。重新 attach 时,旧 base abort,之前 attach 设置的所有监听器全清,然后建新 base 给新 attach 用。这就是 04 篇说的「detach/重 attach 自动清理」的实现。
第三招:supersede(key) —— 抢占式取消
这是最精妙的一招。看 abort-controller-registry.ts:30-35:
supersede(key: SignalKey): AbortSignal {
this.#keys.get(key)?.abort(); // L31 — abort 该 key 的旧信号
const controller = new AbortController(); // L32
this.#keys.set(key, controller); // L33 — 替换
return anyAbortSignal([this.#base.signal, controller.signal]); // L34
}
它解决什么问题?「替换」模式——新的请求要取消之前同类型的进行中请求。
经典场景是 seek 抢占 seek:用户拖进度条到 t=10s,触发了异步 seek;还没完成,用户又拖到 t=30s。你希望第二次 seek 调用 signals.supersede('seek')——L31 会 abort 第一次 seek 的控制器,第二次 seek 拿到新信号。
返回的信号(L34)是 anyAbortSignal([base, controller])——既会在该 key 被再次 supersede 时 abort,也会在 base abort 时(即 detach)abort。所以用户在 seek 进行中销毁播放器,seek 也会被取消。
#keys 是个以 key 为键的 Map,Map 语义天然保证「每个逻辑操作一个信号,最新的赢」——同一个 key 替换两次,旧控制器被 abort 后丢弃,无泄漏。
clear() —— 批量取消但不动 attach 生命周期
// abort-controller-registry.ts:15-20
clear(): void {
for (const controller of this.#keys.values()) controller.abort();
this.#keys.clear();
}
abort 所有 keyed 信号,但让 #base 不变。用例(slice.ts:39 的 JSDoc):「加载新源会取消挂起的 seek」——你想 kill 掉进行中的操作,但不想破坏 attach 生命周期。
三招怎么配合
把三招放一起看,就理解了 v10 的取消模型:
| 招式 | 谁调 | 什么时候 | 取消范围 |
|---|---|---|---|
base | feature 的 listen 直接用 | 贯穿整个 attach 生命周期 | detach/reattach 时自动清 |
reset() | store 的 attach/detach | 每次连/断媒体 | 全部(base + keyed) |
supersede(key) | feature 内部的异步操作 | 每次发起可抢占操作 | 该 key 的上一个操作 |
clear() | feature 的特定场景(如换源) | 批量取消挂起操作 | 所有 keyed,不动 base |
这套设计的优雅之处在于:feature 作者几乎不用写清理代码。在 attach 里 listen(media, ..., { signal }),signal 是 base——detach 自动清。发起 seek 时 const signal = signals.supersede('seek'),下一次 seek 自动取消上一次。一切都靠浏览器原生的 AbortSignal 机制。
对比一下 v8:v8 的 tech/组件清理是手动 dispose()、手动解绑事件,漏一个就泄漏。v10 把这件事彻底委托给了 AbortSignal,代码量骤减,可靠性大增。
一个完整的例子
假设你写一个 feature,它要监听媒体事件,还要在用户 seek 时发 fetch:
attach({ target, signal, set }) {
const { media } = target;
// 事件监听:用 base signal,detach 自动清
listen(media, 'timeupdate', () => set({ currentTime: media.currentTime }), { signal });
// seek 动作:每次新 seek 抢占旧 seek
const seek = async (time: number) => {
const seekSignal = signals.supersede('seek'); // ← 抢占上一个 seek
const res = await abortable(fetch(url, { signal: seekSignal }), seekSignal);
// ...
};
}
- 用户切源 → store detach →
reset()→ base abort → timeupdate 监听自动移除。 - 用户连点两次 seek → 第二次
supersede('seek')abort 第一次的 fetch。 - 用户在 seek 进行中销毁播放器 →
reset()→ base abort →anyAbortSignal([base, seek])跟着 abort → fetch 取消。
全程零手动清理代码。这就是「取消作为一等公民」的威力。
小结
这篇是 v10 清理机制的总纲。后面读 store(11、12 篇)、SPF 的 Task/Runner(29、30 篇)时,你会发现它们都建立在这两个 utils 函数和 AbortControllerRegistry 之上。
带走这几个点:
anyAbortSignal的 fallback 用{ signal: controller.signal }自清理——防泄漏的关键技巧。abortable用Promise.race+.finally清理——让 Promise 可取消的标准模式。AbortControllerRegistry三招:base(生命周期)、supersede(抢占)、clear(批量)。- feature 作者几乎不写清理代码——全靠 signal 委托给浏览器。
下一篇我们离开 utils,进入 @videojs/element——那个故意做小的 Lit 子集,看它的响应式属性和更新调度。

364

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



