六维教程

Vue3 生命周期

组件从”被创建”到”从页面移除”会经历几个阶段,Vue 在关键节点提供了钩子函数,让我们在正确时机做正确的事,这些钩子都以 on 开头。

组件的一生

一个组件的大致旅程:

初始化 → 挂载(mount) → 更新(update,可多次) → 卸载(unmount)

对应组合式 API 的钩子:

钩子 触发时机 典型用法
onBeforeMount DOM 即将挂载,此时还访问不到元素 挂载前的准备
onMounted DOM 已挂载,可正常访问模板元素与数据 首屏拉数据、初始化第三方库
onBeforeUpdate 数据变化后、DOM 更新前 记录更新前置状态
onUpdated DOM 更新完成后 依赖新 DOM 的逻辑
onBeforeUnmount 组件即将卸载(销毁前) 清理前的通知
onUnmounted 组件已卸载,DOM 已移除 清理定时器、取消监听、断开连接

用得最多的是 onMounted 和 onUnmounted,这两个配合几乎是”组件资源管理”的标准姿势。

为什么没有创建阶段的钩子

细心的同学可能发现:上面表格里没有”创建时”的钩子。

因为不需要,<script setup> 的顶层代码本身就运行在组件创建阶段,想”刚创建就做初始化”,直接写在顶层即可。

更新阶段

生命周期表的”更新”阶段(onBeforeUpdate / onUpdated)背后有个坑。

当数据改变的时候,DOM 不会立刻跟着变,Vue 会把多次改动攒到一起、异步刷新。

来看一个例子,想要知道list 列表有多高,box 通过模板引用指到这个列表元素上(模板引用在 Vue3 模板引用 讲过),改完数据立刻读高度,会拿到旧值:

<script setup>
import { ref, useTemplateRef } from 'vue'

const list = ref([1])
const box = useTemplateRef('box')   // 模板引用:指到下面 <div ref="box">

const addItem = () => {
  list.value.push(2)              // ① 改了数据,但 DOM 还没刷新
  console.log(box.value?.offsetHeight)  // ② 立刻读 → 还是旧高度
}
</script>

<template>
  <div ref="box">高度随列表变化:{{ list }}</div>
  <button @click="addItem">加一项</button>
</template>

那改完数据,想立刻读新 DOM怎么办?

用 nextTick,它是 Vue 提供的”等本次更新刷完再继续“的工具。

await nextTick()

使用nextTick等待刷新完成,再读新 DOM:

<script setup>
import { ref, nextTick, useTemplateRef } from 'vue'

const list = ref([1])
const box = useTemplateRef('box')   // 模板引用([上一节课](/122.html)讲过:挂载后才有值)

const addItem = async () => {
  list.value.push(2)                   // ① 改数据:此时 DOM 还是旧高度
  await nextTick()                     // ② 等这次刷新完成(时机由 Vue 定)
  console.log(box.value?.offsetHeight)  // ③ 才执行 → 拿到的就是新高度
}
</script>

<template>
  <div ref="box">高度随列表变化:{{ list }}</div>
  <button @click="addItem">加一项</button>
</template>

await nextTick() 究竟做了什么:

  1. nextTick() 返回一个 Promise,这个 Promise 在”刚才攒下的那次刷新完成之后”才 resolve,它做的事就是”确认这次刷新结束了”。
  2. await 让下一行代码挂起,等上面那次刷新完成再继续。

合起来:await nextTick() 的意思是 “等我这次改的数据在 DOM 上反映出来,再往下走”。

它保证下面代码读到的 DOM 是新的,但对”什么时候刷”不做任何强推,刷新时机由 Vue 决定。

那 await nextTick() 之后的代码又改数据了呢?

这就是容易绕晕的地方,注意一条关键规则:一次 DOM 更新覆盖的是”一段连续的同步代码”里发生的所有改动,而 await 会把这根同步线拦腰切断:

const addItem = async () => {
  list.value.push(2)      // 段 1:同步代码,改动被攒下 → 一次刷新 A
  await nextTick()        // 段 1 到此为止,下面的代码不再属于这段

                          // 段 2(等刷新 A 完成后才执行,是新的同步代码)
  list.value.push(3)      // 又一次改动 → 独立攒下 → 需要一次新的刷新 B
  await nextTick()        // 想读 B 的结果,就得再等一次
}

看出区别了吧,list.push(3) 不是发生在”原来那根同步线”上,它发生在 await 之后的新执行段里。

Vue 只把”同一段同步代码”的改动合并成一次刷新,段 2 的改动攒的是新的一轮 B,和 A 无关。

所以想等 B,必须再次 await nextTick()。

一句话:nextTick 只管调用它之前那段同步代码攒下的更新。

完整例子

计时器必须在卸载时清理,否则组件没了定时器还一直跑,会内存泄漏:

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const seconds = ref(0)
let timer = null

onMounted(() => {
  // 挂载后启动定时器
  timer = setInterval(() => {
    seconds.value++
  }, 1000)
})

onBeforeUnmount(() => {
  // 卸载前清掉,避免泄漏
  clearInterval(timer)
})
</script>

<template>
  <p>已在当前页面停留 {{ seconds }} 秒</p>
</template>

用哪个清理

清理资源(定时器、监听器、WebSocket)用 onBeforeUnmount 或 onUnmounted 均可,两者都在卸载阶段触发,只差”确认 DOM 移除”这一步。

建议用 onBeforeUnmount更常见,因为此时组件实例仍是完整的。

特殊钩子

钩子 时机 何时用到
onActivated/onDeactivated 配合 <KeepAlive> 缓存时,组件被”激活/停用” 缓存页面的恢复/挂起处理(路由进阶详讲)
onErrorCaptured 后代组件抛错被捕获 错误边界、上报
onServerPrefetch SSR 服务端渲染预取数据 只在服务端渲染项目用到

onActivated 这对钩子现在只需知道”存在”,等学到 KeepAlive(动态组件与缓存篇)再回来接上。

注意事项

上面计时器例子已经见过钩子的基本写法,这里补几个写钩子时容易踩的点,都是从实际场景来的:。

可以写多个同阶段钩子

组件挂载后既想拉数据、又想初始化图表,拆成两个 onMounted 完全可以,会按顺序执行,互不干扰:

onMounted(() => { fetchData() })
onMounted(() => { initChart() })

比都堆在一个回调里清晰,这也是”组合式”的初衷。

钩子函数不能随意限制执行时机

钩子函数onXxx(() => {}) 不能放进 if 里、放进意外提前return 的代码后面,或者等 await 之后再写。

因为onXxx 内部会去找”当前正在创建的这个组件”,把你的回调追加到这个组件实例对应的钩子队列里,队列在组件创建时产生,到那刻(挂载/更新/卸载) Vue 会依次执行队列里的回调。

所以执行 onXxx() 这行本身,必须发生在”当前组件正在创建”期间。

放进 if 里等于”可能根本不执行这行”,回调就漏登记了,这样写会被警告:

if (someFlag) {
  onMounted(() => { ... })   // 警告:注册不能放在条件判断里
}

想”满足某个条件才注册”,应该在注册时用条件表达式选回调(哪怕选个空函数),而不是把注册本身藏进 if。

钩子跟着时机走

拉数据就放 onMounted,清理就放 onBeforeUnmount,钩子跟”该干什么的时机”天然绑定。

别为了少写几行代码,把该在 onMounted 做的事硬挪到别处,一挪逻辑就悬空了,排查起来难受。

上一篇
Vue3 模板引用
下一篇
Vue3 组件通信入门