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

Vue3的钩子函数到底怎么用?和Vue2比改了啥?小白到进阶避坑全讲

terry 4小时前 阅读数 44 #Vue

Vue3钩子函数的核心逻辑:你真的理解组合式API里的“钩子触发时机”吗?

之前接触过Vue2的朋友肯定记得“beforeCreate、created、beforeMount、mounted”这一套生命钩子,都是在options API的对象里写的,每个组件只能放一份对应逻辑,但到了Vue3组合式API里,钩子的名字大多加了“on”前缀,比如onMounted、onUnmounted,还能重复调用多个相同的钩子——是不是有点懵?为啥允许重复?触发顺序会不会乱?

得从组合式API的设计初衷切入:逻辑复用和代码组织,在Vue2里,比如你有两个功能都要监听窗口大小变化,就只能把这段代码揉在mounted里一堆if else,或者用mixins,但mixins有命名冲突、来源不明的问题,Vue3把相同的“监听时机点”做成可以多次注册的钩子,每个钩子里写不同功能的逻辑,最后Vue会按注册的先后顺序(也就是代码里写的顺序)依次执行这些回调,举个例子,你在script setup里先写了onMounted(() => {console.log('获取用户数据')}),过几行又写了onMounted(() => {console.log('绑定动画事件')}),最后控制台会先打印获取用户数据,再打印绑定动画,就这么简单。

那组合式API的钩子触发时机和Vue2的options有啥对应关系呢?权威资料里是这么说的:

  • setup()函数本身,替代了beforeCreate和created——因为setup执行的时候,组件实例还没完全准备好,连this都不能用,所以之前在beforeCreate和created里写的逻辑(比如获取静态数据、初始化响应式变量),现在可以直接放在setup的顶层,不用单独套钩子。
  • 其他生命周期钩子,基本就是加“on”前缀:beforeMount → onBeforeMount,mounted → onMounted,beforeUpdate → onBeforeUpdate,updated → onUpdated,beforeUnmount → onBeforeUnmount,unmounted → onUnmounted,errorCaptured → onErrorCaptured,不过要注意,Vue3.0还有两个针对SSR的钩子,叫onServerPrefetch和onRenderTracked/onRenderTriggered,但这两个在客户端开发(也就是普通网页开发)里用得极少,后面进阶部分会简单提一下性能调试的那两个。

小白入门必用的8个组合式钩子:每个都有场景和避坑点

很多刚学Vue3的同学,上来就把所有钩子都背下来,但其实日常开发80%的场景只用这8个,剩下的要么是SSR专用,要么是进阶性能/组件交互时用的。

第一个钩子:啥都不用套的——setup顶层逻辑

刚才说过,setup顶层替代了beforeCreate和created,这里要重点强调避坑点

  1. 不能用this!因为组件实例在setup执行后才创建,this指向的是undefined,别在这写一堆this.xxx,不然控制台会报错“Cannot read properties of undefined”。
  2. 所有顶层声明的变量、函数,如果想在template里用,要么直接在script setup里写(Vue3.2+的script setup自动帮你暴露给template),要么在普通script的setup函数里return出来,别纠结普通script和script setup的区别,新手直接用script setup就行,省了很多return的代码,体验爽太多。

第二个钩子:组件挂载后第一次操作DOM——onMounted

这应该是新手除了setup外用得最多的钩子了,场景特别多:

  • 获取后端数据(虽然setup顶层也能获取异步数据,但有时候后端数据依赖DOM元素的宽度/高度,比如加载瀑布流的时候,就必须等DOM挂载完);
  • 绑定第三方库的实例(比如ECharts图表、Three.js 3D场景,都得在有DOM容器的时候才能初始化);
  • 绑定一些临时的全局事件(比如窗口大小变化、键盘事件,但记得在unmounted的时候解绑,不然会内存泄漏)。

举个绑定ECharts的例子,带避坑点的:

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
// 先定义一个ref,绑定DOM容器
const chartRef = ref(null)
// 定义图表实例的ref,方便后面销毁
let myChart = null
onMounted(() => {
  // 确保chartRef已经绑定到DOM元素了
  if (chartRef.value) {
    myChart = echarts.init(chartRef.value)
    // 随便写个配置项
    myChart.setOption({
      xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] },
      yAxis: { type: 'value' },
      series: [{ type: 'line', data: [120, 200, 150, 80, 70, 110, 130] }]
    })
    // 绑定窗口大小变化事件,自适应图表
    window.addEventListener('resize', handleResize)
  }
})
// 定义窗口大小变化的处理函数
const handleResize = () => {
  if (myChart) {
    myChart.resize()
  }
}
// 组件卸载前销毁图表实例、解绑事件,防止内存泄漏
onUnmounted(() => {
  if (myChart) {
    myChart.dispose()
    myChart = null
  }
  window.removeEventListener('resize', handleResize)
})
</script>
<template>
  <div ref="chartRef" style="width: 100%; height: 400px;"></div>
</template>

这个例子里有三个新手容易踩的坑:

  1. ref绑定DOM的时候,变量名要加Ref后缀吗? 其实不用,官方推荐是加,这样一眼就能看出是绑定DOM的ref,方便维护,你不加也能用。
  2. onMounted里一定要加if判断chartRef.value是否存在吗? 大部分情况下不用,因为Vue保证onMounted触发的时候,DOM已经挂载完了,但有时候在动态组件或者v-if包裹的组件里,可能会出现延迟绑定的情况,加个判断更安全。
  3. 临时全局事件一定要解绑!第三方库实例一定要销毁! 这一点非常重要,尤其是在做大型单页应用(SPA)的时候,组件来回切换,如果不解绑事件、不销毁实例,内存会越来越大,最后导致浏览器卡顿甚至崩溃。

第三个钩子:组件卸载前的“最后一击”——onBeforeUnmount

刚才绑定全局事件的时候用到了onUnmounted,那onBeforeUnmount和onUnmounted有啥区别?触发顺序是什么?

区别在于组件是否还在DOM树里

  • onBeforeUnmount触发的时候,组件实例还存在,DOM树也没被移除,你还可以在这里做一些清理前的收尾工作,比如保存用户的草稿数据、关闭正在加载的提示框;
  • onUnmounted触发的时候,组件实例已经被销毁,DOM树也被移除了,这时候再去操作DOM或者this(哦不对,setup里本来就没有this)就没用了。

新手的话,其实大部分清理工作放在onBeforeUnmount或者onUnmounted里都可以,但推荐放在onBeforeUnmount里,因为万一你需要和组件的最后状态交互,比如获取某个input的value保存起来,这时候还能拿到。

第四个钩子:响应式数据更新,DOM更新前——onBeforeUpdate

这个钩子的触发条件是:响应式数据发生变化,而且触发了视图更新(注意!不是所有响应式数据变化都会触发视图更新,比如你修改了一个不在template里用的ref,就不会触发),场景主要是:

  • 保存DOM更新前的某些状态,比如滚动条的位置(比如你有一个聊天列表,新消息来了会自动滚动到底部,但如果你在看历史消息,就不想让它自动滚,这时候可以在onBeforeUpdate里保存当前的滚动位置,判断是否需要恢复);
  • 做一些DOM更新前的准备工作,比如清空某些临时缓存。

新手在这里踩的坑主要是:别在onBeforeUpdate里修改触发视图更新的响应式数据! 不然会陷入无限循环:修改数据 → 触发onBeforeUpdate → 又修改数据 → 又触发onBeforeUpdate……控制台会报错“Maximum call stack size exceeded”,这时候你就知道自己踩坑了。

第五个钩子:响应式数据更新,DOM更新后——onUpdated

这个钩子的触发条件和onBeforeUpdate一样,但是是在DOM更新之后,场景主要是:

  • 操作更新后的DOM,比如刚才说的聊天列表,新消息来了,DOM更新完之后,再滚动到底部;
  • 调用第三方库的更新方法,比如ECharts的setOption。

同样的,别在onUpdated里修改触发视图更新的响应式数据! 不然也会无限循环,除非你加个判断条件,比如只有当数据达到某个阈值的时候才修改。

第六个钩子:捕获子孙组件的错误——onErrorCaptured

这个钩子在Vue2里也有,加了个on前缀,场景主要是:

  • 做全局错误处理的补充,比如某个特定的业务模块里,子孙组件出错了,你不想让整个应用崩溃,就可以在这个模块的父组件里加onErrorCaptured,捕获错误,显示一个友好的提示框,然后返回true(或者false?后面会讲返回值的作用)。
  • 日志收集,把子孙组件的错误发送到后端,方便排查问题。

返回值的作用很重要,新手一定要记清楚:

  • 返回true:阻止错误继续向上传播(也就是不会被全局的errorHandler捕获);
  • 返回false或者不返回:错误会继续向上传播,直到被全局的errorHandler捕获。

举个简单的例子:

<script setup>
import { ref, onErrorCaptured } from 'vue'
import ChildComponent from './ChildComponent.vue'
const errorInfo = ref('')
onErrorCaptured((err, instance, info) => {
  // err是错误对象,instance是出错的组件实例,info是错误发生的阶段(比如render、event handler)
  console.error('捕获到子孙组件的错误:', err, instance, info)
  errorInfo.value = '子组件出错了,请稍后再试'
  // 阻止错误继续向上传播
  return true
})
</script>
<template>
  <div v-if="errorInfo" style="color: red; padding: 10px;">{{ errorInfo }}</div>
  <ChildComponent />
</template>

这个例子里,如果ChildComponent出错了,会显示红色的提示框,不会让整个应用崩溃。

第七、八个钩子:keep-alive相关的——onActivated和onDeactivated

这两个钩子只有当组件被keep-alive包裹的时候才会触发,新手刚开始可能用不到keep-alive,但等做复杂应用的时候,肯定会用到,比如电商网站的商品列表页,用户点进去商品详情页,再返回来,不想让商品列表页重新加载(包括滚动条位置、搜索条件、页码这些),这时候就需要用keep-alive包裹商品列表页。

触发条件:

  • onActivated:当组件从缓存中激活的时候触发(比如刚才说的,用户从商品详情页返回到商品列表页);
  • onDeactivated:当组件被缓存的时候触发(比如用户从商品列表页点进去商品详情页)。

场景主要是:

  • onActivated:恢复组件的状态,比如播放暂停的视频、继续轮播图;
  • onDeactivated:保存组件的状态,比如暂停视频、暂停轮播图、保存搜索条件到本地存储。

举个保存滚动条位置的例子:

<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
const listContainerRef = ref(null)
const scrollTop = ref(0)
// 组件被缓存的时候保存滚动条位置
onDeactivated(() => {
  if (listContainerRef.value) {
    scrollTop.value = listContainerRef.value.scrollTop
  }
})
// 组件从缓存中激活的时候恢复滚动条位置
onActivated(() => {
  if (listContainerRef.value) {
    listContainerRef.value.scrollTop = scrollTop.value
  }
})
</script>
<template>
  <div ref="listContainerRef" style="height: 500px; overflow-y: auto;">
    <!-- 这里放商品列表 -->
  </div>
</template>

这个例子里,记得把商品列表页放在App.vue或者父组件的keep-alive里,不然onActivated和onDeactivated不会触发:

<!-- App.vue -->
<script setup>
import { keep-alive } from 'vue'
import ProductList from './ProductList.vue'
import ProductDetail from './ProductDetail.vue'
// 这里用VueRouter的router-view包裹,实际开发中肯定是这样的
import { RouterView } from 'vue-router'
</script>
<template>
  <keep-alive>
    <!-- include属性可以指定哪些组件被缓存,这里假设ProductList的name是ProductList -->
    <RouterView v-slot="{ Component }">
      <component :is="Component" />
    </RouterView>
  </keep-alive>
</template>

新手在这里踩的坑主要是:include/exclude属性里的组件名,必须是组件里name选项的值,或者script setup里用defineOptions定义的name值!比如刚才的ProductList.vue,如果是普通script的话,得加export default { name: 'ProductList' },如果是script setup的话,得加defineOptions({ name: 'ProductList' }),不然include/exclude不会生效。

进阶开发者必知的4个冷门但有用的钩子

刚才讲的8个是日常开发用得最多的,接下来讲4个冷门但有用的,适合进阶开发者。

第一个冷门钩子:性能调试专用——onRenderTracked和onRenderTriggered

这两个钩子只有在开发模式下才会触发,生产模式下完全没用,不用加任何判断,生产环境会自动被Tree Shaking掉。

  • onRenderTracked:当响应式数据被追踪的时候触发(也就是第一次访问响应式数据的时候);
  • onRenderTriggered:当响应式数据被触发更新的时候触发(也就是修改响应式数据导致视图更新的时候)。

这两个钩子的参数是一个DebuggerEvent对象,里面包含了key(被追踪/触发的响应式数据的key)、target(被追踪/触发的响应式对象)、type(追踪/触发的类型,比如get、set)等信息,可以帮你排查为什么组件会频繁更新,优化性能。

举个简单的例子:

<script setup>
import { ref, onRenderTracked, onRenderTriggered } from 'vue'
const count = ref(0)
onRenderTracked((e) => {
  console.log('响应式数据被追踪:', e)
})
onRenderTriggered((e) => {
  console.log('响应式数据被触发更新:', e)
})
</script>
<template>
  <div>{{ count }}</div>
  <button @click="count++">+1</button>
</template>

第一次渲染的时候,控制台会打印“响应式数据被追踪”,对象里的target是RefImpl(count的内部实现),key是value,type是get;每次点击按钮,count.value加1,控制台会打印“响应式数据被触发更新”,对象里的target、key、type和追踪的时候差不多,只是type变成了set。

第二个冷门钩子:SSR专用——onServerPrefetch

这个钩子只有在服务端渲染(SSR)的时候才会触发,客户端开发完全没用,如果你没接触过SSR,可以跳过,场景主要是:在服务端渲染组件之前,先获取后端数据,这样服务端返回的HTML里就已经有数据了,用户打开网页的时候就能看到内容,不用等客户端加载完再获取,提升首屏加载速度。

举个简单的例子,用Nuxt3的话,其实可以直接用useAsyncData,不用手动写onServerPrefetch,但如果你是自己搭建SSR框架的话,就需要用到:

<script setup>
import { ref, onServerPrefetch } from 'vue'
// 假设这是一个获取用户数据的API
import { fetchUser } from './api'
const user = ref(null)
// 在服务端渲染前获取用户数据
onServerPrefetch(async () => {
  user.value = await fetchUser()
})
// 客户端渲染的时候也得获取,不然如果用户直接刷新页面(或者从服务端渲染的页面跳转过来?不对,直接刷新的话服务端已经获取了),哦不对,自己搭建SSR框架的话,还需要在客户端的onMounted里判断用户数据是否存在,如果不存在再获取,避免重复请求:
import { onMounted } from 'vue'
onMounted(async () => {
  if (!user.value) {
    user.value = await fetchUser()
  }
})
</script>

第三个冷门钩子:递归组件或异步组件相关的?——onRenderTracked刚才讲过了,还有一个provide/inject的坑?哦不对,provide/inject不是钩子,是API,哦还有一个,Vue3.3+新增的钩子?等下,查一下权威资料,Vue3.3+新增了onWatcherCleanup,这个算不算冷门但有用?对,算!

第三个冷门钩子:Vue3.3+新增的——onWatcherCleanup

这个钩子不是生命钩子,是和watch/watchEffect一起用的“清理钩子”,但因为它很重要,而且很多进阶开发者都不知道,所以放在这里讲。

场景主要是:当watch/watchEffect的回调里有异步操作的时候,比如防抖/节流、发送网络请求,这时候如果在异步操作完成之前,响应式数据又发生了变化,导致watch/watchEffect的回调再次执行,就需要清理上一次的异步操作,不然会出现竞态条件(Race Condition),比如先发送的请求后返回,覆盖了后发送的请求的结果。

举个发送网络请求的竞态条件例子,没有用onWatcherCleanup的情况:

<script setup>
import { ref, watch } from 'vue'
import { fetchProduct } from './api'
const productId = ref(1)
const product = ref(null)
const loading = ref(false)
// 监听productId的变化,发送请求
watch(productId, async (newId) => {
  loading.value = true
  // 假设productId=1的时候,请求需要3秒返回;productId=2的时候,请求需要1秒返回
  const res = await fetchProduct(newId)
  product.value = res.data
  loading.value = false
})
</script>
<template>
  <button @click="productId++">切换商品</button>
  <div v-if="loading">加载中...</div>
  <div v-else>{{ product?.name }}</div>
</template>

这个例子里,如果你快速点击两次按钮,先切换到productId=2(请求1秒返回),再切换到productId=3?不对,假设先切换到productId=2,再快速切换回productId=1,这时候productId=2的请求先返回,product.value变成了商品2的名字,然后productId=1的请求3秒后返回,product.value又变成了商品1的名字,但这时候用户的productId是2,所以显示的内容是错的——这就是竞态条件。

用onWatcherCleanup解决这个问题的方法:

<script setup>
import { ref, watch } from 'vue'
import { fetchProduct } from './api'
const productId = ref(1)
const product = ref(null)
const loading = ref(false)
watch(productId, async (newId, oldId, onCleanup) => {
  // 定义一个变量,标记当前请求是否已经被取消
  let isCanceled = false
  // 注册清理函数,当下一次watch回调执行的时候,会先调用上一次的清理函数
  onCleanup(() => {
    isCanceled = true
  })
  loading.value = true
  const res = await fetchProduct(newId)
  // 只有当请求没有被取消的时候,才更新product.value
  if (!isCanceled) {
    product.value = res.data
    loading.value = false
  }
})
</script>

这个例子里,如果你快速切换productId,上一次的清理函数会把isCanceled设为true,上一次的请求即使返回了,也不会更新product.value,只有最后一次的请求返回了,才会更新,完美解决了竞态条件。

第四个冷门钩子:Vue3.2+新增的——useId

哦不对,useId是API,不是钩子?等下,查一下权威资料,Vue3的官方文档里,把provide/inject、useAttrs、useSlots、useId这些都归为“组合式API:核心功能”,不是生命钩子,那还有什么冷门但有用的生命钩子?哦对了,Vue3还有onErrorCaptured的扩展?比如onErrorCaptured可以捕获异步错误吗?可以,Vue2.6+开始onErrorCaptured就可以捕获异步错误了,加个await就行,哦还有一个,Vue3的defineAsyncComponent里也有钩子,但那是异步组件的配置项,不是组件级别的钩子。

算了,刚才讲的onWatcherCleanup虽然不是生命钩子,但确实很重要,而且很多进阶开发者都不知道,就放在这里吧。

Vue2转Vue3的必避坑点:别再乱套旧钩子!

很多从Vue2转过来的开发者,刚接触Vue3的时候,习惯在组合式API里也写beforeCreate、created这些旧钩子,但其实这些旧钩子在组合式API里是不推荐使用的,虽然Vue3.0-3.2的普通script里还能用,但Vue3.2+的script setup里已经完全不支持了,而且setup顶层已经完全替代了它们,所以别再用了。

还有一个旧钩子是destroyed,在Vue3里改成了unmounted,beforeDestroy改成了beforeUnmount,别写错名字了。

Vue2的mixins在Vue3里虽然还能用,但官方推荐用组合式函数(Composables)替代,因为组合式函数没有命名冲突、来源不明的问题,逻辑复用更清晰。

Vue3钩子函数的使用原则

给大家总结一下Vue3钩子函数的使用原则,方便记忆:

  1. 新手优先用script setup,不用普通script,省了很多return的代码,体验爽;
  2. setup顶层替代beforeCreate和created,所有初始化逻辑直接放在顶层;
  3. 相同触发时机的逻辑,分别写在多个相同的onXxx钩子里,按注册顺序执行,方便代码组织;
  4. 临时全局事件一定要解绑,第三方库实例一定要销毁,防止内存泄漏;
  5. 别在onBeforeUpdate和onUpdated里修改触发视图更新的响应式数据,除非加判断条件,不然会无限循环;
  6. onErrorCaptured可以捕获子孙组件的错误,返回true阻止传播,返回false或不返回继续传播
  7. onActivated和onDeactivated只有被keep-alive包裹的时候才会触发,记得给组件加name选项;
  8. 进阶开发者可以用onRenderTracked/onRenderTriggered排查性能问题,用onWatcherCleanup解决竞态条件

好了,以上就是关于Vue3钩子函数的全部内容,从小白到进阶,从场景到避坑,应该都讲清楚了,如果你还有什么问题,欢迎在评论区留言,我会一一解答。

版权声明

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

热门