这是 element 包的最后一篇,讲
DestroyMixin。它在ReactiveElement之上加了「永久销毁」语义。最值得说的是它那个双重 requestAnimationFrame 的设计------乍看很怪,但理解之后你会觉得这是处理「DOM 重组 / 框架协调」误销毁的妙招。另外它还有个「镜像 Set」的小技巧,解决真私有字段无法枚举的问题。
先说问题:disconnect 不等于销毁
Web Components 有个很坑的特性:元素从 DOM 移除时触发 disconnectedCallback,但这不一定意味着元素真的不要了。
考虑几个场景:
- DOM 重组:父元素把子元素从一个位置挪到另一个位置。过程中子元素会先 disconnect 再 reconnect。
- 框架协调:React/Vue 在 diff 时可能短暂移除再插回元素。
- 条件渲染:元素被「暂时」移除,稍后可能重新挂载。
如果你在 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)明确说明了这个语义。
销毁流程:destroy → destroyCallback
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 里被条件渲染:
- React 渲染
<media-controls>→ connectedCallback →enableUpdating(true)→ 正常工作。 - 用户切到别的页面,React 卸载组件 → disconnectedCallback(L65)→ 触发
hostDisconnected(controller 解订阅,可恢复)→ L67 检查通过 → 排双重 rAF。 - 用户立刻切回来,React 重新挂载 → connectedCallback(L61)。此时第一次 rAF 还没触发。
- 双重 rAF 触发 → L70 检查
this.isConnected→ true(已重连)→ 跳过销毁。✅ 元素幸存。 - 如果用户没切回来 → L70 检查
false→destroy()→ 广播hostDestroyed(controller 释放重资源)。✅ 正常销毁。
对比如果用 disconnectedCallback 直接销毁:步骤 2 就把元素销毁了,步骤 3 重连时元素已经废了。这就是双重 rAF 的价值。
小结
DestroyMixin 代码不长(84 行),但解决了 Web Components 一个很实际的痛点。
带走这几个点:
- 双重 rAF 防误销毁 ------跳过完整一帧再确认
isConnected,DOM 重组/框架协调时元素能幸存。 keep-alive完全禁用自动销毁------用户全权控制。- 镜像 Set 解决真私有字段 ------mixin 靠自己的追踪集广播
hostDestroyed。 hostDestroyedvshostDisconnected------区分「永久销毁」和「临时断连」,controller 该在哪释放重资源。- 两道 guard------销毁后既不更新也不复活。
至此 element 包讲完了。下一篇我们进入 @videojs/store------v10 的响应式状态基石,看看它的微任务批处理是怎么做的(和 element 的批处理是两套,但思想相通)。