videojs v10 源代码系列解读:10 · `DestroyMixin`:双重 rAF 延迟销毁

这是 element 包的最后一篇,讲 DestroyMixin。它在 ReactiveElement 之上加了「永久销毁」语义。最值得说的是它那个双重 requestAnimationFrame 的设计------乍看很怪,但理解之后你会觉得这是处理「DOM 重组 / 框架协调」误销毁的妙招。另外它还有个「镜像 Set」的小技巧,解决真私有字段无法枚举的问题。

先说问题:disconnect 不等于销毁

Web Components 有个很坑的特性:元素从 DOM 移除时触发 disconnectedCallback,但这不一定意味着元素真的不要了

考虑几个场景:

  1. DOM 重组:父元素把子元素从一个位置挪到另一个位置。过程中子元素会先 disconnect 再 reconnect。
  2. 框架协调:React/Vue 在 diff 时可能短暂移除再插回元素。
  3. 条件渲染:元素被「暂时」移除,稍后可能重新挂载。

如果你在 disconnectedCallback 里直接销毁元素(释放资源、解绑事件、调 destroy),那场景 1 和 2 会误销毁------元素明明还要用,却被你干掉了。

v8 时代这个问题很头疼。v10 的 DestroyMixin 给了个干净利落的解法。

hostDestroyed:区分「断连」和「永久销毁」

先看 DestroyMixin 在协议层加了什么。types.ts:58-63 新增了一个 hostDestroyed?() 钩子,JSDoc 写得很清楚:

当宿主被永久销毁时调用。与 hostDisconnected 不同,这表示宿主不会重新连接------应该释放所有资源。

所以 controller 现在有两个不同的 teardown 时机:

  • hostDisconnected------元素从 DOM 移除。可能只是暂时的(会重新连接),不该在这里释放重资源。
  • hostDestroyed------元素永久不要了。在这里释放所有资源。

这个区分非常关键。store 的 StoreController 就利用了它:disconnect 时只解订阅(可恢复),destroy 时才真正释放 store(不可恢复)。

DestroyMixin 的核心:双重 rAF

destroy-mixin.ts:64-74 是最精华的部分:

ts 复制代码
disconnectedCallback(): void {
  super.disconnectedCallback();                             // L65 --- 先触发 hostDisconnected
  if (!this.#destroyed && !this.hasAttribute('keep-alive')) {  // L67
    requestAnimationFrame(() => {                           // L68 --- 第一次 rAF
      requestAnimationFrame(() => {                         // L69 --- 第二次 rAF
        if (!this.isConnected) this.destroy();              // L70
      });
    });
  }
}

我第一次看到双重 rAF 的时候很疑惑:为什么要两次?一次不行吗?

想明白之后,发现这是处理「DOM 重组」的关键:

  • 第一个 rAF :在下一次绘制之前触发。
  • 第二个 rAF :在下一次绘制之后触发。

所以销毁会跳过完整的一帧 。这意味着:如果元素被 DOM 重组(先 disconnect 立刻 reconnect),到 rAF 回调执行时 this.isConnected 已经变回 true,L70 的检查会跳过销毁。误销毁被避免了。

L70 的 if (!this.isConnected) this.destroy()重入保护------只在确实仍然断开时才销毁。

为什么不用 setTimeout(0)

有人可能问:用 setTimeout(() => ..., 0) 不也能延迟吗?区别在于语义。rAF 和浏览器绘制周期对齐,能保证「至少完整一帧」。setTimeout(0) 实际是最小 4ms,且和绘制不对齐,在快速 DOM 重组时可能不够稳。双 rAF 是前端处理这类「下一帧再确认」的标准模式。

keep-alive:完全禁用自动销毁

注意 L67 的第二个条件:!this.hasAttribute('keep-alive')

如果元素有 keep-alive 属性,完全不调度自动销毁 。用户必须手动调 destroy()。这适合你知道元素会被频繁移除/重新插入、不想让它被自动回收的场景。JSDoc(L18-20)明确说明了这个语义。

销毁流程:destroydestroyCallback

destroy-mixin.ts:37-47

ts 复制代码
destroy(): void {
  if (this.#destroyed) return;              // L39 --- 幂等
  this.#destroyed = true;                   // L40
  this.destroyCallback();                   // L41
}

destroyCallback(): void {
  for (const c of this.#trackedControllers) c.hostDestroyed?.();  // L43-47
}

destroy() 幂等(L39),设标志后调 destroyCallback()destroyCallback 默认实现是给所有追踪的 controller 广播 hostDestroyed。子类覆盖 destroyCallback 时应调 super.destroyCallback() 来释放自己的资源。

镜像 Set:解决真私有字段问题

这里有个我读的时候觉得「真讲究」的设计。destroyCallback 要给 controller 广播,但 ReactiveElement#controllers真私有#-names,08 篇提过)------DestroyMixin 作为 mixin 根本访问不到它。

怎么办?DestroyMixin 维护了自己的镜像 Set(destroy-mixin.ts:30-31):

ts 复制代码
#destroyed = false;
#trackedControllers = new Set<ReactiveController>();

然后重写 addController / removeController(L49-57):

ts 复制代码
addController(controller: ReactiveController): void {
  super.addController(controller);              // L50 --- 调基类
  this.#trackedControllers.add(controller);     // L51 --- 镜像
}

removeController(controller: ReactiveController): void {
  super.removeController(controller);           // L55 --- 调基类
  this.#trackedControllers.delete(controller);  // L56 --- 镜像
}

每次 add/remove 都同步更新镜像 Set。这样 destroyCallback(L43-47)就能遍历 #trackedControllers 找到所有 controller 来广播 hostDestroyed

这是 JS mixin 模式的一个典型痛点:基类用真私有字段,mixin 无法直接读。镜像 Set 是个干净解法------虽然多一份内存,但保证 mixin 能拿到完整的 controller 列表。

两道 guard:防止销毁后还干活

DestroyMixin 还加了两个 guard,防止销毁后元素还在更新或重连。

guard performUpdate(L76-79):

ts 复制代码
protected performUpdate(): void {
  if (this.#destroyed) return;    // L77 --- 销毁后不更新
  super.performUpdate();          // L78
}

因为基类对销毁一无所知,所以 mixin 在这里 gate。销毁后,任何挂起的更新(或销毁后 controller/属性触发的 requestUpdate)都成 no-op。

guard connectedCallback(L59-62):

ts 复制代码
connectedCallback(): void {
  if (this.#destroyed) return;    // L60 --- 销毁后不再启用
  super.connectedCallback();      // L61
}

已销毁的元素即使被重新插入 DOM,也不会重新启用或重新广播 hostConnected。注意这和 rAF 的重连保护(L70,阻止销毁发生)是两回事------那个是「还没销毁时防止误销毁」,这个是「已经销毁后防止复活」。

一个完整场景串一遍

假设播放器控件在 React 里被条件渲染:

  1. React 渲染 <media-controls> → connectedCallback → enableUpdating(true) → 正常工作。
  2. 用户切到别的页面,React 卸载组件 → disconnectedCallback(L65)→ 触发 hostDisconnected(controller 解订阅,可恢复)→ L67 检查通过 → 排双重 rAF。
  3. 用户立刻切回来,React 重新挂载 → connectedCallback(L61)。此时第一次 rAF 还没触发
  4. 双重 rAF 触发 → L70 检查 this.isConnectedtrue(已重连)→ 跳过销毁。✅ 元素幸存。
  5. 如果用户没切回来 → L70 检查 falsedestroy() → 广播 hostDestroyed(controller 释放重资源)。✅ 正常销毁。

对比如果用 disconnectedCallback 直接销毁:步骤 2 就把元素销毁了,步骤 3 重连时元素已经废了。这就是双重 rAF 的价值。

小结

DestroyMixin 代码不长(84 行),但解决了 Web Components 一个很实际的痛点。

带走这几个点:

  1. 双重 rAF 防误销毁 ------跳过完整一帧再确认 isConnected,DOM 重组/框架协调时元素能幸存。
  2. keep-alive 完全禁用自动销毁------用户全权控制。
  3. 镜像 Set 解决真私有字段 ------mixin 靠自己的追踪集广播 hostDestroyed
  4. hostDestroyed vs hostDisconnected------区分「永久销毁」和「临时断连」,controller 该在哪释放重资源。
  5. 两道 guard------销毁后既不更新也不复活。

至此 element 包讲完了。下一篇我们进入 @videojs/store------v10 的响应式状态基石,看看它的微任务批处理是怎么做的(和 element 的批处理是两套,但思想相通)。

相关推荐
计算机魔术师1 小时前
英伟达是人工智能领域的"中央银行"
前端
我的div丢了肿么办1 小时前
顶部区域固定,左侧区域滚动,右侧区域滚动,彼此独立
前端·css
雪芽蓝域zzs1 小时前
第四十六节:顶部【驾驶舱】独立大屏页面实现
前端·javascript·vue.js
IMPYLH1 小时前
HTML 的 <select> 元素
前端·html
爱丶不疚2 小时前
Electron:Module 与 Service 的职责边界与加载时序编排
前端·electron·nestjs
三十而立洋2 小时前
深入浅出 Nginx:从核心原理到实战指南
前端·前端工程化
右耳朵猫AI2 小时前
Node.js周刊2026W37 | 三处进程崩溃修复、fs 内置 glob、Workers 模块注册表、Vitest 5.0
javascript·后端·node.js
giszhc2 小时前
腾讯地图瓦片接入方案:一个 Bun 代理,让 Mapbox / Leaflet / OpenLayers 通用
前端·后端
摸鱼仙人~2 小时前
Vue 应用完整启动链路
前端·vue.js·软件工程