追书神器精简版(可进女生区)
60.56MB · 2025-09-26
很多人以为写了 <script setup>
就万事大吉,直到:
Cannot read properties of null (reading 'exposed')
。99% 都是副作用没清理导致的“幽灵”在运行。
本文从“原子概念 → Vue 源码级诞生 → 真实踩坑 → 万能清理范式”四层,带你一次性把“副作用”做成标本。
// 纯函数:零副作用
const add = (a, b) => a + b;
// 有副作用:改外部变量 + I/O
let count = 0;
const addAndPrint = (a, b) => {
count++; // 1. 外部变量被改写
console.log(a + b); // 2. 控制台 I/O
};
类别 | 源码入口 | 你日常写的代码 |
---|---|---|
渲染副作用 | componentEffect() | 组件模板 |
计算副作用 | ComputedRefImpl | computed() |
侦听副作用 | doWatch() | watch / watchEffect |
手动副作用 | effect() | 底层 API,极少直接用 |
结论:只要被 Vue 的响应式系统“双向绑定”起来的函数,都是副作用;
它活着一天,就能被 trigger 重新执行,也就能占内存、发请求、改 DOM。
以我们最常用的 watchEffect
为例,走一遍 Vue3.5 源码级流程:
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()
→ 进入用户函数user.name
→ 触发 track(user, 'get', 'name')
ReactiveEffect
被塞进 user.name
的 dep
集合user.id
也被收集结果:effect ↔ dep
形成双向链表,自此 user
任何属性变化都会 dep.notify()
→ 重新执行 effect。
scope.stop()
会遍历 effects[]
并 effect.stop()
stop()
干两件事dep
里摘除(断链)effect.active = false
,异步回调再进来直接 return
断链 + 置灰 = 真正可 GC + 不再 setState
幽灵 | 触发条件 | 临床表现 |
---|---|---|
① 内存泄漏 | 闭包持有多大数组/DOM | 切换 20 次路由 Heap ×3 |
② 幽灵请求 | 旧请求后返回 | 列表闪跳、数据错乱 |
③ 事件重复 | 热更新未移除 | 一次点击执行 2 次 |
④ 已卸载 setState | 异步回调里 data.value = xxx | 满屏红线 |
⑤ 全局污染 | window.map = new AMap() | 再次进入重复初始化 |
需求:用户每停 200 ms 发一次搜索,旧请求没回来要取消,切走页面也要取消。
<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
是最后保险,防止快速切路由// 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)
}
使用
<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
要成对写,热更新才不会重复// 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()
。副作用生命周期
├─ 创建
│ ├─ 渲染 / computed / watch / watchEffect / 手动 effect
│ └─ 第三方库:addEventListener / setInterval / fetch / WebSocket
├─ 运行
│ ├─ 被 trigger 重新执行
│ └─ 占内存、发请求、改 DOM
└─ 清理
├─ 依赖变化 → onCleanup / onWatcherCleanup
├─ 组件卸载 → onUnmounted + 自动 stop(同步侦听器)
└─ 批量清理 → effectScope.stop()
如果本文帮你少踩一个坑,欢迎点赞 / 收藏 / 转给队友。
Happy coding,愿你的组件“死得干净,跑得飞快”。