smartskip智能跳绳软件
15.27MB · 2025-12-21
很多人以为写了 <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 → doWatchfn 包成 jobReactiveEffect 实例,内部指向 jobeffect.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,愿你的组件“死得干净,跑得飞快”。
15.27MB · 2025-12-21
40.05MB · 2025-12-21
50.79MB · 2025-12-21