nextTick 实现原理深度解析
nextTick 本质是微任务回调注册器,Vue 把同一帧内的多次数据变更合并为一次 DOM 更新,回调在 DOM 更新后执行。 底层按 Promise.then 大于 MutationObserver 大于 setImmediate 大于 setTimeout 依次降级,面试常考为什么用微任务以及它和事件循环的关系。
一句话概括
nextTick = 微任务回调注册器。Vue 把同一帧内的多次数据变更合并为一次 DOM 更新,你的回调在 DOM 更新后执行。底层选型:Promise.then > MutationObserver > setImmediate > setTimeout,绝大多数浏览器走 Promise 微任务。
核心知识点
1. nextTick 解决什么问题
1
2
3
4
5
6
7
8
9
10
11
12
const count = ref(0);
count.value = 1;
console.log(document.querySelector('#app').textContent); // 还是 "0"!
// DOM 还是旧的——因为 Vue 还没更新它
count.value = 2; // 同一 tick 再改
count.value = 3; // 同一 tick 再改——Vue 只更新一次,显示 3
await nextTick();
console.log(document.querySelector('#app').textContent); // "3" ✅
原因: Vue 不在每次 ref.value = x 后立刻更新 DOM——而是攒齐后在本轮事件循环末尾统一处理。
2. 队列机制——isFlushPending 做门闩
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const queue = [];
let isFlushPending = false;
function nextTick(fn) {
const p = Promise.resolve();
return fn ? p.then(fn) : p; // 有回调注册回调,没回调返回 Promise 给 await 用
}
function queueJob(job) {
if (!queue.includes(job)) queue.push(job); // 去重
if (!isFlushPending) {
isFlushPending = true;
Promise.resolve().then(flushJobs); // 🔑 整个渲染周期只有这一次微任务注册
}
}
function flushJobs() {
isFlushPending = false;
queue.forEach(job => job());
queue.length = 0;
}
两个关键设计: isFlushPending 保证 100 个组件的数据变更只注册一个微任务;queue.includes 去重防止同一组件渲染函数被重复执行。
3. pre / post 双队列——执行顺序
1
2
3
4
5
6
7
8
// Vue 3 内部两个独立队列
const pendingPreFlushCbs = []; // pre 队列
const pendingPostFlushCbs = []; // post 队列
// 一轮 tick 的执行顺序:
// ① 清空 pre 队列(computed 更新、watch flush=pre 的回调)
// ② 执行所有组件的 render 函数(这个也在 pre 队列触发)
// ③ 清空 post 队列(watch flush=post 的回调、nextTick 注册的回调、生命周期钩子)
4. nextTick 两种用法——callback vs await
1
2
3
4
5
6
7
8
// 用法 1:回调(老派)
nextTick(() => {
inputRef.value.focus();
});
// 用法 2:await(推荐——避免回调嵌套)
await nextTick();
inputRef.value.focus(); // DOM 一定就位
5. 为什么是微任务而不是宏任务
1
2
3
4
5
6
// setTimeout(宏任务):本轮渲染完 → 等新一帧 → 才执行你的 callback
// 中间用户渲染引擎可能已经绘制了旧状态 → 用户看到闪烁
// Promise.then(微任务):本轮事件循环末尾执行
// 执行完所有数据变更队列 → 立即在微任务中执行 DOM 更新
// 在这之后浏览器才做 layout + paint → 用户永远看不到中间态
一句话: 微任务保证”数据变 → DOM 更新 → 浏览器绘制”三件事紧咬着执行,中间不会插入用户可见的渲染帧。
其实你每天都在用
- v-for 追加后滚动到底部:
data.push(item); await nextTick(); container.scrollTop = container.scrollHeight - 表单校验后聚焦错误字段:
errors.value.email = '必填'; await nextTick(); formRef.value.email.focus() - onMounted 里拿 ref: 虽然 onMounted 保证组件挂载完毕,但子组件可能还在渲染——用了 nextTick 二次确认
- 单元测试等断言:
wrapper.vm.$nextTick()或await nextTick()等 DOM 更新完再expect - 第三方库初始化:
await nextTick(); const chart = echarts.init(container.value)—— div 必须先在 DOM 里
常见误解(FAQ)
❌ 误区:「nextTick 只在组件内有效」
nextTick是全局 API,import { nextTick } from 'vue'拿进来就能用。它不依赖任何组件实例——内部没有this。❌ 误区:「nextTick 就是
setTimeout(fn, 0)」 Vue 内部有降级链:Promise.then→MutationObserver→setImmediate→setTimeout。现代浏览器几乎全走 Promise(微任务),这比 setTimeout(宏任务)早一个事件循环阶段——微任务在当前渲染帧内执行,宏任务在下个渲染帧。❌ 误区:「多次
await nextTick()会等多次 DOM 更新」data.value = 1; await nextTick(); data.value = 2; await nextTick()会等两次——因为第二个await nextTick()是在第一次更新完之后才注册的,属于两个不同的 tick。❌ 误区:「watch 回调里操作 DOM 不需要 nextTick」 看
flush。flush: 'pre'(默认)在 DOM 更新前执行——此时读到的是旧 DOM,需要再套await nextTick()。flush: 'post'在 DOM 更新后执行——直接操作新 DOM。
一句话总结
nextTick 的本质 = 微任务回调:数据变先记账 → tick 末尾统一结算(DOM 更新)→ 你的回调拿到最新 DOM——把”感知不到的延迟”换成了”不可见的中间态”。