💥写完watchEffect就下班?小心组件半夜给你“暴雷”!

关键词: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.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 发一次搜索,旧请求没回来要取消,切走页面也要取消。

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

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

  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()

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

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

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

写完副作用,先写清理,再写业务。

当这条顺序变成下意识动作,你就再也见不到"幽灵请求"和"内存暴涨"了。

如果本文帮你少踩一个坑,欢迎点赞 / 收藏 / 转给队友。

Happy coding,愿你的组件"死得干净,跑得飞快"。

相关推荐
懒大王、2 小时前
视频元素在富文本编辑器中的光标问题
前端·vue.js
用户0751420429052 小时前
Docker 一键部署 NestJS + MySQL 避坑指北
前端
xianyinsuifeng2 小时前
概念篇:ReactJS + AppSync + DynamoDB 性能优化核心概念
前端·react.js·性能优化·aws
OEC小胖胖2 小时前
SEO 优化:元数据 (Metadata) API 和站点地图 (Sitemap) 生成
前端·javascript·前端框架·html·web·next.js
Bruce-li__2 小时前
前端开发利器:nvm、npm与pnpm全面解析与TypeScript/JavaScript选择指南
javascript·typescript·npm
Dontla2 小时前
npx命令介绍(Node Package Execute)(允许开发者直接执行来自npm注册表的包中的二进制文件,而无需全局安装)临时使用
前端·npm·node.js
张人玉2 小时前
npm和pnpm命令大全
前端·npm·node.js
杨晓风-linda2 小时前
npm玩转技巧
前端·npm·node.js
weixin_456904272 小时前
npm install 时包库找不到报错解决
前端·npm·node.js