生命周期与 nextTick
React 用 useEffect 处理「挂载后、更新后、卸载前」三类时机。Vue 3 用一组生命周期钩子,语义更细分,而且因为是「组合式」的,可以在 setup 里多次调用、按需组合。
1. 生命周期钩子
<script setup lang="ts">
import { onMounted, onUpdated, onUnmounted } from 'vue';
onMounted(() => {
console.log('组件挂载完成,DOM 已就绪');
});
onUpdated(() => {
console.log('响应式数据变化导致的重新渲染完成');
});
onUnmounted(() => {
console.log('组件即将销毁,清理定时器/监听');
});
</script>完整清单:
| 钩子 | 触发时机 |
|---|---|
onBeforeMount | 组件挂载前(DOM 还没创建) |
onMounted | 挂载完成(DOM 可用,适合发请求、拿 ref、起定时器) |
onBeforeUpdate | 数据变了、重新渲染前 |
onUpdated | 重新渲染完成(DOM 已更新) |
onBeforeUnmount | 组件卸载前 |
onUnmounted | 组件卸载后(清理副作用) |
onActivated / onDeactivated | 被 <KeepAlive> 缓存的组件切回/切走 |
onErrorCaptured | 捕获后代组件的错误(第 15 章) |
1.1 钩子必须在 setup 同步期注册
和 React Hooks「不能在条件/循环里调用」是同源约束,但 Vue 的原因不同:钩子靠一个全局指针 currentInstance 找到「当前正在初始化的组件」,把回调函数挂上去。所以钩子必须在 setup 的同步执行期间调用:
// ❌ 在 setTimeout / .then 里调用 —— 此时 currentInstance 已经是 null
setTimeout(() => {
onMounted(() => {}); // 报错或静默失效
}, 0);2. 父子组件的生命周期顺序
父子嵌套时,顺序是:
- 挂载:父
onBeforeMount→ 子onBeforeMount→ 子onMounted→ 父onMounted - 更新:父
onBeforeUpdate→ 子onBeforeUpdate→ 子onUpdated→ 父onUpdated - 卸载:父
onBeforeUnmount→ 子onBeforeUnmount→ 子onUnmounted→ 父onUnmounted
记忆口诀:挂载时「父先子后」但「子 mounted 先于父 mounted」(因为父要等子都好了才算好);卸载完全反过来。
3. nextTick:等 DOM 更新完
第 4 章说过,状态变更后界面是异步批量更新的。改完状态立刻读 DOM,拿到的是旧值:
<script setup>
import { ref, nextTick } from 'vue';
const show = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
async function open() {
show.value = true;
// ❌ 这里 inputRef.value 还是 null(DOM 还没渲染出输入框)
await nextTick();
// ✅ 现在输入框已挂载,可以聚焦
inputRef.value?.focus();
}
</script>
<template>
<input v-if="show" ref="inputRef" />
</template>nextTick() 返回一个 Promise,在「本次所有响应式更新都 flush 完、DOM 都更新好」之后才 resolve。
4. 调度器:pre / render / post
Vue 的更新不是「改一个就立刻渲染一个」,而是收集成一个异步队列,在同一个微任务里一次性 flush,避免同一事件里改 3 次状态就渲染 3 次。flush 时还分三段:
- pre 回调:
watch默认、onBeforeUpdate—— 在渲染前 - render:真正执行组件的渲染副作用
- post 回调:
onUpdated、flush: 'post'的 watch —— 在 DOM 更新后
队列里的 job 按组件 uid 排序(父先于子)。同一个 job 如果本次被多次入队,只会执行一次(去重)。
下面这个 Playground 亲手实现一个带去重的异步刷新调度器,并展示 pre → render → post 的顺序与父先于子的排序。
let isFlushPending = false;
const queue = new Set<Job>();
const p = Promise.resolve();
interface Job {
id: number;
run: () => void;
}
function queueJob(job: Job): void {
queue.add(job); // Set 去重:同一 job 只进一次
queueFlush();
}
function flushJobs(): void {
isFlushPending = false;
const jobs = Array.from(queue).sort((a, b) => a.id - b.id);
queue.clear();
for (const j of jobs) console.log("pre: render #" + j.id);
for (const j of jobs) {
console.log("render #" + j.id);
j.run();
}
for (const j of jobs) console.log("post: render #" + j.id);
}
function queueFlush(): void {
if (isFlushPending) return;
isFlushPending = true;
p.then(flushJobs);
}
function nextTick(fn?: () => void): Promise<void> {
return p.then(() => {
if (fn) fn();
});
}
const parentJob: Job = { id: 1, run: () => console.log(" [parent] 更新 DOM") };
const childJob: Job = { id: 2, run: () => console.log(" [child] 更新 DOM") };
// 同步连改 3 次状态 -> 同一 job 入队 3 次
queueJob(parentJob);
queueJob(parentJob);
queueJob(parentJob);
queueJob(childJob);
console.log("同步阶段已入队,render 还没发生");
console.log("队列里待渲染 job 数(去重后): " + queue.size);
// nextTick 的回调在「所有渲染 flush 完成后」才执行
let state = 0;
const stateJob: Job = { id: 3, run: () => { state = 100; } };
nextTick(() => {
console.log("--- 微任务后,一次性 flush 完成 ---");
queueJob(stateJob);
console.log("flush 前 state = " + state);
nextTick(() => {
console.log("nextTick 后 state = " + state);
});
});注意两点:队列去重后只有 2 个 job(连改 3 次只渲染 1 次),且 flush 顺序是 pre → render → post 三段、父子按 id 排序(父 #1 先于子 #2)。nextTick 回调在全部渲染完成后执行,此时 state 已经是 100——这正是「await nextTick() 后才能读到更新后 DOM」的底层原理。
父组件 Parent 包子组件 Child,Parent 和 Child 都注册了全部四个钩子(onBeforeMount / onMounted / onBeforeUpdate / onUpdated / onBeforeUnmount / onUnmounted)。请写出下面三种操作的完整打印顺序:
- 首次挂载
- 父组件一个只影响父自己的状态变化(触发更新)
- 父组件卸载
一个需求:弹窗打开后自动 focus 输入框。有人写成:
function open() {
show.value = true;
inputRef.value?.focus(); // 不生效
}说明为什么这样不生效,并给出两种修法(nextTick 与 flush:'post' 的 watch)。
下面代码为什么拿不到组件实例、钩子不生效?
import { onMounted } from 'vue';
setTimeout(() => {
onMounted(() => console.log('挂载了'));
}, 0);小结
- 生命周期钩子(
onMounted等)必须在setup同步期调用,靠全局currentInstance挂载到当前组件 - 父子顺序:挂载「父 before 先、子 mounted 先」,卸载完全相反
- 状态变更是异步批量的,改完立刻读 DOM 拿到旧值;
await nextTick()后才能拿到更新后的 DOM - 调度器分 pre / render / post 三段;队列按组件 uid 排序(父先于子),同一 job 去重只跑一次
- 下一章我们深入响应式原理:Proxy、依赖收集与 effect →