文章

nextTick 实现原理深度解析

nextTick 本质是微任务回调注册器,Vue 把同一帧内的多次数据变更合并为一次 DOM 更新,回调在 DOM 更新后执行。 底层按 Promise.then 大于 MutationObserver 大于 setImmediate 大于 setTimeout 依次降级,面试常考为什么用微任务以及它和事件循环的关系。

nextTick 实现原理深度解析

一句话概括

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——把”感知不到的延迟”换成了”不可见的中间态”。

本文由作者按照 CC BY 4.0 进行授权