关键词:Vue、副作用、内存泄漏、watchEffect、onCleanup、effectScope、AbortController
适合读者:正在用 Vue3 写业务,被"重复请求 / 组件卸载后还在 setState / 切换路由内存暴涨"折磨过的前端工程师
一、前言:为什么你总在"看不见"的地方翻车
很多人以为写了 <script setup>
就万事大吉,直到:
- 测试同学切换 20 次路由后,页面从 60 FPS 掉到 10 FPS;
- 搜索框快速输入,后台报出 30 条 499,前端却把最早那条结果展示出来;
- 控制台疯狂报错
Cannot read properties of null (reading 'exposed')
。
99% 都是副作用没清理 导致的"幽灵"在运行。
本文从"原子概念 → Vue 源码级诞生 → 真实踩坑 → 万能清理范式"四层,带你一次性把"副作用"做成标本。
二、副作用到底是什么?
1. 计算机原教旨定义
任何飞出函数本身的影响,都叫 side effect。
js
// 纯函数:零副作用
const add = (a, b) => a + b;
// 有副作用:改外部变量 + I/O
let count = 0;
const addAndPrint = (a, b) => {
count++; // 1. 外部变量被改写
console.log(a + b); // 2. 控制台 I/O
};
2. 在 Vue 世界里,官方明确把下面 4 类函数称为"ReactiveEffect"
类别 | 源码入口 | 你日常写的代码 |
---|---|---|
渲染副作用 | componentEffect() |
组件模板 |
计算副作用 | ComputedRefImpl |
computed() |
侦听副作用 | doWatch() |
watch / watchEffect |
手动副作用 | effect() |
底层 API,极少直接用 |
结论 :只要被 Vue 的响应式系统"双向绑定 "起来的函数,都是副作用;
它活着一天,就能被 trigger 重新执行,也就能占内存、发请求、改 DOM。
三、一个副作用在 Vue 内部的"出生证明"
以我们最常用的 watchEffect
为例,走一遍 Vue3.5 源码级流程:
① 用户代码
ts
watchEffect(async (onCleanup) => {
document.title = user.name; // 改 DOM
const res = await fetch(`/api/${user.id}`); // 网络
data.value = await res.json();
});
② 进入 runtime-core/src/apiWatch.ts
→ doWatch
- 把你的
fn
包成job
- 生成
ReactiveEffect
实例,内部指向job
- 首次
effect.run()
→ 进入用户函数
③ 依赖收集(track 阶段)
- 读取
user.name
→ 触发track(user, 'get', 'name')
- 当前
ReactiveEffect
被塞进user.name
的dep
集合 - 同理
user.id
也被收集
结果 :effect ↔ dep
形成双向链表 ,自此 user
任何属性变化都会 dep.notify()
→ 重新执行 effect。
④ 组件卸载时
- 同步创建的 effect → 组件
scope.stop()
会遍历effects[]
并effect.stop()
stop()
干两件事
a) 把自身从所有dep
里摘除(断链)
b)effect.active = false
,异步回调再进来直接return
断链 + 置灰 = 真正可 GC + 不再 setState
四、不清理的 5 种"幽灵"现场
幽灵 | 触发条件 | 临床表现 |
---|---|---|
① 内存泄漏 | 闭包持有多大数组/DOM | 切换 20 次路由 Heap ×3 |
② 幽灵请求 | 旧请求后返回 | 列表闪跳、数据错乱 |
③ 事件重复 | 热更新未移除 | 一次点击执行 2 次 |
④ 已卸载 setState | 异步回调里 data.value = xxx |
满屏红线 |
⑤ 全局污染 | window.map = new AMap() |
再次进入重复初始化 |
五、真实开发场景:从"踩坑"到"填坑"
场景 1:搜索框 + 防抖 + 请求取消
需求:用户每停 200 ms 发一次搜索,旧请求没回来要取消,切走页面也要取消。
vue
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'
const kw = ref('')
const list = ref([])
const loading = ref(false)
let ctrl: AbortController | null = null
const search = debounce(async (key: string) => {
ctrl?.abort() // 1. 取消上一次
ctrl = new AbortController()
loading.value = true
try {
const res = await fetch(`/search?q=${key}`, { signal: ctrl.signal })
list.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
} finally {
loading.value = false
}
}, 200)
const stop = watch(kw, v => v && search(v))
onUnmounted(() => {
stop() // 停止侦听器
ctrl?.abort() // 中断最后一次请求
})
</script>
要点
AbortController
放外层,才能跨调用取消onUnmounted
是最后保险,防止快速切路由
场景 2:图表库(ECharts)resize & dispose
ts
// useEcharts.ts
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
export function useEcharts(elRef: Ref<HTMLDivElement | null>) {
let ins: echarts.ECharts | null = null
const resize = () => ins?.resize()
onMounted(() => {
if (!elRef.value) return
ins = echarts.init(elRef.value)
window.addEventListener('resize', resize)
})
onUnmounted(() => {
window.removeEventListener('resize', resize)
ins?.dispose()
})
return (opt: echarts.EChartsOption) => ins?.setOption(opt)
}
使用
vue
<template><div ref="el" class="chart"></div></template>
<script setup>
import { ref } from 'vue'
import { useEcharts } from '@/composables/useEcharts'
const el = ref(null)
const setChart = useEcharts(el)
setChart({ /* option */ })
</script>
要点
- 第三方库的
dispose()
必须自己调,Vue 不会代劳 removeEventListener
要成对写,热更新才不会重复
场景 3:全局鼠标跟随 ------ 跨组件共享且仅最后一次卸载才移除
ts
// useMouse.ts
import { ref, effectScope, onScopeDispose } from 'vue'
let counter = 0
let scope = effectScope()
const pos = ref({ x: 0, y: 0 })
const move = (e: MouseEvent) => { pos.value = { x: e.clientX, y: e.clientY } }
export function useMouse() {
if (counter === 0) {
scope.run(() => window.addEventListener('mousemove', move))
}
counter++
onScopeDispose(() => {
counter--
if (counter === 0) {
window.removeEventListener('mousemove', move)
scope.stop()
scope = effectScope() // 方便下次再用
}
})
return pos
}
要点
effectScope
+onScopeDispose
让"全局事件"也能享受"引用计数"- 适合做"跨组件共享"的副作用,避免"最后一个组件卸载"后事件还在
六、万能清理范式(背下来即可)
- 谁打开,谁关闭 ------ 任何
setInterval
/addEventListener
/new XX()
都要在"对称"生命周期里关。 - 同步创建 的
watch/watchEffect
→ Vue 自动stop
,异步创建 (await
之后)→ 手动stop()
。 - 依赖变化 也要清理 → 用
onCleanup
(3.4+)或onWatcherCleanup
(3.5+)。 - 批量资源 → 塞进
effectScope
,一键scope.stop()
。 - 网络请求 → 一律
AbortController
,并在onCleanup
+onUnmounted
双保险abort()
。
七、一张图总结(脑图文字版)
sql
副作用生命周期
├─ 创建
│ ├─ 渲染 / computed / watch / watchEffect / 手动 effect
│ └─ 第三方库:addEventListener / setInterval / fetch / WebSocket
├─ 运行
│ ├─ 被 trigger 重新执行
│ └─ 占内存、发请求、改 DOM
└─ 清理
├─ 依赖变化 → onCleanup / onWatcherCleanup
├─ 组件卸载 → onUnmounted + 自动 stop(同步侦听器)
└─ 批量清理 → effectScope.stop()
八、结语:把"清理"做成肌肉记忆
写完副作用,先写清理,再写业务。
当这条顺序变成下意识动作,你就再也见不到"幽灵请求"和"内存暴涨"了。
如果本文帮你少踩一个坑,欢迎点赞 / 收藏 / 转给队友。
Happy coding,愿你的组件"死得干净,跑得飞快"。