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() 究竟做了什么:
nextTick()返回一个 Promise,这个 Promise 在”刚才攒下的那次刷新完成之后”才 resolve,它做的事就是”确认这次刷新结束了”。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 做的事硬挪到别处,一挪逻辑就悬空了,排查起来难受。