文章

nextTick 实现原理

nextTick 实现原理

一句话概括

nextTick 是 Vue 的异步更新”守门员”——它将同一事件循环内的多次数据变更合并为一次 DOM 更新,回调在 DOM 更新完成后执行。底层利用微任务(Promise.then)实现。

核心知识点

1. nextTick 解决的问题

1
2
3
4
5
6
7
8
9
10
11
const count = ref(0);

count.value = 1;
console.log(document.querySelector('#app').textContent); // 还是旧的 "0"!

nextTick(() => {
  console.log(document.querySelector('#app').textContent); // "1" ✅
});

count.value = 2;  // 同一 tick 的多次修改
count.value = 3;  // Vue 只会更新一次 DOM,最终显示 3

原因:Vue 不会在每次数据变更后立即更新 DOM,而是将更新任务推入微任务队列,在本轮事件循环末尾统一处理。

2. 任务队列机制——promise.then

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 简化版 nextTick 实现
const queue = [];
let isFlushPending = false;

function nextTick(fn) {
  return fn
    ? Promise.resolve().then(fn)       // 注册回调
    : Promise.resolve();               // 无回调时返回一个 resolved Promise
}

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 确保多个数据变更只注册一个微任务,queue.includes(job) 去重防止同一个组件渲染多次。

3. pre / post 队列

1
2
3
4
5
6
7
8
// Vue 3 实际有两个队列
const pendingPreFlushCbs = [];  // pre 队列(组件更新前)
const pendingPostFlushCbs = []; // post 队列(DOM 更新后)

// 执行顺序:
// 1. 清空 pre 队列(computed 更新、watch:flush=pre)
// 2. 执行组件 render
// 3. 清空 post 队列(watch:flush=post、nextTick 回调)

4. nextTick 的两种用法

1
2
3
4
5
6
7
8
9
10
11
12
import { ref, nextTick } from 'vue';

// 用法 1:回调函数
const count = ref(0);
count.value++;
nextTick(() => {
  console.log('DOM 已更新');
});

// 用法 2:await
await nextTick();
console.log('DOM 已更新');

5. 为什么是微任务不是 setTimeout

1
2
3
4
5
// ❌ 宏任务(setTimeout):每次数据变更后等一轮事件循环才更新 DOM
//    用户可能看到中间态:先闪一下旧值,再闪新值

// ✅ 微任务(Promise.then):当前 tick 末尾执行
//    用户在浏览器渲染前看不到中间态

其实你每天都在用

  • v-for 渲染后操作 DOMdata.push(item); await nextTick(); scrollToBottom()——先等列表渲染完再滚到底部
  • 表单验证后聚焦输入框error.value = '必填'; await nextTick(); inputRef.value.focus()
  • 组件 ref 的使用onMounted 里拿 ref 保证 DOM 就位——其实 Vue 内部也是靠 nextTick 调度的
  • 测试框架(Vue Test Utils)await wrapper.vm.$nextTick() 等 DOM 更新完再断言
  • echarts 初始化await nextTick(); echarts.init(containerRef.value)——必须先有 DOM 节点

常见误解

  • ❌ 误区:「await nextTick() 只在 Vue 组件内有效」 nextTick 是全局 API,任何 JS 环境都能用(只要有 Promise)。它不依赖组件实例,模块外也能 import { nextTick } from 'vue'

  • ❌ 误区:「多次调用 nextTick 会多次更新 DOM」 不会。count.value = 1; count.value = 2; count.value = 3 在同一 tick 合并为一次更新。即便中间穿插 await nextTick(),也只是等这一轮更新完再继续。

  • ❌ 误区:「nextTick 就是 setTimeout(fn, 0) Vue 内部会按环境选择最优方案:Promise.thenMutationObserversetImmediatesetTimeout。绝大多数现代浏览器走 Promise 微任务路径,比 setTimeout(宏任务)早一个阶段执行。

  • ❌ 误区:「在 watch 的回调里不需要 nextTick」 看 flush。flush: 'pre'(默认)的回调在 DOM 更新执行,此时拿 DOM 还是旧的;flush: 'post' 在 DOM 更新执行。如果你用默认 watch 回调操作 DOM,必须里面再套 await nextTick()

一句话总结

nextTick = 数据变了先记账 → 微任务结算 → DOM 更新 → 你的回调执行——把多次操作合并成交,实现”感知不到延迟”的异步批量更新。

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