
做前端这些年Vue的nextTick是我在面试里几乎必问的一道题也是平时排查线上问题最常用到的 API。很多人背得下“nextTick 就是等 DOM 更新之后执行回调”这句话但真到了场景里照样翻车改了数据拿不到最新 DOM、第三方图表初始化时机不对、滚动条位置永远差一点。这篇文章我从事件循环、源码实现、实战踩坑三个层面把nextTick彻底掰开讲清楚顺便带一个能直接“抄作业”的手写实现适合正在用 Vue 做项目、准备 Vue 面试、或者想真正搞懂响应式更新机制的朋友。1. 从现象入手数据改了DOM 为什么没变1.1 一个再常见不过的“Bug”先看一段每个 Vue 开发者几乎都写过的代码const vm new Vue({ data() { return { msg: hello } }, mounted() { this.msg world console.log(this.$el.textContent) // 输出hello } })Vue 3 里同样的情况const count ref(0) count.value 1 console.log(document.querySelector(#app).textContent) // 输出0我第一次遇到这个现象的时候第一反应是 Vue 出 bug 了。后来翻源码才明白这不是 bug而是 Vue 故意设计成异步更新 DOM。this.msg world这行代码执行完之后msg的数据确实已经变了但页面上渲染的 DOM 还是旧值。Vue 要等到当前这一段同步代码跑完才会统一去改 DOM。这个设计的直接后果就是任何同步代码里读 DOM拿到的都是更新前的状态。而nextTick就是 Vue 官方提供的“等 DOM 这次更新完成后再执行回调”的工具。1.2 异步更新队列Vue 的批量处理策略为什么 Vue 要这么绕不在数据改变那一刻立刻更新 DOM核心原因是性能。假设一个组件的数据在同一个函数里被改了十几次this.name 张三 this.age 30 this.city 北京 this.job 前端工程师如果每次赋值都立刻触发一次 DOM 更新那就意味着要连续做十几次 DOM patch浏览器要多次计算样式、多次重排重绘代价非常大。Vue 的做法是把所有数据变化触发的更新任务放进一个队列然后去重——同一个 watcher / effect 即使被触发了十几次在队列里也只保留一个。等当前所有同步代码执行完再统一取出队列里的任务执行一次真正的 DOM 更新。这个机制在 Vue 内部叫“异步更新队列”配合响应式系统一起工作。响应式数据在 setter 阶段会触发依赖收集到的 WatcherVue 2或者副作用函数Vue 3但它不会立刻执行 watcher 的 update而是把 update 包装成一个任务推入调度器队列。这个队列的 flush 时机就是微任务阶段。用一张简单的对照表来看同步更新和异步更新的区别更新方式触发次数DOM 操作结果同步更新每次赋值都触发十几次完整 patch频繁重排重绘性能差异步更新每次赋值只入队去重后一次 patch最终值一次性渲染性能好1.3 到底什么是 tick“tick”直译是“滴答”在事件循环里意思就是一圈。浏览器执行完一个宏任务再把微任务队列清空这整个过程可以理解成一个 tick。Vue 的异步更新队列本质上是在当前 tick 结束时执行任务而nextTick的字面意思就是“在下一个 tick 里做这件事”。但这里有个容易混淆的点nextTick的回调并不一定是“下一个 tick”才执行。在多数情况下它会在当前这个 tick 的微任务阶段就执行也就是说往往比setTimeout(fn, 0)还要早。这个细节非常重要后文我会专门展开讲。2. 事件循环视角下的 nextTick微任务与宏任务的博弈2.1 浏览器怎么调度任务要理解nextTick的底层行为绕不开事件循环。浏览器是单线程的但任务也分优先级主要分成两类宏任务MacroTask整段 script、setTimeout、setInterval、setImmediate、I/O 操作、UI 渲染。微任务MicroTaskPromise.then、Promise.catch、queueMicrotask、MutationObserver回调。执行规则可以用一句话概括每个宏任务执行完后必须把当前微任务队列里的所有任务全部清空才会进入下一个宏任务或渲染。也就是说微任务永远插在“当前宏任务结束”和“下一个宏任务开始”之间。这段空隙非常短但它恰好给了 Vue 一个绝佳的更新时机——等到这个空隙的时候同步代码已经全部执行完了DOM patch 的任务也准备好了一次性把队列清空页面就能以最终状态呈现。2.2 为什么 Vue 首选微任务而不是 setTimeout很多初学者第一反应是既然要“等一会儿再更新”那用setTimeout不就行了确实Vue 2 的早期版本和某些降级方案里就用过setTimeout但它并不是最优解。setTimeout的问题在于延迟不稳定。浏览器对嵌套超过 5 层的setTimeout有最小 4ms 的延迟限制。也就是说即使你写setTimeout(fn, 0)也可能要等 4ms 甚至更久。它是宏任务。在它执行之前要等当前宏任务的所有微任务、以及浏览器可能发起的渲染都处理完回调时机比微任务晚得多。顺序不好控制。如果响应式更新用宏任务实现而业务代码里又用微任务等待两者之间很容易出现时序竞争。所以 Vue 的源码里有一条清晰的降级策略Vue 2.6首选Promise.resolve().then也就是微任务如果环境不支持 Promise比如非常老的浏览器降级到MutationObserver它也是微任务连 MutationObserver 都没有才用setImmediate注意这是 Node.js 和 IE 特有的不是标准 API最后才用setTimeout兜底。Vue 3 更彻底新版浏览器和运行时环境基本都支持 Promise所以直接把nextTick建立在 Promise 之上不再保留那么多降级代码。2.3 一个让很多人翻车的顺序问题看这段代码猜一猜输出顺序const msg ref(hello) msg.value world nextTick(() { console.log(nextTick 回调) }) setTimeout(() { console.log(setTimeout 回调) }, 0) console.log(同步代码)实际输出顺序是同步代码 nextTick 回调 setTimeout 回调nextTick的回调先于setTimeout执行。因为nextTick走的是微任务而setTimeout是宏任务。同一个事件循环里微任务队列一定先被清空。但如果你在nextTick回调里再注册一个setTimeout那它才会到下一个宏任务阶段执行。这也是“为什么nextTick不能替代setTimeout”的原因之一两者所处的任务阶段不同作用也不同。3. 源码解剖Vue 2 与 Vue 3 的 nextTick 实现差异3.1 Vue 2 的 next-tick.jscallbacks 数组与降级链Vue 2 的nextTick源码在src/core/util/next-tick.js整体思路非常经典用一个callbacks数组收集所有待执行的回调用一个pending标志位防止重复启动 flush然后通过timerFunc在微任务阶段统一执行。核心逻辑简化后如下const callbacks [] let pending false function flushCallbacks() { pending false const copies callbacks.slice(0) callbacks.length 0 for (let i 0; i copies.length; i) { copies[i]() } } let timerFunc if (typeof Promise ! undefined) { const p Promise.resolve() timerFunc () { p.then(flushCallbacks) } } else if (typeof MutationObserver ! undefined) { let counter 1 const observer new MutationObserver(flushCallbacks) const textNode document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true }) timerFunc () { counter (counter 1) % 2 textNode.data String(counter) } } else if (typeof setImmediate ! undefined) { timerFunc () { setImmediate(flushCallbacks) } } else { timerFunc () { setTimeout(flushCallbacks, 0) } } export function nextTick(cb, ctx) { let _resolve callbacks.push(() { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, nextTick) } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending true timerFunc() } if (!cb typeof Promise ! undefined) { return new Promise(resolve { _resolve resolve }) } }有几个细节非常值得注意第一为什么要把回调包一层函数因为要捕获执行过程中的异常。如果某个回调抛错了不能影响其他回调执行所以要包一层 try/catch。这也是Vue 2里handleError的职责。第二为什么 flush 时要先slice(0)拷贝一份因为回调执行过程中可能又有新的nextTick被调用往callbacks里推入了新任务。如果直接遍历原数组可能把新任务也一起执行了导致同一轮 flush 生命周期内出现任务递归逻辑混乱。拷贝一份出来保证这轮 flush 只处理进入队列那一刻已经存在的回调新任务等下一轮。第三pending的作用。只要队列还没被 flush无论调用多少次nextTick都不会重复启动timerFunc。所以多个回调会被收集到同一个队列里等同一轮微任务一起执行。第四为什么要返回 Promise。Vue 2.6 之后nextTick如果不传回调就返回一个 Promise。这是为了方便用await this.$nextTick()的写法。_resolve会在 flush 时被调用这样 await 后面的代码就等到了 DOM 更新完成后才执行。3.2 Vue 3 的 nextTick从工具函数到调度器Vue 3 的nextTick整体思路没变但实现更精简了。代码在packages/runtime-core/src/scheduler.ts核心就是三行逻辑const resolvedPromise Promise.resolve() let currentFlushPromise: Promisevoid | null null export function nextTickT void(this: T, fn?: (this: T) void): Promisevoid { const p currentFlushPromise || resolvedPromise return fn ? p.then(fn) : p }Vue 3 把“异步更新任务”的管理交给了调度器 SchedulernextTick自身不再维护 callbacks 数组而是把回调挂到currentFlushPromise上。调度器在每次flushJobs刷新任务队列时会给currentFlushPromise赋值flush 完成后置空。看一个简化版的调度器核心const queue: SchedulerJob[] [] let flushing false let currentFlushPromise: Promisevoid | null null function queueJob(job: SchedulerJob) { if (!queue.includes(job)) { queue.push(job) queueFlush() } } function queueFlush() { if (!flushing) { flushing true currentFlushPromise resolvedPromise.then(flushJobs) } } function flushJobs() { // 按 job.id 排序保证父子组件更新顺序 queue.sort((a, b) a.id - b.id) for (let i 0; i queue.length; i) { const job queue[i] job() } queue.length 0 currentFlushPromise null }Vue 3 的调度器比 Vue 2 的 callbacks 数组多了几个关键能力按 id 排序组件更新任务按照组件实例 id 排序保证父组件先于子组件更新避免子组件重复渲染。任务去重同一个 job 在多轮更新中只保留一个前提是它还没有被执行。多个队列真实调度器分 pre queue、job queue 和 post queue分别对应组件更新前、更新中和更新后的钩子比如watch的回调、生命周期钩子。3.3 从 Vue 2 迁到 Vue 3行为上有什么变化Vue 2 里this.$nextTick和 Vue 3 里nextTick都能实现“DOM 更新后再执行回调”这个核心语义但有几个差异需要注意对比项Vue 2Vue 3回调参数支持(cb, ctx)ctx 绑定 this只支持(fn)fn 的 this 通过闭包决定Promise 支持2.6 之后才支持无回调返回 Promise原生就是 Promise降级链Promise / MutationObserver / setImmediate / setTimeout直接使用 Promise不降级和调度器关系独立维护 callbacks 数组复用调度器的currentFlushPromiseflush 顺序同一队列按入队顺序执行任务按 id 排序且区分多个队列最直观的感受是Vue 3 里nextTick更像一个“锚点”它直接挂到当前正在进行的 flush 上而不是新开一轮微任务等待。这意味着如果你在组件更新过程中比如 watch 回调里调用nextTick它能拿到当前这轮 flush 的 Promise回调时机比 Vue 2 里更精准。4. 实战哪些场景该用 nextTick哪些属于滥用4.1 正确使用场景场景一created 生命周期里操作 DOMcreated阶段实例已经创建但 DOM 还没有挂载。如果你想在创建后立刻读取或操作 DOM直接访问this.$el是拿不到的。常见的做法是配合nextTickcreated() { this.$nextTick(() { // 此时组件已经挂载DOM 可用 console.log(this.$el) }) }但说实话真的想操作挂载后的 DOM放到mounted里更直观。nextTick在created里的真正价值是你想保证某个和 DOM 无关的初始化逻辑也放在 DOM 更新之后执行。场景二修改数据后立即读取 DOM 尺寸或位置这是nextTick最高频的场景。比如做一个聊天列表消息发送后要自动滚动到底部const messages ref([]) const listRef ref(null) const sendMessage async () { messages.value.push(新消息) await nextTick() listRef.value.scrollTop listRef.value.scrollHeight }如果不加await nextTick()执行scrollTop scrollHeight的时候新消息还没渲染进 DOMscrollHeight还是旧值滚动位置自然就不对。场景三v-if 切换后立刻操作新节点showFlag.value true await nextTick() // 此时 v-if 渲染的新节点已经存在可以用 ref 获取并初始化为第三方组件这类场景做动态表单、条件渲染的表格时经常遇到。场景四和第三方 DOM 库集成比如在数据更新后用 Cropper.js 初始化裁剪框、用 ECharts 重新 resize、用 SortableJS 重新排序。第三方库往往在初始化时读取 DOM 结构如果 DOM 还是旧的初始化就废了。const handleChange async () { await nextTick() cropper.replace(newImgUrl) chartInstance.resize() }4.2 常见误用别把 nextTick 当万能等待nextTick不是银弹它只能保证“Vue 的 DOM 更新完成”不能保证“渲染绘制完成”。浏览器把 DOM 更新到页面上还有样式计算style/layout、绘制paint等阶段这些都不在nextTick的范围内。如果你需要等绘制完成后再做测量更好的选择是requestAnimationFrame或者干脆用setTimeout再包一层。另一个常见的误用是把业务逻辑的真实顺序依赖在 nextTick 的时序上。例如有一段业务逻辑必须等某个第三方库初始化完才能执行结果代码里不分青红皂白地写上await nextTick()。这种写法把“不确定的运行时行为”当成了“确定的时序保证”一旦浏览器渲染时序变化问题就暴露出来。更稳妥的做法是直接用第三方库自身的回调或 Promise而不是依赖网络时序。4.3 一个我排查过的线上问题之前维护过一个 Vue 2 项目有个导出 PDF 的功能表格数据更新后点击导出生成的 PDF 里表格内容总是旧数据。现象是第一次点击导出没问题第二次开始数据不变。排查过程是这样的先确认业务代码里的表格数据确实已经更新打印出来是新的再确认导出插件读取的 DOM 结构是旧的定位到原因导出插件读取的是表格 DOM 的innerHTML而表格数据刚更新时DOM 还没完成 patch所以导出拿到的还是旧内容解决办法是在导出前加一句await this.$nextTick()等 DOM 更新完成后再触发导出。这类问题非常典型你改了数据但任何同步读取 DOM 的代码都拿不到最新结果。尤其是导出、截图、打印这类会直接读取 DOM 内容的操作几乎必然会踩这个坑。4.4 什么时候你根本不需要 nextTick如果你只是想读取响应式数据的新值不需要nextTick直接访问变量即可如果你只是想根据数据变化执行一些纯 JS 逻辑用watch更好watch 回调默认在 DOM 更新之前触发但如果你需要等 DOM 更新后可以用flush: post选项驱动 watch 回调这和nextTick的时机本质上一样如果你在处理连续动画或高频事件可以考虑requestAnimationFrame而不是nextTick后者并不关心渲染帧。5. 手写一个小而美的 nextTick顺便应对面试追问5.1 最小可用的实现理解了原理之后自己写一个nextTick并不难。下面是一个最小可用的版本同时支持传回调和无参返回 Promiseconst callbacks [] let pending false function flushCallbacks() { pending false const copies callbacks.slice(0) callbacks.length 0 for (let i 0; i copies.length; i) { copies[i]() } } const p Promise.resolve() function timerFunc() { p.then(flushCallbacks) } function nextTick(cb, ctx) { let _resolve callbacks.push(() { if (cb) { try { cb.call(ctx) } catch (e) { console.error(nextTick 回调执行出错:, e) } } else if (_resolve) { _resolve() } }) if (!pending) { pending true timerFunc() } if (!cb) { return new Promise(resolve { _resolve resolve }) } }没错这段代码和 Vue 2 源码的核心逻辑几乎一致。你只要能把这段代码默写出来面试官就知道你是真懂底层原理的人。5.2 追问一flushCallbacks 为什么要 slice 拷贝 callbacks这个前面讲过回调执行过程中可能会产生新的nextTick调用往同一个队列里 push 新回调。如果直接用for...of遍历原始数组那么这一轮 flush 会连带执行新推入的回调造成任务在当前 tick 里递归执行甚至可能出现死循环。拷贝一份就保证了本轮 flush 只处理入队时已经存在的任务新任务留给下一轮微任务。5.3 追问二为什么用 Promise 而不是 setTimeout这个话题我在第二部分已经详细说了。核心答案是Promise的回调在微任务队列里执行时机早于宏任务延迟更低对 Vue 的“响应式更新 批量更新”语义也更友好。setTimeout延迟不稳定而且会额外插进一个宏任务队列导致nextTick的回调顺序和预期不符。面试时如果能补一句“现代浏览器对嵌套 setTimeout 有最小 4ms 延迟限制而微任务没有这个限制”会非常加分。5.4 追问三await nextTick() 这个写法是怎么实现的nextTick()不传参数时内部会返回一个 Promise_resolve在 flush 时被调用所以await nextTick()后面的代码会在 DOM 更新完成后执行。Vue 2.6 之后this.$nextTick()也支持这种写法所以项目中常见的await this.$nextTick() // 后面的代码等 DOM 更新完再执行本质就是 Promise 语法糖。在 Vue 3 中nextTick()永远返回 Promise因此await nextTick()是官方推荐的写法。5.5 追问四nextTick 和 requestAnimationFrame 可以互相替代吗不能。二者关注的时间点完全不同nextTick关注的是 Vue 响应式更新完成执行时机在微任务阶段通常在浏览器绘制之前。requestAnimationFrame关注的是浏览器渲染帧执行时机在下一帧绘制之前约 16.7ms 一次和屏幕刷新率相关。如果你要等 DOM 更新后测量元素尺寸nextTick是对的。如果你要做一个跟手动画或者一定要等浏览器完成一次绘制后再执行逻辑那就得用requestAnimationFrame。二者不是替代关系是配合关系。5.6 追问五连续修改数据多次nextTick 回调会执行几次执行一次。不管你在一次事件循环里修改多少次响应式数据只要这些修改发生在同一个 tick 内Vue 的异步更新队列只会 flush 一次所有nextTick回调都在这次 flush 结束后执行。所以count.value 1 count.value 2 count.value 3 nextTick(() { // DOM 里的值已经是最终值 3 })这个行为保证了批量更新的意义DOM 只被真实修改一次页面性能不会被无谓的多次渲染拖垮。最后说点我自己的体会。nextTick看起来只是个不起眼的小 API但它背后牵扯的是 Vue 响应式系统、异步更新队列、浏览器事件循环这三块核心知识。很多 Vue 开发者在业务里写了两三年代码可能每天都在用await nextTick()却从没想过它为什么能保证时序。真到了线上性能优化、疑难 bug 排查、手写响应式系统这类硬仗面前这些基础理解往往才是决定你能不能快速定位问题的关键。我的建议是别把它当面试八股把它当成理解 Vue 运行机制的一把钥匙。