Vue3 组合式函数的内存泄漏排查与预防范式
Vue3 组合式函数的内存泄漏排查与预防范式
一、组合式函数中的资源生命周期
Vue3 的组合式函数(Composables)让逻辑复用变得简洁,但也把资源管理的责任从 Options API 中显式的生命周期钩子转移到了闭包作用域中。一个 Composable 内部可能创建 DOM 事件监听、定时器、WebSocket 连接、IntersectionObserver 实例——这些资源不会因为组件卸载而自动释放,除非你显式地在onUnmounted中清理。
sequenceDiagram participant C as 组件实例 participant M as Composable 函数 participant R as 外部资源 participant D as DOM / Observer C->>M: setup() 调用 Composable M->>R: 创建 WebSocket / 定时器 M->>D: addEventListener / observe C-->>M: 组件卸载 Note over M: 未注册清理逻辑 R-->>M: 连接持续存在(内存泄漏) D-->>M: 事件回调持有效(内存泄漏) Note over C,R,D: 泄漏的资源无法被 GC 回收排查时有两个关键现象:Chrome DevTools Performance Monitor 中 JS Heap 在组件切换后不降反升;Memory 面板的 Heap Snapshot 中 Detached DOM 节点持续累积。
二、四种高频泄漏场景
场景一:事件监听未解绑。在 Composable 中给window、document或某个 DOM 引用添加了事件监听,但未在组件卸载时移除。
场景二:外部数据订阅未取消。使用watch或手动创建的effectScope对全局 store 进行了订阅,Composable 实例销毁后订阅仍然存活。
场景三:异步操作未竞态处理。setTimeout或异步请求的回调在组件卸载后才执行,尝试更新已销毁的响应式状态。
场景四:第三方实例未销毁。echarts.init()、new IntersectionObserver()等第三方库创建的实例未调用对应的销毁方法。
三、预防范式:统一清理模式
推荐的预防范式基于 Vue3 的effectScope,将所有副作用收敛到一个作用域中,组件卸载时一次性释放:
import { ref, watch, onUnmounted, effectScope, EffectScope, } from 'vue'; /** * 使用 effectScope 统一管理 Composable 内的所有副作用 * 组件卸载时自动清理,避免资源泄漏 */ export function useAutoDispose<T>( setupFn: (scope: EffectScope) => T ): T { const scope = effectScope(); const result = scope.run(() => setupFn(scope))!; onUnmounted(() => { scope.stop(); // 释放该 scope 内的所有 watch / computed / 事件监听 }); return result; } // 使用示例:WebSocket Composable export function useWebSocket(url: string) { return useAutoDispose((scope) => { const message = ref<string | null>(null); const status = ref<'connecting' | 'open' | 'closed'>('connecting'); let ws: WebSocket | null = null; function connect() { ws = new WebSocket(url); ws.onopen = () => { status.value = 'open'; }; ws.onmessage = (event) => { message.value = event.data; }; ws.onerror = () => { status.value = 'closed'; }; ws.onclose = () => { status.value = 'closed'; }; } function disconnect() { if (ws) { ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null; ws.close(); ws = null; } } connect(); // scope.stop() 时自动调用此回调 return { message, status, disconnect }; }); }effectScope的核心价值在于它追踪了其作用域内创建的所有watch、computed、watchEffect等响应式副作用。调用scope.stop()会递归停止所有子作用域,释放响应式依赖。
四、排查工具与自动化检测
Chrome DevTools 提供了一套内存泄漏排查工作流:
1. Performance Monitor → 观察 JS Heap 趋势,组件切换后应回落 2. Memory → Heap Snapshot → 两次快照之间对比 Detached 节点增量和闭包引用 3. Performance → Record → 观察 GC 是否正常回收(Major GC 后堆应显著下降)工程化检测可以在单元测试中实现内存泄漏断言。使用vi.runAllTimers()和手动触发 GC 来验证 Composable 的清理行为:
import { describe, it, expect, vi } from 'vitest'; import { useWebSocket } from './useWebSocket'; describe('useWebSocket 内存清理', () => { it('组件卸载后应当释放 WebSocket 连接', () => { // 使用 process 全局对象避免 TS 类型报错 const originalGc = (globalThis as Record<string, unknown>).gc; // 模拟 WebSocket const mockClose = vi.fn(); (globalThis as Record<string, unknown>).WebSocket = vi.fn(() => ({ close: mockClose, addEventListener: vi.fn(), })); // 创建并销毁 Composable(此处省略完整 setup 流程) // …… // 触发强制 GC(需 node --expose-gc 启动) if (typeof (globalThis as Record<string, unknown>).gc === 'function') { ((globalThis as Record<string, unknown>).gc as () => void)(); } expect(mockClose).toHaveBeenCalled(); }); });在生产环境中,可以添加一个简单的指标收集:监控全局setInterval/setTimeout的数量是否在页面切换后回归基线,异常增长直接触发告警。
五、总结
Composable 的内存泄漏本质上是"资源创建的职责与释放的职责分离了"。解决问题的关键在于让这两者重新收敛:要么使用effectScope将创建与销毁绑定在一起,要么在每个 Composable 的内部显式注册onUnmounted清理逻辑。排查优先关注事件监听、外部订阅和第三方实例三种类型的资源,它们是泄漏的最高发区。