Effect Disposal(副作用清理)

响应式系统中取消订阅和清理副作用的机制。各框架策略差异显著:Vue 3.5 用链表截断、Solid 用 swap-and-pop 数组操作、React 用闭包 cleanup 函数。是响应式系统最容易出内存泄漏 bug 的环节。

核心概念

当一个响应式 effect 不再需要时(组件卸载、依赖变化、手动销毁),系统必须:

  1. 从所有依赖源的订阅者列表中移除该 effect——否则依赖源变化时仍会通知已失效的 effect(内存泄漏 + 无效计算)
  2. 执行 effect 注册的清理回调——关闭定时器、取消网络请求、移除事件监听等

"什么时候停止通知"比"通知谁"更容易出 bug——这是响应式系统最危险的角落。

各框架的清理策略

Vue 3.5 — 链表截断

Vue 用双向链表(Link 节点)管理依赖关系。每个 Link 连接一个 Dep(依赖源)和一个 Subscriber(effect)。

依赖变化时的清理:effect 重执行时,沿 Link 链表走,访问过的 Link 移到尾部;执行完后截断未移动的节点——位置即存活标记。对依赖稳定的 computed(绝大多数场景),所有 Link 都移到尾部,截断操作为空,实现零清理开销。

组件卸载时的清理:调用 effectScope.stop() 递归停止 scope 内所有 effect,断开所有 Link 节点。onUnmounted 钩子在此阶段触发。

KeepAlive 的特殊路径:缓存组件的 effect 不销毁,而是通过 effectScope.pause() / effectScope.resume() 暂停和恢复响应式追踪——保留订阅关系但不响应变化,恢复时重新激活。

Solid — Owner 树 + swap-and-pop

Solid 的 computation 维护 sources 数组(订阅的 Signal 列表)和 sourceSlots 数组(在每个 Signal 的 observer 列表中的位置索引)。

依赖变化时的清理cleanNode 通过 sourceSlots 精确定位并移除自己——用最后一个元素覆盖被删除位置(swap-and-pop),O(1) 操作。

组件卸载时的清理:Solid 用 Owner 树管理 computation 的生命周期。组件销毁时,owner.dispose() 递归清理所有子级 effect,onCleanup 注册的回调在此触发。

内存泄漏风险:如果 createEffect 在组件外部调用且没有挂在任何 owner 下,订阅关系永远不会被清理。Solid 文档强调这一点但新手常犯。

React — 闭包 cleanup 函数

React 没有响应式订阅图,所以不存在"从订阅者列表移除"的问题。但 useEffect 的 cleanup 函数承担了副作用清理的职责:

js
useEffect(() => {
  const timer = setInterval(tick, 1000);
  return () => clearInterval(timer);  // cleanup
}, []);

cleanup 在组件卸载或 effect 依赖变化时执行。关键区别:cleanup 函数是开发者手写的,而不是框架自动推导的。如果漏写或依赖数组不正确,不会内存泄漏(因为没有订阅图),但会导致逻辑 bug(旧闭包捕获旧值继续执行)。React Compiler 能自动推导 useMemo/useCallback 的依赖,但管不了 cleanup 逻辑——这仍然是开发者的责任。

清理策略的设计哲学对比

框架清理触发自动化程度风险点
Vue 3.5链表截断(依赖变化)+ scope.stop(卸载)全自动KeepAlive 缓存组件的 effect 暂停/恢复边界 case
Solidswap-and-pop(依赖变化)+ owner.dispose(卸载)全自动(在 owner 树内)脱离 owner 树的孤立 effect 泄漏
React开发者手写 cleanup 闭包半自动(调用时机由框架控制,内容由开发者提供)cleanup 漏写、依赖数组不正确

引用本术语的文章