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

为啥用了Vue3 watch还内存泄漏?手把手教你3种安全取消监听的方式!

terry 2小时前 阅读数 35 #Vue
文章标签 Vue3 watch内存泄漏

最近收到好几个前端朋友的私信,说做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里用watchwatchEffect这些API创建的监听,默认不会和组件实例强绑定——除非你是在<script setup>里或者setup函数的同步顶层作用域直接调用的,哦对,这是第一个容易踩的坑!比如你在setTimeoutPromise.thenaxios的请求回调里异步调用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暴露出来的getCurrentScopescope.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

这个刚才已经举过反例了,比如在setTimeoutPromise.thenaxios.get().then()async/await的await之后调用的watch,都必须手动取消,用第1种或者第2种方式。

场景2:在可复用的第三方库钩子函数里调用watch

比如你用了某个第三方UI库的useDialog钩子,在useDialogonOpened回调里调用了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. 如果是单个、零散的监听,或者在异步/条件/循环里调用的监听:用第1种方式,存返回值在需要的时候(组件销毁、主动关闭)手动调用;
  2. 如果是批量的监听,或者在写可复用的逻辑封装函数:用第2种方式,创建一个手动的响应式作用域,把所有监听都放在里面,然后批量stop;
  3. 如果是在<script setup>或者setup函数的同步顶层调用的监听:用第3种方式,啥都不用写,Vue3自动帮你搞定。

最后再啰嗦一句:内存泄漏这种问题,平时开发的时候可能不会马上发现,但项目上线之后,用户长时间使用(比如后台系统开一天),页面就会越来越卡,甚至崩溃,所以从开发的时候就养成“创建监听就要考虑取消”的好习惯,是非常重要的。

版权声明

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

热门