大家好,我是小墨,射手座,三十多年从未打过架,斗地主逃跑率99% 。代码写得比字多,bug 修得比头发快 ,秉持“能跑就是好代码”的哲学 。日常和前端打交道,代码写得比注释多,希望和大家多多交流!🤝
咱们写 Vue 应用的时候,和 DOM 打交道是家常便饭。一个非常普遍的需求是:当数据变了,视图更新后,我们想马上拿到最新的 DOM 元素,比如获取它的尺寸、内容或者执行某些操作。这时候,this.$nextTick()往往是第一个出现在我们脑海里的解决方案。
大家普遍认为,把操作 DOM 的代码放进$nextTick的回调里,就能确保这段代码在 DOM 更新完成之后执行。但,事实总是如此吗?有没有可能,$nextTick的回调执行了,看到的 DOM 却还是“旧”的?
别急,本期小墨就带大家一起,潜入 Vue 的内部机制,结合浏览器的事件循环,把$nextTick和 DOM 更新这对“异步兄弟”的真实关系捋清楚。
先看现象:$nextTick 的“意外”
我们用一个简单的计数器例子来看看可能发生的情况:
当你点击按钮后,观察控制台输出,可能会得到类似这样的结果:
发现了吗?回调 A执行时,拿到的 DOM 内容显示的计数还是0,尽管同步代码里count已经变成了1。而回调 B则符合预期,拿到了更新后的内容1。
这个现象说明,$nextTick的执行时机并非简单地“等待 DOM 渲染完毕”。要理解它,我们得先补上两个关键的知识点。
图解核心交互流程
在深入细节前,我们先用一个时序图(Sequence Diagram)来描绘一下上面例子中,用户操作、数据修改、$nextTick调用以及最终 DOM 更新的大致流程:
这个图清晰地展示了回调函数和 DOM 更新任务是如何进入同一个队列,并按顺序执行的。接下来我们详细解释背后的机制。
知识点一:浏览器的“时间管理员”——事件循环与微任务
浏览器运行 JavaScript 是单线程的,但它通过事件循环(Event Loop)机制来处理异步任务,避免阻塞。事件循环中有两种主要的任务队列:
1.宏任务(Macrotask)队列:存放script(整体代码块)、setTimeout/setInterval回调、I/O 操作、用户交互事件(如点击)等。每次事件循环,只会从这个队列里取出一个任务来执行。
2.微任务(Microtask)队列:存放Promise.then/catch/finally的回调、MutationObserver回调、queueMicrotask安排的任务,以及Vue 的$nextTick回调和 DOM 更新任务。
关键规则:在一个宏任务执行结束后,浏览器会立即检查微任务队列,并清空它,执行里面所有的微任务。如果在执行微任务的过程中,又产生了新的微任务,这些新来的也会在当前这一轮被执行掉,直到微任务队列彻底干净为止。然后才可能进行 UI 渲染,并开始下一轮的事件循环(取下一个宏任务)。
这意味着微任务拥有极高的优先级,它们总是在当前同步代码执行完、下一次渲染发生前被集中处理掉。
知识点二:Vue 的“智能更新”——异步 DOM 更新
当你修改 Vue 组件里的数据(比如this.count = 1)时,Vue 并不会立刻冲过去更新真实的 DOM。这是出于性能考虑:如果短时间内连续修改多次数据,频繁操作 DOM 会非常消耗性能。
Vue 的策略是异步批量更新:
1.侦测变化:数据一变,依赖这个数据的 Watcher(观察者)就会收到通知。
2.入队缓冲:负责更新视图的那个 Watcher(Render Watcher),不会马上干活。它会调用一个内部函数queueWatcher,把自己添加到一个异步更新队列里。这个过程会进行去重处理,保证同一个 Watcher 在一个更新周期里只进来一次。
3.调度执行:queueWatcher函数最关键的一步,是调用了nextTick,并把一个叫flushSchedulerQueue的函数作为回调传进去。这个flushSchedulerQueue函数的任务就是遍历整个异步更新队列,执行所有待更新 Watcher 的run方法,最终触发 Virtual DOM 的比对和真实 DOM 的更新。
划重点:Vue 本身就是用nextTick来安排 DOM 更新的!DOM 更新操作,是被 Vue 打包成了一个任务,放进了微任务队列等待执行。
解密时刻:$nextTick 与 DOM 更新的“共享通道”
现在我们来看看this.$nextTick(callback)的内部运作(基于 Vue 2.x 简化逻辑):
1.回调收集站 (callbacks数组):你每次调用this.$nextTick(fn),你的函数fn都会被推进这个全局的callbacks数组里排队。
2.异步调度开关 (pending标志):用一个pending变量(初始为false)来确保在同一个事件循环“tick”内,只启动一次异步任务去处理callbacks队列。
• 当第一次调用nextTick时,发现pending是false,就把它设为true,然后调用timerFunc。timerFunc会优先尝试用Promise.resolve().then(flushCallbacks)(这是微任务)来异步触发flushCallbacks函数。如果环境不支持 Promise,它会依次尝试MutationObserver(微任务)、setImmediate(宏任务,Node 环境)、最后是setTimeout(flushCallbacks, 0)(宏任务)。目标是尽快把flushCallbacks的执行塞进异步队列,微任务是最佳选择。
• 如果在同一个“tick”内后续再调用nextTick,此时pending已经是true了,就不会再调用timerFunc,只是简单地把新的回调函数加到callbacks数组末尾。
3.回调执行官 (flushCallbacks函数):当事件循环执行到那个被timerFunc安排的异步任务时(通常是微任务阶段),flushCallbacks函数就会被调用。它干几件事:
• 把pending设回false,表示这一批次的异步处理开始了,允许下一轮再启动新的。
•非常重要:创建callbacks数组的一个副本(copies = callbacks.slice(0))。
• 清空原来的callbacks数组 (callbacks.length = 0)。
• 遍历那个副本(copies),按顺序执行里面的每一个回调函数。
核心真相:
无论是你自己写的this.$nextTick(yourCallback),还是 Vue 内部为了安排 DOM 更新调用的nextTick(flushSchedulerQueue),它们的回调函数,最终都进入了同一个callbacks数组!
执行顺序揭晓:先来后到,排队执行!
既然大家都在一个队列里排队,并且flushCallbacks是按顺序执行的,那么:
在一个事件循环的“tick”内(即一次宏任务及其随后的所有微任务处理期间):
• DOM 更新任务 (flushSchedulerQueue) 和你的$nextTick回调 (yourCallback) 的实际执行顺序,就取决于它们被添加到callbacks数组的先后次序。
• 谁先调用了nextTick把自己的回调塞进去,谁的回调就在队列前面,在接下来的微任务处理阶段就会被flushCallbacks先执行到。
再看开头的例子,豁然开朗:
在increment方法里:
1.this.$nextTick(回调A)先执行,回调A第一个进入callbacks数组。[回调A]。并安排了微任务flushCallbacks。
2.this.count += 1执行,触发内部 DOM 更新流程,调用nextTick(DOM更新任务),DOM更新任务进入数组。[回调A, DOM更新任务]。
3.this.$nextTick(回调B)执行,回调B进入数组。[回调A, DOM更新任务, 回调B]。
4. 同步代码结束。
5. 事件循环进入微任务阶段,执行flushCallbacks。
6.flushCallbacks按副本[回调A, DOM更新任务, 回调B]的顺序执行:
• 执行回调A:此时DOM更新任务还没执行,DOM 没变,所以看到当前计数: 0。
• 执行DOM更新任务:Vue 更新 DOM,内容变为当前计数: 1。
• 执行回调B:此时 DOM 刚刚更新完,所以看到当前计数: 1。
如何确保回调在 DOM 更新后执行?
理解了原理,答案就很清晰了。要想让你的$nextTick回调稳定地在 DOM 更新之后执行,最佳实践是:
始终先完成数据修改操作,然后再调用this.$nextTick。
遵循这个模式,数据修改会先触发 Vue 内部调用nextTick来安排 DOM 更新。你随后调用的this.$nextTick则将你的回调放在更新任务之后。这样,在微任务阶段,DOM 更新就自然地先执行了。
总结
$nextTick并非是一个神奇的“等待 DOM 渲染完成”的指令,它更准确的身份是“将回调推迟到下一个微任务队列中执行”的调度器。
因为 Vue 的 DOM 更新本身也依赖nextTick机制,并且用户调用和内部调用共享同一个回调队列,所以它们的执行顺序取决于谁先“挂号”(注册回调)。
记住“先改数据,后$nextTick”这个简单有效的实践原则,就能在绝大多数场景下确保你的回调在 DOM 更新后运行。理解了这层原理,下次再遇到关于$nextTick的时序问题,你就能从容应对,精准定位啦!
如果觉得内容不错,欢迎点赞、分享、推荐!一起交流前端开发!
领取专属 10元无门槛券
私享最新 技术干货