为啥用了Vue3 watch还内存泄漏?手把手教你3种安全取消监听的方式!
最近收到好几个前端朋友的私信,说做Vue3后台系统的时候,切换页面偶尔会发现控制台有残留的请求或者数据报错,甚至有的页面越刷加载越慢,排查了半天,最后发现是watch监听没取消干净的锅——组件销毁了,监听逻辑还在后台等着触发,占内存不说,还可能和新页面的逻辑冲突,今天就把这个事儿聊透,从Vue3 watch取消的底层原理讲起,再给你3种实打实用过的安全取消方式,最后补几个容易踩坑的场景,帮你彻底解决这个问题。
先搞懂:Vue3的watch为什么需要手动取消?
很多Vue2转Vue3的朋友可能会疑惑:“Vue2里我用watch好像从来没手动取消过啊,怎么Vue3就突然麻烦了?”其实根本原因是Vue2和Vue3的组件实例销毁逻辑有细微差别,而且Vue3把“取消监听”的控制权完完整整交给了开发者——这是好事,可控性更强了,但也意味着得自己多操点心。
先说说Vue2的情况:Vue2的组件实例(vm)里有个私有的_watchers数组,存了当前组件里所有用vm.$watch或者options API里watch定义的监听函数,当组件执行beforeDestroy生命周期钩子的时候,Vue2会自动遍历这个数组,调用每个watcher的teardown方法,把监听关系全断了,所以不管你是用选项式还是简单的命令式$watch,都不用手动处理。
那Vue3呢?
Composition API的设计理念就是“逻辑组合、按需引入、解耦实例”,所以Vue3里用watch、watchEffect这些API创建的监听,默认不会和组件实例强绑定——除非你是在<script setup>里或者setup函数的同步顶层作用域直接调用的,哦对,这是第一个容易踩的坑!比如你在setTimeout、Promise.then、axios的请求回调里异步调用watch,那这个监听就真的完全“独立”了,组件销毁的时候Vue根本找不到它,自然不会自动取消,内存泄漏就这么来了。
第1种取消方式:利用watch的返回值(最通用,所有场景都能用)
不管是在什么地方调用watch(同步顶层、异步回调、第三方库的钩子函数里),这招都能行,因为它完全不依赖Vue3的自动绑定机制。
先看最基础的用法:
你写const stopWatch = watch(() => state.count, (newVal, oldVal) => { console.log(newVal) }),这里返回的stopWatch就是一个取消监听的函数,直接调用它就行,参数都不用传。
举个例子,异步调用watch的场景怎么取消:
比如你有个需求,只有用户点击了“开启数据监控”按钮之后,才去监听某个后台返回的实时价格数据,而且数据刷新频率是5秒一次,这时候异步启动监听就得存返回值,组件销毁的时候或者用户点击“关闭监控”的时候手动调用。
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import axios from 'axios'
const isMonitoring = ref(false)
const realTimePrice = ref(0)
// 先在顶层声明一个变量存取消函数
let stopPriceWatch = null
// 开启监控
const startMonitor = async () => {
// 先检查有没有正在跑的监控,有的话先取消
if (stopPriceWatch) stopPriceWatch()
isMonitoring.value = true
// 先获取一次初始数据
const res = await axios.get('/api/real-time-price')
realTimePrice.value = res.data.price
// 异步创建的watch,必须存返回值!
stopPriceWatch = watch(realTimePrice, (newVal) => {
// 这里假设每次价格变了要给后台发个埋点
axios.post('/api/monitor-log', { type: 'price-change', value: newVal })
})
}
// 关闭监控
const stopMonitor = () => {
if (stopPriceWatch) {
stopPriceWatch()
// 取消完记得把变量置空,避免重复取消或者误操作
stopPriceWatch = null
}
isMonitoring.value = false
}
// 组件销毁的时候必须手动取消,不管监控有没有开
onUnmounted(() => {
if (stopPriceWatch) stopPriceWatch()
})
</script>
这里有个小细节提醒大家:不管是用户主动关闭还是组件销毁,取消完监听函数之后,最好把存返回值的变量置空——不然下次调用startMonitor的时候,先检查stopPriceWatch还在不在,就可以直接调用之前的(虽然之前的函数已经执行过一次teardown了,Vue3里重复调用stop函数不会报错,但置空更干净,逻辑也更清晰)。
watchEffect的返回值也是一样的用法
很多朋友容易搞混watch和watchEffect的取消方式,其实完全一样——watchEffect也会返回一个stop函数,调用方式没区别。 比如你用watchEffect监听某个网络状态的变化:
const stopNetworkEffect = watchEffect((onInvalidate) => {
const handleOnline = () => { /* 处理上线逻辑 */ }
const handleOffline = () => { /* 处理下线逻辑 */ }
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
// 这里顺便提下onInvalidate,它和stop函数的作用有点像,但触发时机不一样
onInvalidate(() => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
})
})
// 手动取消
stopNetworkEffect()
这里先顺便讲下onInvalidate和返回值stop函数的区别,后面讲具体场景会用到:
onInvalidate是watchEffect内部的一个清理函数,触发时机有两个:一是下次watchEffect重新执行之前,二是调用stop函数取消监听的时候;- 返回值stop函数只有一个触发时机:就是开发者手动调用它的时候,不管是组件销毁还是主动取消。
所以如果你的监听逻辑里有需要清理的副作用(比如上面的addEventListener、setInterval、axios的取消请求),优先用onInvalidate,因为它能覆盖“重新执行清理旧逻辑”的场景;如果只是单纯要取消整个监听,用返回值stop函数就行。
第2种取消方式:利用scope.stop()(适合批量取消,逻辑组合场景超好用)
刚才的第1种方式是单个单个取消,但如果你在写一个可复用的逻辑封装函数(比如VueUse里的那些钩子),里面创建了好几个watch、watchEffect,那一个个存返回值再一个个取消,是不是太麻烦了?
这时候就可以用Vue3暴露出来的getCurrentScope和scope.stop()来批量取消。
先讲下什么是“响应式作用域(Reactive Scope)”:
Vue3里,每个<script setup>或者setup函数的同步顶层作用域,都会自动创建一个响应式作用域,所有在这个作用域里同步创建的watch、watchEffect、computed(computed其实也有响应式依赖,不过它的销毁逻辑更特殊,一般不用手动处理),都会被自动注册到这个作用域里;当组件销毁的时候,Vue3会自动调用这个作用域的stop方法,把里面所有注册的副作用全清理掉——这就是为什么同步顶层调用的watch不用手动取消的原因。
那既然Vue3能自动创建,我们能不能手动创建一个呢?当然可以!用createScope函数就行。
举个可复用逻辑封装的例子:比如你要写一个useDebouncedWatch(防抖监听)的钩子函数,里面可能会有1个主watch,还有一个处理防抖的定时器(不过定时器最好用onInvalidate清理,但主watch可以批量处理):
<script setup>
import { ref, watch, getCurrentScope, createScope } from 'vue'
// 自定义防抖监听钩子
function useDebouncedWatch(source, callback, delay = 500) {
// 先获取当前的父级作用域(也就是setup同步顶层的自动作用域)
const parentScope = getCurrentScope()
// 手动创建一个子响应式作用域
const childScope = createScope()
let timer = null
// 在子作用域里同步运行创建watch的逻辑,这样watch会被注册到子作用域里
childScope.run(() => {
watch(source, (newVal, oldVal) => {
// 先清理之前的定时器(这里用onInvalidate其实也可以,但逻辑更分散)
clearTimeout(timer)
timer = setTimeout(() => {
callback(newVal, oldVal)
}, delay)
})
})
// 父级作用域销毁的时候(也就是组件销毁的时候),自动销毁子作用域
if (parentScope) {
parentScope.onCleanup(() => {
childScope.stop()
// 顺便清理最后一次可能没触发的定时器
clearTimeout(timer)
})
}
// 也可以暴露一个手动取消的函数给外部用
const stop = () => {
childScope.stop()
clearTimeout(timer)
}
return stop
}
// 测试一下
const inputVal = ref('')
const stopDebounced = useDebouncedWatch(
inputVal,
(newVal) => {
console.log('防抖后的输入值:', newVal)
},
800
)
// 比如提交表单之后就不需要监听了,可以手动调用stopDebounced()
</script>
这里的批量处理优势就很明显了:如果这个useDebouncedWatch钩子以后要升级,比如加一个监听防抖前后值对比的辅助watch,那只需要把这个辅助watch也放在childScope.run()里就行,不用再额外存返回值,也不用修改自动销毁和手动取消的逻辑——子作用域一stop,里面所有注册的watch、watchEffect全没了,超级省心。
第3种取消方式:利用组件的自动绑定机制(最偷懒,但仅限同步顶层使用)
刚才前面提过,在<script setup>或者setup函数的同步顶层作用域直接调用的watch、watchEffect,会被自动注册到组件的响应式作用域里,组件销毁的时候自动取消——这就是最偷懒的方式,完全不用写任何额外的代码。
不过重点强调:仅限同步顶层作用域!!! 什么是同步顶层作用域?就是不能在任何异步函数(setTimeout、Promise.then、async/await里的await之后)、条件判断(if、switch)、循环(for、while)、第三方库的回调(比如element-plus的MessageBox的确认回调)里调用watch,否则就不会被自动绑定。
举个反例,这个场景就会内存泄漏:
<script setup>
import { ref, watch, onMounted } from 'vue'
const count = ref(0)
// 错误写法!在async/await里的await之后调用watch,不会自动绑定
onMounted(async () => {
const res = await fetch('/api/init-count')
const data = await res.json()
count.value = data.count
// 这里的watch是异步调用的,不在setup同步顶层,组件销毁后还在
watch(count, (newVal) => {
console.log('新的count值:', newVal)
})
})
</script>
那这个反例怎么改?要么用第1种方式存返回值在onUnmounted里取消,要么把watch的逻辑移到同步顶层,把异步获取初始数据的逻辑分开:
<script setup>
import { ref, watch, onMounted } from 'vue'
const count = ref(0)
// 同步顶层调用watch,自动绑定
watch(count, (newVal) => {
console.log('新的count值:', newVal)
})
onMounted(async () => {
const res = await fetch('/api/init-count')
const data = await res.json()
count.value = data.count
})
</script>
这样就没问题了,同步顶层的watch自动绑定,组件销毁自动取消。
避坑指南:这4个场景最容易忘记取消watch!
刚才讲了3种取消方式,现在再给你列4个前端开发中最容易踩坑、最容易内存泄漏的场景,帮你提前预防:
场景1:在异步回调里调用watch
这个刚才已经举过反例了,比如在setTimeout、Promise.then、axios.get().then()、async/await的await之后调用的watch,都必须手动取消,用第1种或者第2种方式。
场景2:在可复用的第三方库钩子函数里调用watch
比如你用了某个第三方UI库的useDialog钩子,在useDialog的onOpened回调里调用了watch,那这个回调一般是异步触发的(因为对话框打开有动画或者渲染延迟),所以也不会自动绑定到组件的响应式作用域里,必须手动取消。
场景3:在循环里多次创建watch
比如你有个列表,每个列表项都要单独监听一个状态,那如果在循环里创建watch,而且没有取消的话,当列表项被删除的时候,对应的watch还在后台跑——虽然Vue3的列表diff会自动销毁子组件,但如果列表项是用普通的v-for和数据渲染的(没有单独封装成子组件),那循环里创建的watch还是独立的,不会自动取消。 这时候最好的做法是把每个列表项封装成一个单独的子组件,在子组件的同步顶层调用watch,这样子组件销毁的时候自动取消;如果不想封装子组件,就用第1种方式把每个循环创建的stop函数存到一个数组里,当列表项被删除的时候,从数组里取出对应的stop函数调用,然后删除数组里的元素,组件销毁的时候再遍历数组调用所有剩下的stop函数。
场景4:监听了全局的响应式数据(比如Vuex/Pinia的state)
很多朋友可能觉得“监听全局数据没关系吧,反正数据一直在”——不对,如果监听全局数据的组件已经销毁了,而监听逻辑还在,不仅占内存,还可能在全局数据变化的时候触发一些不必要的逻辑(比如给已经销毁的组件的DOM元素赋值,或者发一些没必要的请求),所以不管监听的是局部数据还是全局数据,只要组件销毁了,就应该取消对应的监听(同步顶层调用的全局数据watch会自动取消,不用担心)。
举个监听Pinia全局数据的反例:
<script setup>
import { watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
// 错误写法!在router.beforeEach里调用watch,是异步回调,不会自动绑定
router.beforeEach((to, from, next) => {
// 假设只有登录后才能进入后台页面
if (to.meta.requiresAuth && !userStore.isLoggedIn) {
next('/login')
} else {
next()
// 这里的watch是在router的守卫回调里调用的,不会自动绑定
watch(() => userStore.isLoggedIn, (newVal) => {
if (!newVal) {
router.push('/login')
}
})
}
})
</script>
这个反例不仅会内存泄漏,还会在每次进入后台页面的时候都创建一个新的watch,导致当用户退出登录的时候,会触发无数次router.push('/login')——太可怕了!
怎么改?可以把监听Pinia全局数据的逻辑移到App.vue的同步顶层,因为App.vue一般不会销毁,所以不用取消;如果一定要在当前页面监听,就用第1种方式存返回值在onUnmounted里取消。
什么时候用哪种取消方式?
为了方便大家记忆,我做了一个简单的选择指南:
- 如果是单个、零散的监听,或者在异步/条件/循环里调用的监听:用第1种方式,存返回值在需要的时候(组件销毁、主动关闭)手动调用;
- 如果是批量的监听,或者在写可复用的逻辑封装函数:用第2种方式,创建一个手动的响应式作用域,把所有监听都放在里面,然后批量stop;
- 如果是在
<script setup>或者setup函数的同步顶层调用的监听:用第3种方式,啥都不用写,Vue3自动帮你搞定。
最后再啰嗦一句:内存泄漏这种问题,平时开发的时候可能不会马上发现,但项目上线之后,用户长时间使用(比如后台系统开一天),页面就会越来越卡,甚至崩溃,所以从开发的时候就养成“创建监听就要考虑取消”的好习惯,是非常重要的。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

