Composable中的响应式依赖泄露问题
Vue3的组合式函数(Composable)是组织逻辑复用的核心模式,但在实际项目中,Composable的不当使用会引入隐蔽的响应式依赖泄露和内存泄漏。这类问题不会直接报错,而是表现为页面切换后内存持续增长、组件卸载后定时器或监听器仍在运行、以及不必要的重复渲染。
响应式依赖泄露指的是:一个组件引用了某个Composable返回的响应式数据,该组件卸载后,由于Composable内部维护的全局状态或闭包引用,组件的响应式副作用链路并未断开,导致组件对象无法被垃圾回收。
watch和watchEffect未清理导致的内存泄漏
最常见的内存泄漏场景是Composable内部创建了watch或watchEffect但没有在组件卸载时清理。当Composable在多个组件中被复用时,每次调用都会新增一个watcher,但旧的watcher不会被自动清除:
// 错误示范:watcher未清理
export function usePollingData(url) {
const data = ref(null)
watchEffect(async () => {
data.value = await fetch(url)
.then(r => r.json())
})
const timer = setInterval(async () => {
data.value = await fetch(url)
.then(r => r.json())
}, 5000)
return { data }
}
当使用这个Composable的组件被反复挂载和卸载时,每个组件实例都会留下一个无法终止的定时器和watcher。
正确的做法是将清理逻辑交给调用方或利用Vue的生命周期自动清理:
// 正确做法:利用onScopeDispose自动清理
import { onScopeDispose, getCurrentScope } from 'vue'
export function usePollingData(url) {
const data = ref(null)
let timer = null
const stop = watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
try {
data.value = await fetch(url, {
signal: controller.signal
}).then(r => r.json())
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
timer = setInterval(async () => {
data.value = await fetch(url)
.then(r => r.json())
}, 5000)
if (getCurrentScope()) {
onScopeDispose(() => {
stop()
clearInterval(timer)
})
}
return { data }
}
全局响应式状态与组件实例的意外绑定
另一个常见陷阱是Composable内部使用模块级别的全局响应式状态,并且watch的全局副作用间接引用了组件实例:
const globalStore = reactive({
items: [],
selectedId: null
})
// 错误:全局watch捕获了组件闭包
export function useSelectedItem() {
const componentLocalData = ref('local state')
watch(
() => globalStore.selectedId,
(newId) => {
componentLocalData.value = processId(newId)
}
)
return {
selectedItem: computed(() =>
globalStore.items.find(
i => i.id === globalStore.selectedId
)
)
}
}
修正方法:确保watch也注册在组件作用域内,或者返回stop函数让调用方手动清理:
export function useSelectedItem() {
const componentLocalData = ref('local state')
const stopWatch = watch(
() => globalStore.selectedId,
(newId) => {
componentLocalData.value = processId(newId)
}
)
onScopeDispose(stopWatch)
return {
selectedItem: computed(() =>
globalStore.items.find(
i => i.id === globalStore.selectedId
)
),
dispose: stopWatch
}
}
使用Chrome DevTools排查Composable内存泄漏
当怀疑存在Composable内存泄漏时,Chrome DevTools的Memory面板是首选工具:
步骤1:复现泄漏场景。在应用中反复进入和退出使用某个Composable的页面,每次操作后点击DevTools中的Collect garbage按钮。
步骤2:堆快照对比。分别在第1次和第10次页面操作后拍摄堆快照(Heap Snapshot),选择Comparison视图,筛选Delta为正数的对象。
步骤3:识别保留链。找到Delta增长最多的Vue组件对象,查看其Retainers链路。如果看到组件对象被一个全局watch的回调函数闭包引用,就是典型的Composable泄漏。
步骤4:定位具体Composable。在堆快照中搜索组件名,查看其引用链中是否存在来自Composable的watcher、effect或timer引用。
Vue Devtools也提供了组件树和响应式依赖的可视化功能,在Component inspector中选中组件后,切换到Setup面板,可以看到该组件在setup阶段创建的所有响应式副作用。
Composable设计的最佳实践清单
1. 所有watch/watchEffect必须考虑清理。要么使用onScopeDispose自动清理,要么返回stop/dispose函数。
2. 避免在Composable外部注册全局副作用。watch、addEventListener、setInterval等副作用应在Composable内部注册,并在组件作用域销毁时清理。
3. 全局状态只返回computed引用。如果Composable内部使用了全局响应式状态,对外只暴露computed,不要让调用方的组件直接与全局watch建立依赖。
4. 对异步操作使用AbortController。watchEffect的onCleanup回调和AbortController配合,可以安全取消组件卸载时的未完成请求。
5. 使用provide/inject替代模块级全局状态。当Composable需要在组件树中共享状态时,provide/inject可以随组件树的生命周期自动管理。
6. 编写单元测试验证清理行为。使用@vue/test-utils的unmount方法卸载组件后,检查Composable的副作用是否停止。
Composable模式本身没有问题,问题出在响应式副作用的生命周期与组件生命周期不同步。只要遵循注册即清理的原则,绝大多数内存泄漏都可以在开发阶段被发现和修复。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3-zu-he-shi-han-shu-composable-xing-neng-xian-jing-xiang/