Code前端首页关于Code前端联系我们

Vue3 watch里的onCleanup到底有啥用?不用行不行?踩过坑才敢说的实用指南

terry 5小时前 阅读数 73 #Vue

你刚学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有两个精准的触发时机:

  1. watch触发下一次回调之前:不管你是一秒敲10个字符,还是快速切换10个聊天室,每次新的watch回调执行前,Vue3都会自动调用上一次回调里注册的onCleanup函数,这就解决了异步结果覆盖、定时器重复启动、WebSocket连接串台的问题。
  2. 组件销毁时:不管你有没有开启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临时操作等),都应该用它。

最后再给大家总结一下今天的核心内容:

  1. watch的触发逻辑和同步代码不一样,异步场景下容易出现结果覆盖、内存泄漏、消息串台的问题。
  2. onCleanup有两个触发时机:下一次watch/watchEffect回调执行之前、组件销毁时。
  3. onCleanup的最佳实践是:注册代码放在所有异步操作的前面,可以注册多个,watch和watchEffect都能用。
  4. 不用onCleanup的后果很严重,一定要养成用它的习惯。

如果大家还有什么关于Vue3 watch onCleanup的问题,欢迎在评论区留言讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门