你了解NextTick的实现原理吗

132 阅读7分钟

前言

笔者最近在学习Vue的时候,突然对我们平常使用的NextTick原理很感兴趣,他是怎么做到基于更新后的 DOM 状态执行一些逻辑(比如测量某个元素的尺寸),nextTick 是一个非常有用的方法,它允许你在 DOM 更新之后执行代码。

要深入理解 nextTick 的原理,就不得不了解 JavaScript 的任务执行机制,特别是同步代码、宏任务、微任务以及页面渲染之间的优先级关系。

一、JavaScript 任务执行机制基础

(一)任务类型及概念

JavaScript 执行环境基于事件循环(Event Loop)机制,任务主要分为同步任务和异步任务,而异步任务又细分为宏任务和微任务。

  • 同步代码:按照代码书写顺序在主线程上依次执行的代码。只有当同步代码全部执行完毕,才会开始处理异步任务。例如简单的变量赋值、函数调用等操作都属于同步代码。
  • 宏任务:常见的宏任务有 setTimeoutsetIntervalsetImmediate(主要在 Node.js 环境)、I/O 操作以及 UI 渲染等。宏任务会在新的事件循环周期开始时被处理。
  • 微任务:像 Promise.thenMutationObserverprocess.nextTick(Node.js 环境)等都属于微任务。微任务会在当前宏任务执行结束后,下一个宏任务开始之前执行。
  • 渲染页面:浏览器会在合适的时机对页面进行重排和重绘操作,以此更新页面的视觉表现。不过,并非每次事件循环都会触发页面渲染,浏览器会根据自身的渲染策略来决定。

(二)任务执行优先级顺序

JavaScript 代码执行时,遵循 “同步代码> 微任务 > 渲染页面 > 宏任务” 的优先级顺序:

  1. 同步代码优先执行:当代码开始运行,主线程会先执行所有的同步代码,在这个过程中不会处理任何异步任务。例如以下代码:

console.log('同步代码开始');
const result = 3 + 5;
console.log('计算结果:', result);
console.log('同步代码结束');

上述代码中的 console.log 和变量赋值操作会依次执行,直到所有同步代码执行完毕。
2. 处理微任务队列:同步代码执行完成后,事件循环会检查微任务队列。如果队列中有任务,会依次执行队列中的所有微任务,直至队列为空。示例如下:

console.log('同步代码开始');
Promise.resolve().then(() => {
    console.log('微任务执行');
});
console.log('同步代码结束');

执行时,先输出 “同步代码开始” 和 “同步代码结束”,然后执行微任务,输出 “微任务执行”。
3. 渲染页面:微任务队列清空后,浏览器会判断是否需要进行页面渲染。如果满足渲染条件(如 DOM 发生变化),则会进行重排和重绘操作。
4. 处理宏任务队列:页面渲染完成后,事件循环会从宏任务队列中取出一个宏任务并执行。执行完该宏任务后,再次检查微任务队列,重复上述执行流程。例如:

console.log('同步代码开始');
setTimeout(() => {
    console.log('宏任务执行');
}, 0);
Promise.resolve().then(() => {
    console.log('微任务执行');
});
console.log('同步代码结束');

执行顺序为:先执行同步代码,输出 “同步代码开始” 和 “同步代码结束”;接着执行微任务,输出 “微任务执行”;之后浏览器可能进行页面渲染;最后执行宏任务,输出 “宏任务执行”。

送给大家一道大厂面试真题

      const p = document.createElement("p");
      const body = document.querySelector('body')
      p.innerHTML = "p";
      console.log(1);
      setTimeout(() => {
        console.log(2);
      }, 0);
      new Promise((resolve) => {
        console.log(3);
        resolve()
      }).then((res) => {
        console.log(4);
      });
      body.appendChild(p);

大家可以先自己分析一下输出结果哦,我们在文章末尾公布答案

二、Vue.js 的 nextTick 原理与任务执行机制的关联

(一)为何需要 nextTick

Vue 采用异步更新策略,当响应式数据发生变化时,并不会立即将更新应用到 DOM 上。而是把 DOM 更新操作放入队列,等下一个更新周期统一处理。这样做的目的是避免频繁的 DOM 操作,提升性能。所以,在修改数据后立即获取更新后的 DOM 状态,往往得到的是更新前的结果。此时,就需要使用 nextTick 方法,确保在 DOM 更新完成后再执行相应操作。

(二)nextTick 原理与任务执行的结合

  1. 异步更新队列与任务执行:Vue 在更新 DOM 时采用异步执行方式。当响应式数据改变,与之关联的 watcher 对象会被添加到队列中,并且会对队列进行去重处理,防止重复更新。等到同一事件循环内所有数据变化完成,在合适的任务执行阶段统一执行队列中的 watcher 更新操作,从而更新 DOM。

  2. 执行时机选择与任务类型nextTick 的核心目标是在 DOM 更新循环结束后执行回调函数。Vue 会根据不同浏览器环境,选择合适的异步方法来执行回调,这些方法与 JavaScript 的任务类型紧密相关:

    • Promise.then(微任务) :若浏览器支持 Promise,Vue 优先使用 Promise.then 实现异步回调。由于 Promise.then 的回调函数会在当前宏任务执行完毕、下一个宏任务开始前的微任务队列中执行,所以可以保证在 DOM 更新(作为宏任务的一部分)完成后尽快执行 nextTick 的回调。
    • MutationObserver(微任务) :当浏览器不支持 Promise 时,Vue 会使用 MutationObserver。它同样会将回调函数放入微任务队列中执行,确保在 DOM 更新后执行 nextTick 回调。
    • setImmediate(宏任务) :若上述两种方法都不支持,Vue 会尝试使用 setImmediate。这是在 IE 浏览器中支持的异步方法,属于宏任务,会在当前事件循环结束后立即执行回调。
    • setTimeout(宏任务) :作为兜底方案,Vue 会使用 setTimeout,将回调函数放入下一个宏任务队列中执行。
  3. 回调函数管理与任务执行流程nextTick 会把传入的回调函数存放在数组中。当根据所选的异步方法(微任务或宏任务)到达执行时机时,依次执行数组中的所有回调函数。若未传入回调函数,还会返回一个 Promise 以支持链式调用。这一过程严格遵循 JavaScript 的任务执行优先级顺序,确保在 DOM 更新完成后执行回调。

三、总结

理解 JavaScript 的任务执行机制对于掌握 Vue.js 的 nextTick 原理至关重要。nextTick 正是巧妙地利用了浏览器的异步机制,根据不同情况选择合适的任务类型(微任务或宏任务),将回调函数放入异步队列,在 DOM 更新循环结束后执行,从而保证开发者能够在 DOM 更新完成后进行相应的操作,避免因异步更新带来的问题,提高代码的正确性和性能。

上文的大厂真题答案为

  1. 同步代码执行:首先,所有同步代码会依次执行。

    • 创建 <p> 元素,并设置其 HTML 内容为 "p"。
    • 输出 console.log(1),所以你会看到控制台输出 1
    • 立即执行 new Promise 构造函数内的代码,输出 console.log(3),因此接下来你会看到 3 被打印出来。
    • 由于在 Promise 构造函数内部调用了 resolve(),这将触发 .then() 方法中的回调函数,但是这个回调会被放入微任务队列中,不会立即执行。
    • 最后,将 <p> 元素追加到 body 中。
  2. 微任务执行:一旦当前的同步代码执行完毕,JavaScript 引擎会检查并执行所有已排队的微任务。

    • 因此,在此阶段,.then() 方法中的回调将会被执行,输出 console.log(4)。此时你将在控制台上看到 4
  3. 宏任务执行:当所有的微任务都处理完之后,JavaScript 引擎开始处理宏任务队列中的任务。

    • 在你的例子中,宏任务就是由 setTimeout 设置的任务。它的回调函数被安排在事件循环的下一个周期执行,因此它会在所有微任务完成之后才被执行,输出 console.log(2)。这意味着最后你会看到 2 出现在控制台上。

综上所述,最终的控制台输出顺序将是:

1
3
4
2