Vue3 watch里的onCleanup到底有啥用?不用行不行?踩过坑才敢说的实用指南
你刚学Vue3 Composition API的时候,是不是刷到过watch里有个叫onCleanup的参数?但翻官方文档,只说它是清理函数,例子用得也挺简单的,要么是取消定时器,要么是取消网络请求,那这玩意儿是不是“可有可无的优化项”?不用的话会不会有什么大问题?我前两个月刚接手公司一个旧Vue3项目,踩了三个大坑才搞明白它的真实地位——这哪里是优化,简直是Composition API在异步场景下的“安全头盔”,不管是小项目还是大项目,涉及异步监听的地方都离不开它。
别小看watch的监听触发逻辑,它和同步代码不一样
在说onCleanup之前,得先补个大家最容易忽略的前提:Vue3的watch到底什么时候触发回调?什么时候算“回调过期”?很多人以为watch和原生addEventListener一样,回调是一个接一个排队执行的,其实不对。
举个最常见的同步例子,你监听一个搜索关键词input的变化,每次输入都打印“搜索xxx”,这时哪怕你一秒敲10个字符,watch回调也会依次触发10次,没有“过期”的概念,因为同步代码执行很快,输入的延迟根本赶不上回调执行的速度,但如果换成异步场景呢?比如输入关键词后要等300ms的防抖,然后发一个耗时2秒的接口请求,这时候就很容易出问题了。
假设你输入“V”,300ms后触发请求;没等接口回来,你又输入了“ue”,变成“Vue”,再300ms后又触发新请求;如果第一个接口因为网络波动,比第二个接口晚了0.5秒才回来,那你页面上显示的搜索结果,是不是就会从“Vue”的结果,突然跳回“V”的?这就是很多新手(包括我当时)踩的第一个大坑:异步回调结果覆盖。
这时候可能有人会说,我加个防抖不行吗?加个取消上一次请求的变量不行吗?别急,咱们先看看原生手动写的代码有多麻烦,再对比用onCleanup有多爽。
不用onCleanup的三个真实踩坑场景,看完你还敢省这几行代码吗
刚才提到的接口结果覆盖只是最基础的,我前两个月踩的那三个坑,一个比一个离谱,甚至差点影响了公司的季度报表展示。
第一个坑:搜索接口结果跳变,花了3天才定位到
接手的那个旧项目是公司的电商数据看板,有个核心功能是“按日期筛选数据”,日期选择器用的是element-plus的DatePicker,支持快捷选择(昨天、近7天),也支持自定义日期范围,之前的开发者用了watch监听日期的ref,每次变化都发接口请求拿图表数据,但没加任何清理逻辑。
上线后的第一个周一,运营同学疯狂找过来:“昨天的近30天数据还好好的,今天怎么一会儿显示上周的,一会儿显示大上周的?”我打开控制台看了一下,发现快捷选今天的请求刚发出去,运营同学又顺手点了近7天,接着又点自定义上个月,结果三个请求依次回来,但因为服务器处理上个月的数据比较慢(上个月有618活动,订单量是平时的10倍),最后回来的是今天的请求,中间回来的反而把图表数据覆盖掉了。
之前的开发者加了loading,但loading只是UI层面的提示,根本管不住异步数据的返回,如果手动解决这个问题,我得在组件里定义一个全局的取消请求的变量(比如lastCancel),每次触发watch回调的时候,先检查lastCancel有没有值,有的话就调用取消函数,然后再发新请求,并且把新请求的取消函数赋值给lastCancel,代码大概长这样:
import { ref, watch } from 'vue'
import axios from 'axios'
const dateRange = ref([])
const chartData = ref(null)
let lastCancel = null // 手动定义的全局取消变量
watch(dateRange, (newVal) => {
// 先取消上一次的请求
if (lastCancel) {
lastCancel()
}
// 发新请求,创建取消令牌
const source = axios.CancelToken.source()
lastCancel = source.cancel
axios.get('/api/chart', {
params: { start: newVal[0], end: newVal[1] },
cancelToken: source.token
}).then(res => {
chartData.value = res.data
}).catch(err => {
// 这里要判断是不是取消请求的错误,不然会报错
if (!axios.isCancel(err)) {
console.error(err)
}
})
})
看起来还行对吧?但你有没有想过,如果这个组件被销毁了,比如运营同学点了其他看板,那lastCancel这个变量还在内存里吗?axios的请求还会继续发送吗?虽然一般服务器会忽略这种没有对应页面的请求,但还是会浪费带宽和服务器资源,更重要的是,如果接口返回的数据是用来修改某个状态或者触发某个事件的,那组件销毁后还修改状态,Vue3虽然不会报错(因为响应式系统已经和组件解绑了?不对,等一下,Vue3的响应式变量如果在组件销毁后还被引用,是不会被垃圾回收的,这就是第二个坑!)
第二个坑:组件销毁后内存泄漏,浏览器越用越卡
还是刚才那个数据看板项目,还有一个“实时刷新订单量”的功能,监听一个刷新间隔的ref(默认30秒,可调整为10秒、60秒),每次间隔变化或者组件初始化的时候(immediate: true),都启动一个定时器,每隔一段时间发一次请求,之前的开发者同样没加清理逻辑,甚至连组件销毁的钩子函数onUnmounted都没写。
我测试的时候发现,连续在这个看板和其他看板之间切换10次,浏览器的内存占用就从200M涨到了500M,再切换几次就直接卡成PPT了,后来用Chrome的DevTools Memory面板测了一下,发现有10个定时器还在后台跑,每个定时器都在发请求,同时也持有了那个刷新间隔的ref,导致ref一直没被垃圾回收。
如果手动解决这个问题,我得同时做两件事:一是在watch回调里取消上一次的定时器,二是在onUnmounted里取消最后一次的定时器,代码大概长这样:
import { ref, watch, onUnmounted } from 'vue'
const refreshInterval = ref(30000)
const orderCount = ref(0)
let lastTimer = null // 手动定义的全局定时器变量
const fetchOrderCount = () => {
// 假设这是一个同步的本地模拟,或者是异步接口
orderCount.value++
}
// immediate: true 组件初始化就触发
watch(refreshInterval, (newVal) => {
// 先取消上一次的定时器
if (lastTimer) {
clearInterval(lastTimer)
}
// 启动新定时器
lastTimer = setInterval(fetchOrderCount, newVal)
}, { immediate: true })
// 组件销毁时取消最后一次的定时器
onUnmounted(() => {
if (lastTimer) {
clearInterval(lastTimer)
}
})
比第一个坑的代码更麻烦了,而且还要手动维护两个钩子(watch和onUnmounted),如果忘了写onUnmounted,内存泄漏的问题还是存在,那有没有一种方法,既能在watch触发新回调之前自动清理上一次的,又能在组件销毁的时候自动清理最后一次的?当然有,就是onCleanup!
第三个坑:WebSocket消息串台,用户收到了别人的消息
这个坑是我同事上周踩的,他们负责的是公司的即时通讯聊天页面,监听当前选中的聊天室ID的ref,每次变化都断开旧的WebSocket连接,建立新的连接,之前的代码也是手动维护的,但是有个bug:如果快速切换聊天室,比如从ID1切到ID2再切到ID3,ID1的WebSocket连接可能还没来得及断开,就已经收到了ID2的消息,ID2的连接还没建立好,就已经收到了ID3的,导致用户的聊天记录串台了。
同事一开始以为是WebSocket的断开延迟问题,后来加了个状态锁(比如isConnecting),但锁的逻辑又写得不对,有时候会出现无法建立新连接的情况,最后还是用onCleanup解决了,而且代码比手动加锁加onUnmounted的简单10倍都不止。
终于轮到onCleanup出场了!它到底是怎么工作的
现在知道不用onCleanup的后果了吧?接下来咱们就好好聊聊它的用法和底层逻辑(不用太底层,讲清楚我们能用到的就行)。
onCleanup的基本用法,一看就会
先看一个最简单的例子,就是刚才的搜索接口结果跳变的问题,用onCleanup改写后的代码:
import { ref, watch } from 'vue'
import axios from 'axios'
const dateRange = ref([])
const chartData = ref(null)
watch(dateRange, async (newVal, oldVal, onCleanup) => {
// 这里的onCleanup是watch回调的第三个参数!
const source = axios.CancelToken.source()
// 注册清理函数
onCleanup(() => {
source.cancel('用户切换了日期范围,取消上一次请求')
})
try {
const res = await axios.get('/api/chart', {
params: { start: newVal[0], end: newVal[1] },
cancelToken: source.token
})
chartData.value = res.data
} catch (err) {
if (!axios.isCancel(err)) {
console.error(err)
}
}
})
哇,是不是简单多了?不用手动定义全局的取消变量了!不用在onUnmounted里单独写清理逻辑了!那为什么呢?
onCleanup的两个触发时机,才是它的核心价值
刚才的改写之所以这么好用,就是因为onCleanup有两个精准的触发时机:
- watch触发下一次回调之前:不管你是一秒敲10个字符,还是快速切换10个聊天室,每次新的watch回调执行前,Vue3都会自动调用上一次回调里注册的onCleanup函数,这就解决了异步结果覆盖、定时器重复启动、WebSocket连接串台的问题。
- 组件销毁时:不管你有没有开启immediate,不管你最后有没有触发新的回调,只要组件被销毁了(比如路由切换、v-if=false),Vue3都会自动调用最后一次watch回调里注册的onCleanup函数,这就彻底解决了内存泄漏的问题!
你看,这两个触发时机是不是刚好覆盖了我们刚才所有的踩坑场景?而且完全不用我们手动维护状态,一切都是Vue3自动帮我们做的。
那有人可能会问,onCleanup可以注册多个吗?当然可以!比如你在一个watch回调里既要发网络请求,又要启动一个临时的定时器,那你可以注册两个onCleanup函数,Vue3会按注册的顺序依次调用它们。
再举个刚才的实时刷新订单量的例子,用onCleanup改写后的代码:
import { ref, watch } from 'vue'
const refreshInterval = ref(30000)
const orderCount = ref(0)
const fetchOrderCount = () => {
orderCount.value++
}
watch(refreshInterval, (newVal, oldVal, onCleanup) => {
const timer = setInterval(fetchOrderCount, newVal)
// 注册清理函数,下一次触发或者组件销毁时都会调用
onCleanup(() => clearInterval(timer))
}, { immediate: true })
是不是比手动加onUnmounted的简单太多了?连那个全局的lastTimer变量都不用定义了!
补充:watchEffect也能用onCleanup吗?
可以!而且用法和watch一模一样,都是作为回调的第三个参数,不过要注意的是,watchEffect的回调是“自动收集依赖”的,只要回调里用到的响应式变量变化了,就会触发回调,同时也会自动调用上一次注册的onCleanup函数。
比如刚才的搜索功能,如果用watchEffect改写的话:
import { ref, watchEffect } from 'vue'
import axios from 'axios'
const dateRange = ref([])
const chartData = ref(null)
watchEffect(async (onCleanup) => {
// 只要dateRange变化,这里就会重新执行
if (!dateRange.value.length) return
const source = axios.CancelToken.source()
onCleanup(() => source.cancel('依赖变化,取消上一次请求'))
try {
const res = await axios.get('/api/chart', {
params: { start: dateRange.value[0], end: dateRange.value[1] },
cancelToken: source.token
})
chartData.value = res.data
} catch (err) {
if (!axios.isCancel(err)) {
console.error(err)
}
}
})
这里连immediate都不用加了,因为watchEffect默认就是组件初始化时执行一次。
三个常见的误区,你是不是也踩过?
虽然onCleanup的用法很简单,但还是有一些新手容易踩的误区,我整理了三个最常见的,大家可以对照一下。
onCleanup只能在异步场景下用
很多人以为onCleanup只能用来清理网络请求、定时器、WebSocket这些异步资源,其实不然,它也可以用来清理同步场景下的“临时副作用”。
比如你有一个需求:当用户选中某个商品的时候,给商品添加一个高亮的class;当用户取消选中或者切换商品的时候,给上一个商品移除高亮的class,这时候你也可以用onCleanup:
import { ref, watch, nextTick } from 'vue'
const selectedProductId = ref(null)
const productList = ref([
{ id: 1, name: 'iPhone 15' },
{ id: 2, name: 'MacBook Pro' },
{ id: 3, name: 'AirPods Pro' }
])
watch(selectedProductId, async (newVal, oldVal, onCleanup) => {
await nextTick() // 等DOM更新完再操作
const newElement = document.querySelector(`.product-${newVal}`)
if (newElement) {
newElement.classList.add('highlight')
// 注册清理函数,移除当前选中商品的高亮
onCleanup(() => {
newElement.classList.remove('highlight')
})
}
})
这里的同步副作用就是给DOM添加class,用onCleanup来清理是不是比手动找oldElement更方便?尤其是当productList是动态变化的时候,oldElement可能已经被销毁了,手动操作还会报错。
onCleanup的清理函数是在回调执行完之后马上调用的
不对不对不对!重要的事情说三遍!onCleanup的清理函数是在下一次回调执行之前或者组件销毁时调用的,不是在当前回调执行完之后马上调用的。
比如刚才的实时刷新订单量的例子,如果清理函数是在当前回调执行完之后马上调用的,那定时器刚启动就被清除了,根本不会执行fetchOrderCount。
这个误区非常重要,大家一定要记住。
watch的回调里不能用async/await,不然onCleanup会失效
这个误区是我之前在某个技术论坛上看到的,说如果watch的回调里用了async/await,那onCleanup要放在await之前,不然会失效,其实这个说法一半对一半不对。
如果onCleanup放在await之后,那当watch触发下一次回调或者组件销毁时,当前的回调可能还在await的阶段(还没执行到onCleanup的代码),这时候Vue3就找不到上一次注册的清理函数,所以清理就会失效,但如果onCleanup放在await之前,那不管回调有没有执行完,只要下一次触发或者组件销毁,Vue3都能找到并调用清理函数。
所以最佳实践是:把onCleanup的注册代码放在watch/watchEffect回调的最前面,或者至少放在所有异步操作(await、setTimeout、Promise.then等)的前面。
比如刚才的搜索接口的例子,我把onCleanup放在了创建取消令牌之后、await之前,这是对的,如果我把onCleanup放在await之后,那就错了:
// ❌ 错误的写法!
watch(dateRange, async (newVal, oldVal, onCleanup) => {
const source = axios.CancelToken.source()
try {
const res = await axios.get('/api/chart', {
params: { start: newVal[0], end: newVal[1] },
cancelToken: source.token
})
chartData.value = res.data
// 这里的onCleanup放在await之后,如果回调还在await阶段就触发下一次,就找不到清理函数
onCleanup(() => source.cancel('错误的时机'))
} catch (err) {
if (!axios.isCancel(err)) {
console.error(err)
}
}
})
onCleanup是异步监听的“标配”,不是“选配”
现在大家应该彻底明白onCleanup的作用了吧?它不是什么“高级特性”,也不是“可有可无的优化项”,而是Vue3 Composition API在处理异步监听场景下的“安全头盔”,不管是小项目还是大项目,只要涉及到异步监听(网络请求、定时器、WebSocket、DOM临时操作等),都应该用它。
最后再给大家总结一下今天的核心内容:
- watch的触发逻辑和同步代码不一样,异步场景下容易出现结果覆盖、内存泄漏、消息串台的问题。
- onCleanup有两个触发时机:下一次watch/watchEffect回调执行之前、组件销毁时。
- onCleanup的最佳实践是:注册代码放在所有异步操作的前面,可以注册多个,watch和watchEffect都能用。
- 不用onCleanup的后果很严重,一定要养成用它的习惯。
如果大家还有什么关于Vue3 watch onCleanup的问题,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



