一、前言:为什么你总在“看不见”的地方翻车

很多人以为写了 <script setup> 就万事大吉,直到:

  • 测试同学切换 20 次路由后,页面从 60 FPS 掉到 10 FPS;
  • 搜索框快速输入,后台报出 30 条 499,前端却把最早那条结果展示出来;
  • 控制台疯狂报错 Cannot read properties of null (reading 'exposed')

99% 都是副作用没清理导致的“幽灵”在运行。
本文从“原子概念 → Vue 源码级诞生 → 真实踩坑 → 万能清理范式”四层,带你一次性把“副作用”做成标本。


二、副作用到底是什么?

1. 计算机原教旨定义

// 纯函数:零副作用
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()组件模板
计算副作用ComputedRefImplcomputed()
侦听副作用doWatch()watch / watchEffect
手动副作用effect()底层 API,极少直接用

结论:只要被 Vue 的响应式系统“双向绑定”起来的函数,都是副作用;
它活着一天,就能被 trigger 重新执行,也就能占内存、发请求、改 DOM


三、一个副作用在 Vue 内部的“出生证明”

以我们最常用的 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.tsdoWatch

  1. 把你的 fn 包成 job
  2. 生成 ReactiveEffect 实例,内部指向 job
  3. 首次 effect.run() → 进入用户函数

③ 依赖收集(track 阶段)

  • 读取 user.name → 触发 track(user, 'get', 'name')
  • 当前 ReactiveEffect 被塞进 user.namedep 集合
  • 同理 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 发一次搜索,旧请求没回来要取消,切走页面也要取消。

<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

// 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 要成对写,热更新才不会重复

场景 3:全局鼠标跟随 —— 跨组件共享且仅最后一次卸载才移除

// 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 让“全局事件”也能享受“引用计数”
  • 适合做“跨组件共享”的副作用,避免“最后一个组件卸载”后事件还在

六、万能清理范式(背下来即可)

  1. 谁打开,谁关闭 —— 任何 setInterval / addEventListener / new XX() 都要在“对称”生命周期里关。
  2. 同步创建watch/watchEffect → Vue 自动 stop异步创建await 之后)→ 手动 stop()
  3. 依赖变化也要清理 → 用 onCleanup(3.4+)或 onWatcherCleanup(3.5+)。
  4. 批量资源 → 塞进 effectScope,一键 scope.stop()
  5. 网络请求 → 一律 AbortController,并在 onCleanup + onUnmounted 双保险 abort()

七、一张图总结(脑图文字版)

副作用生命周期
├─ 创建
│  ├─ 渲染 / computed / watch / watchEffect / 手动 effect
│  └─ 第三方库:addEventListener / setInterval / fetch / WebSocket
├─ 运行
│  ├─ 被 trigger 重新执行
│  └─ 占内存、发请求、改 DOM
└─ 清理
   ├─ 依赖变化 → onCleanup / onWatcherCleanup
   ├─ 组件卸载 → onUnmounted + 自动 stop(同步侦听器)
   └─ 批量清理 → effectScope.stop()

八、结语:把“清理”做成肌肉记忆

如果本文帮你少踩一个坑,欢迎点赞 / 收藏 / 转给队友。
Happy coding,愿你的组件“死得干净,跑得飞快”。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]