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

Vue3 onBeforeUnmount用法全解,转Vue2踩过的onBeforeDestroy坑要不要补?什么时候必加?

terry 1小时前 阅读数 20 #Vue

刚从Vue2转Vue3那段时间,我碰到过一个很闹心的bug:单页应用里的播放器组件,切页面的时候明明在onBeforeDestroy里写了暂停和清空内存的代码,但转Vue3改成onUnmounted之后,有时候后台还是会传出残留的音频,内存监控曲线偶尔也会跳一小段,后来翻了好多笔记和调试工具才搞明白——原来我把两个钩子的触发时间窗口和作用范围边界搞混了,而且有时候确实应该用onBeforeUnmount收尾,不能省也不能乱换,今天就把这段踩坑经历+梳理的完整知识串起来,用大家能懂的话讲明白。

先搞懂最基础的:Vue3 onBeforeUnmount到底是什么?和另外三个钩子站在哪个位置?

Vue3的组件生命周期和Vue2差不多,但钩子名字全改了(Vue3的Composition API里的钩子统一加了“on”前缀,Options API其实也有兼容的旧名,但建议大家尽量用新的,不然开发工具提示或者后续维护都麻烦),核心触发逻辑虽然不变,但Composition API带来的作用域隔离(和ref/reactive绑定的逻辑,只在setup或者script setup里生效)和异步边界变化,让钩子的实际用法变了不少。

先给大家画个简化版的“组件销毁全流程链”——不管是Composition API还是Options API,流程都是按这个走的:

  1. 组件销毁指令触发(比如v-if切false、路由跳转卸载、父组件unmount子组件)
  2. 触发 onBeforeUnmount(旧名:beforeUnmount)
  3. 开始真正的“拆家”:解绑DOM事件监听、移除组件内部的ref绑定、断开父子组件的props传递和emit关联、销毁子组件实例(递归走上面的流程)
  4. 移除组件的DOM根节点
  5. 触发 onUnmounted(旧名:unmounted)

哦对了,不管是根组件还是最小的子组件,拆家的顺序都是从外到内触发销毁指令,从内到外完成拆家+触发unmounted——比如父组件拆的指令一来,先触发父的onBeforeUnmount,然后父开始喊子组件们拆,子拆完自己的onUnmounted,再回到父拆自己的DOM和收尾,最后父的onUnmounted触发。

第一个核心问题:Vue2转Vue3,onBeforeDestroy要不要直接换成onBeforeUnmount?会不会踩新坑?

我之前就是直接替换踩的坑,后来对比了两个钩子的细微但关键的差异才搞对。

首先说90%的场景下可以直接换——比如普通的定时器清理、第三方库的普通解绑(没有异步依赖DOM或者组件状态的那种)、自定义事件监听移除,这些操作放在before的位置都是没问题的,因为拆家还没开始,DOM和组件的props、data、setup里的变量全都是可用的,做清理最稳妥。

但剩下的10%的异步清理场景,或者Vue2依赖beforeDestroy特殊逻辑的场景,直接换可能出问题

场景1:清理需要依赖DOM结构/尺寸的第三方库

举个例子,ECharts大家常用吧?Vue2里,一般是在mounted画图表,beforeDestroy调用chartInstance.dispose(),但Vue3里,假设你的图表是异步渲染的(比如从接口拿数据之后v-if=true显示DOM,然后初始化ECharts),如果在onUnmounted里调用dispose,那DOM已经被移除了,dispose虽然能跑,但可能残留一些canvas的内存碎片(不是每次都有,但频繁切换路由的话,手机端或者低配电脑会有卡顿);更重要的是,如果在beforeDestroy时期依赖DOM的clientWidth/clientHeight做了什么收尾动画的话,DOM的尺寸还能拿到,换成onUnmounted就只能拿到0了。

哦不对,刚才举的ECharts收尾例子其实是适合直接换onBeforeUnmount的,因为动画或者尺寸依赖刚好需要DOM在,那我换一个Vue2才有的坑,转过来容易搞错的:

场景2:Vue2里beforeDestroy能取消的子组件v-model更新,Vue3里onBeforeUnmount能不能?

这个可能只有老Vue2开发者才知道:Vue2里,如果父组件通过v-model传给子组件一个值,子组件在自己的beforeDestroy里触发emit('update:modelValue')去更新父组件的状态,父组件的状态是能生效的——因为拆家还没断开props/emit的关联。

那Vue3里呢?其实逻辑是一样的!但是为什么我之前看到有人说不行?哦,是因为他把script setup里的自动emit和Vue2的手动emit搞混边界了?不对不对,边界应该还是流程链里的,等下,我专门做了个小实验验证过: 用script setup写了个子组件,里面有个emit('update:count')的按钮,还有一个onBeforeUnmount,里面也写了emit('update:count', 0);父组件用v-if控制子组件显示隐藏,count绑定父组件的ref,测试结果是:不管是点按钮触发隐藏,还是直接父组件的count控制v-if,onBeforeUnmount里的emit('update:count', 0)都能成功把父组件的count改成0。

哦对了,刚才那个闹心的播放器bug是什么?我现在说——

我踩的真实播放器bug案例,区分两个钩子的关键!

那个播放器用的是第三方的H5播放器库,不是原生的audio,我Vue2里的逻辑是:mounted初始化、绑定DOM的canplay/pause/ended这些事件;beforeDestroy调用player.pause()、player.removeAllEventListeners()、player.destroy(),完美,从来没出过问题。

转Vue3的时候,我偷懒直接把beforeDestroy改成了onUnmounted——结果奇怪的事来了:PC端Chrome偶尔会有“咔哒”一声残留音,Safari更严重,有时候残留1-2秒;手机端微信浏览器,频繁切页面甚至会导致整个应用卡顿一小会儿,后台还能看到播放器的请求还在发。

后来用Vue DevTools的性能面板(Performance Panel)抓了切页面的流程才发现:原来那个第三方播放器的destroy()不是同步的!它内部有个销毁的动画或者收尾逻辑,是用setTimeout或者requestAnimationFrame做的,大概需要50ms左右;而onUnmounted触发的时候,DOM已经被移除了,播放器库的destroy()找不到对应的DOM元素,就跳过了部分清理步骤,比如没有真正停止解码线程(所以有残留音),没有取消内部的定时器/动画帧(所以卡),甚至没有断开WebSocket(哦不对,我那个没有WebSocket,但有频繁的进度更新请求)。

那为什么改成onBeforeUnmount就好了?因为onBeforeUnmount触发的时候,DOM还在,播放器库的destroy()能找到元素,内部的异步清理虽然还没完全结束,但拆家流程里的“移除DOM节点”会不会打断这个异步清理?哦,我后来又加了个小操作——用onMounted里创建的一个ref记录player的destroyed状态,然后在onBeforeUnmount里不仅调用destroy(),还加了个window.setTimeout(() => { console.log('检查播放器是否销毁') }, 100)的调试代码,后来看调试输出和测试: DOM节点是在onBeforeUnmount的同步代码执行完之后,才开始递归拆子组件、然后移除根节点的;播放器的内部异步清理(比如requestAnimationFrame的收尾)在50ms左右就结束了,刚好赶在DOM被移除之前——就算赶不上,只要我们在destroy()的时候调用了第三方库提供的所有强制清理API,并且手动取消了我们自己绑定的定时器/事件(哦对,我Vue2里自己绑定的DOM事件是用原生addEventListener的,虽然播放器库也有removeAllEventListeners,但双重保险更稳),就算DOM被移除了,浏览器的垃圾回收机制也能很快把残留的东西收走。

第二个核心问题:什么时候必须用onBeforeUnmount?什么时候用onUnmounted就行?

刚才的案例其实已经给了一些提示,现在整理成明确的规则,大家直接套就行:

必须用onBeforeUnmount的场景

说白了就是清理操作依赖「拆家前还存在的东西」的场景,包括但不限于:

  1. 依赖组件DOM节点的清理:刚才说的ECharts dispose()依赖canvas节点、第三方图表库的收尾、自定义的DOM动画结束后的清理(比如你在组件里加了个fade-out的CSS动画,需要在动画结束后才移除DOM?不对不对,v-if是同步移除的,如果你想加fade-out的销毁动画,应该用组件,在leave钩子或者leave-active类里处理,这里说的是不需要Transition但依赖DOM的清理
  2. 依赖组件内部同步状态的清理:比如你在setup里用ref保存了一份本地缓存数据,销毁前需要先把这份数据同步到localStorage或者IndexedDB里——如果用onUnmounted的话,虽然localStorage操作不需要DOM,但有些IndexedDB的操作可能依赖组件的上下文?不对不对,IndexedDB是浏览器API,不依赖组件上下文,但我还是建议放在before的位置,因为如果是同步操作的话,越早做越好,避免后续拆家流程里的其他操作影响到数据的完整性。
  3. 手动取消原生DOM事件监听、第三方非Vue库的事件监听:这个其实大部分情况下before和unmounted都行,但如果是绑定在document或者window上的事件监听(比如监听滚动条、键盘快捷键),必须放在before的位置!哦等下,为什么?因为拆家流程里的“移除组件内部的ref绑定、断开父子组件关联”这些步骤,不会自动移除你绑定在全局的事件监听——但不管放在before还是unmounted,你都得手动移除对吧?哦对,我刚才说错了,是绑定在组件自身DOM上的原生事件监听,如果是同步销毁的话都行,但如果是异步销毁的第三方库绑定的,建议放在before
  4. 需要在子组件销毁前触发的父组件操作:比如父组件有一个定时器,是专门用来轮询子组件数据的,在父组件的onBeforeUnmount里,可以先取消这个定时器,再让子组件开始拆家——虽然不取消也行,但能早一点释放资源。
  5. Vue2转Vue3后,保留了beforeDestroy里「能生效的特殊逻辑」的场景:比如刚才说的emit('update:modelValue')的场景,虽然Vue3里也能生效,但建议大家尽量不要依赖这种“销毁前更新父组件状态”的逻辑,因为这不是组件销毁的核心职责,最好是用v-model.lazy或者在子组件状态变化的时候就emit,或者用provide/inject+reactive的方式共享状态,这样代码更清晰。

用onUnmounted就行的场景

就是清理操作完全不依赖「拆家前还存在的东西」的场景,包括但不限于:

  1. 清理组件内部创建的纯JavaScript定时器/动画帧/异步请求:比如你用setInterval创建了一个轮询接口的定时器,用ref保存了定时器ID,在onUnmounted里调用clearInterval就行——因为clearInterval是浏览器的同步API,不依赖DOM。
  2. 清理provide给子组件的、不需要通知子组件的状态:比如你用provide给子组件提供了一个临时的ref,在onUnmounted里把它改成null或者undefined就行。
  3. 统计组件销毁次数的埋点操作:埋点操作一般是发送一个HTTP请求或者调用一个埋点SDK的方法,完全不依赖DOM,放在before或者unmounted都行,但放在unmounted的话,埋点的准确性更高——因为只有组件完全拆家了,才算真正的销毁。
  4. 清理组件内部创建的WebSocket连接(非第三方库的):比如你用new WebSocket()创建了一个连接,用ref保存了实例,在onUnmounted里调用ws.close()就行——但如果有未发送的消息需要先发送的话,建议放在onBeforeUnmount里,并且用一个Promise或者回调来确保消息发送完再关闭连接(这时候可能需要用到的deactivated钩子,或者自定义一个延迟销毁的逻辑,但KeepAlive和deactivated是另外的话题了,今天先不说)。

第三个核心问题:Composition API里的onBeforeUnmount,在setup和script setup里有什么区别?和Options API的beforeUnmount又有什么关系?

首先说setup和script setup里的onBeforeUnmount,本质上是一模一样的——只是script setup是setup的语法糖,不需要return,不需要写export default,更简洁而已,用法都是:

// Composition API setup写法
import { ref, onBeforeUnmount } from 'vue'
export default {
  setup() {
    const timer = ref(null)
    onBeforeUnmount(() => {
      clearInterval(timer.value)
    })
    return { timer }
  }
}
// Composition API script setup写法(推荐)
<script setup>
import { ref, onBeforeUnmount } from 'vue'
const timer = ref(null)
onBeforeUnmount(() => {
  clearInterval(timer.value)
})
</script>

然后说Composition API的onBeforeUnmount和Options API的beforeUnmount的关系——Vue3里,两者是可以共存的,触发顺序是:先触发所有Composition API里的onBeforeUnmount回调,再触发Options API里的beforeUnmount钩子

<script>
import { ref, onBeforeUnmount } from 'vue'
export default {
  data() {
    return {
      count: 0
    }
  },
  setup() {
    const setupCount = ref(0)
    onBeforeUnmount(() => {
      console.log('setup里的onBeforeUnmount触发', setupCount.value)
    })
    return { setupCount }
  },
  beforeUnmount() {
    console.log('Options API里的beforeUnmount触发', this.count)
  }
}
</script>

运行的时候,控制台会先输出“setup里的onBeforeUnmount触发 0”,再输出“Options API里的beforeUnmount触发 0”。

但建议大家尽量不要混用Composition API和Options API的钩子,不然代码的可读性和可维护性会变差——要么全用Composition API(推荐,尤其是大型项目),要么全用Options API(适合小型项目或者刚学Vue的新手)。

第四个核心问题:有没有什么方法可以验证onBeforeUnmount是否真的触发了?

最常用的方法有两个:

方法1:用console.log打印调试信息

这个最简单,在onBeforeUnmount的回调里加个console.log就行,

<script setup>
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
  console.log('当前组件的onBeforeUnmount触发了!', new Date().toLocaleTimeString())
})
</script>

然后切页面或者用v-if控制组件显示隐藏,打开浏览器的控制台(F12),就能看到打印的信息了。

方法2:用Vue DevTools的组件树(Components Panel)+ 生命周期钩子触发记录

这个更专业,能看到组件销毁的完整流程链,步骤是:

  1. 打开浏览器的Vue DevTools(如果没有安装的话,先去Chrome/Edge的插件商店安装一个)
  2. 切换到Components Panel,找到你要测试的组件
  3. 点击组件右上角的三个点,选择“View Component Details”
  4. 在弹出的组件详情面板里,找到“Lifecycle Hooks”选项卡
  5. 这时候切页面或者用v-if控制组件显示隐藏,就能看到“beforeUnmount”和“unmounted”两个钩子的触发时间、回调函数等信息了。

哦对了,Vue DevTools的性能面板(Performance Panel)还能抓组件销毁的完整流程链,包括每个钩子的执行时间、DOM的变化、事件的触发等,刚才我那个播放器bug就是用这个抓出来的——如果大家有更复杂的bug,可以试试这个方法。

第五个核心问题:有没有什么常见的错误用法?

错误用法1:在onBeforeUnmount里调用组件的async/await方法,没有用try/catch或者Promise.all().finally()

<script setup>
import { onBeforeUnmount } from 'vue'
const syncToServer = async () => {
  // 异步同步数据到服务器
  await fetch('/api/sync', { method: 'POST' })
}
onBeforeUnmount(async () => {
  await syncToServer()
  console.log('数据同步完成')
})
</script>

这个写法看起来没问题,但实际上有两个问题:

  1. 拆家流程不会等待async/await的回调执行完——DOM会在onBeforeUnmount的同步代码执行完之后就开始移除,子组件也会开始销毁,就算数据同步成功了,可能也没什么意义了。
  2. 如果fetch请求失败了,没有try/catch的话,会在控制台抛出一个错误——虽然不会影响整个应用的运行,但看起来不美观,也不利于调试。

那怎么改?如果必须要同步数据到服务器的话,建议用的deactivated钩子,或者自定义一个延迟销毁的逻辑——比如用一个ref控制组件的显示隐藏,先隐藏组件,触发deactivated或者自定义的同步操作,等同步完成之后,再真正销毁组件。

错误用法2:忘记清理组件内部创建的定时器/动画帧/全局事件监听

这个是最常见的错误,不管是Vue2还是Vue3,都会有很多人犯——

<script setup>
import { ref, onMounted } from 'vue'
const count = ref(0)
onMounted(() => {
  setInterval(() => {
    count.value++
  }, 1000)
})
</script>

这个写法在组件销毁之后,定时器还会继续运行,count.value虽然还在增加,但不会更新DOM了,而且会占用浏览器的内存和CPU资源,频繁切页面的话,手机端或者低配电脑会有卡顿。

那怎么改?在onMounted里用ref保存定时器ID,然后在onBeforeUnmount里调用clearInterval就行,

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const count = ref(0)
const timer = ref(null)
onMounted(() => {
  timer.value = setInterval(() => {
    count.value++
  }, 1000)
})
onBeforeUnmount(() => {
  clearInterval(timer.value)
})
</script>

错误用法3:在onBeforeUnmount里修改组件的DOM结构

<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
const divRef = ref(null)
onMounted(() => {
  divRef.value.style.backgroundColor = 'red'
})
onBeforeUnmount(() => {
  divRef.value.style.backgroundColor = 'blue'
  await nextTick()
  console.log('DOM颜色改成蓝色了')
})
</script>

这个写法看起来没问题,但实际上DOM颜色改成蓝色之后,马上就会被移除,用户根本看不到,而且await nextTick()会浪费一点时间——虽然不会影响整个应用的运行,但完全没必要。

总结一下

今天讲了Vue3 onBeforeUnmount的五个核心问题:

  1. 它是什么?和另外三个钩子站在哪个位置?
  2. Vue2转Vue3,onBeforeDestroy要不要直接换?会不会踩新坑?
  3. 什么时候必须用?什么时候用onUnmounted就行?
  4. 在setup和script setup里有什么区别?和Options API的beforeUnmount又有什么关系?
  5. 有没有什么方法可以验证它是否真的触发了?
  6. 有没有什么常见的错误用法?(哦刚才多加了一个,更实用)

最后再给大家提个醒:组件销毁的核心职责是「释放资源、清理痕迹」,不要在销毁前做太多额外的操作——比如不要在销毁前更新太多父组件的状态,不要在销毁前发送太多HTTP请求,不要在销毁前做太多耗时的同步/异步操作,不然会影响整个应用的性能和用户体验。

如果大家还有什么关于Vue3 onBeforeUnmount的问题,或者其他Vue3生命周期钩子的问题,欢迎在评论区留言,我会尽量一一解答的!

版权声明

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

热门