从 14 道面试题,彻底搞懂 Event Loop 与 Vue2 nextTick 底层原理

0 阅读4分钟

从 14 道执行顺序题,彻底搞懂 JavaScript Event Loop 与 Vue2 nextTick 原理

你真的理解 JavaScript 事件循环吗?为什么 Promise 比 setTimeout 先执行?为什么 Vue2 连续修改三次数据,DOM 却只更新一次?为什么 $nextTick 有时候比 Promise.then 先执行,有时候又排在后面?

这篇文章从基础执行顺序出发,逐步深入微任务队列、async/await、浏览器渲染、Vue2 Watcher 调度器以及 nextTick 的源码实现。

前言

作为前端开发者,我们经常会写这样的代码:

this.message = 'hello';

console.log(this.$refs.title.textContent);

this.$nextTick(() => {
  console.log(this.$refs.title.textContent);
});

明明 message 已经修改了,为什么第一次读取 DOM 还是旧值,而 $nextTick 中却能拿到新值?

很多人会回答:

因为 Vue 的 DOM 更新是异步的。

这个答案没错,但还不够。

继续追问:

  • Vue2 的异步更新到底异步在哪里?
  • Promise.then 和 $nextTick 都是微任务吗?
  • Vue2 为什么要维护 Watcher 队列?
  • flushSchedulerQueue 和 flushCallbacks 有什么区别?
  • 嵌套 $nextTick 为什么会产生新一批回调?
  • DOM 更新完成是否意味着浏览器已经把画面绘制出来?

要回答这些问题,必须先理解 JavaScript 的 Event Loop。

本文主要以浏览器环境和 Vue 2.6 默认异步更新机制为背景。涉及 Vue 内部实现的代码均为便于理解的简化版本。


一、JavaScript 为什么需要事件循环?

JavaScript 在浏览器主线程上的执行具有一个重要特点:同一时刻只能执行一段 JavaScript 代码。

假设有以下代码:

console.log('A');

setTimeout(() => {
  console.log('B');
}, 0);

console.log('C');

最终输出:

A
C
B

为什么 setTimeout 的延迟设置为 0,B 还是最后执行?

因为 setTimeout(fn, 0) 的含义不是立即执行 fn,而是在满足计时条件后,将回调安排为一个等待执行的任务。

只有当前 JavaScript 执行结束,并且事件循环调度到这个任务时,它才有机会执行。

1.1 调用栈

调用栈(Call Stack)用于记录当前正在执行的函数。

例如:

function a() {
  b();
}

function b() {
  console.log('hello');
}

a();

执行过程:

进入 a()
  ↓
进入 b()
  ↓
执行 console.log()
  ↓
b() 返回
  ↓
a() 返回

JavaScript 不会在 b() 执行到一半时,突然跳过去执行一个普通的 setTimeout 回调。

1.2 任务与微任务

浏览器中的异步回调可以来自不同的调度机制。

常见的任务来源:

  • setTimeout
  • setInterval
  • 用户点击事件
  • 部分网络与消息事件

常见的微任务来源:

  • Promise.then / catch / finally
  • queueMicrotask
  • async/await 的后续执行
  • MutationObserver

这里需要注意:async/await 并不是一个独立的“更高级队列”。它的后续执行依赖 Promise 相关的微任务机制。

1.3 Event Loop 的简化模型

可以把浏览器的一次典型调度过程理解为:

执行一个任务
     ↓
运行同步 JavaScript
     ↓
调用栈清空
     ↓
执行微任务检查点
     ↓
持续处理微任务,直到队列清空
     ↓
浏览器可能进行渲染更新
     ↓
选择下一个任务

这里有两个重点。

第一,微任务检查点会持续处理新加入的微任务,而不是只执行检查点开始时已经存在的那些。

第二,浏览器不是每执行完一个任务就一定绘制一帧。是否更新渲染取决于浏览器的渲染时机和调度策略。


二、Promise 为什么比 setTimeout 先执行?

来看一道经典面试题:

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

输出:

1
4
3
2

分析:

第一阶段:同步代码

输出 1
注册 setTimeout
注册 Promise.then
输出 4

此时:

调用栈:即将清空

微任务队列:
[Promise.then]

待执行的定时器任务:
[setTimeout]

第二阶段:微任务检查点

执行 Promise 回调:

输出 3

第三阶段:执行定时器任务

输出 2

因此结果为:

1 → 4 → 3 → 2

记住一个原则:

不要只看代码出现的位置,还要看回调通过什么机制被调度。


三、嵌套 Promise:微任务遵循先进先出

下面这道题比上一题更容易出错:

console.log('A');

Promise.resolve().then(() => {
  console.log('B');

  Promise.resolve().then(() => {
    console.log('C');
  });
});

Promise.resolve().then(() => {
  console.log('D');
});

console.log('E');

你可能会以为,B 执行之后紧接着就会执行它内部的 C。

实际上输出为:

A
E
B
D
C

3.1 为什么不是 A E B C D?

同步代码执行结束时,微任务队列为:

[B, D]

开始执行 B:

console.log('B');

Promise.resolve().then(() => {
  console.log('C');
});

执行 B 的过程中,新注册的 C 会追加到微任务队列尾部。

此时队列变成:

[D, C]

所以 D 先执行,C 后执行。

整个过程:

步骤执行内容微任务队列
1同步输出 A空
2注册 BB
3注册 DB、D
4同步输出 EB、D
5执行 B,注册 CD、C
6执行 DC
7执行 C空

结论:微任务执行期间创建的新微任务,不会抢占已经在等待的微任务。


四、async/await 的执行顺序

async/await 让异步代码看起来像同步代码,但它不会改变 Promise 的基本调度机制。

例如:

console.log('1');

async function test() {
  console.log('2');

  await Promise.resolve();

  console.log('3');
}

test();

Promise.resolve().then(() => {
  console.log('4');
});

console.log('5');

输出:

1
2
5
3
4

4.1 await 前后有什么区别?

执行:

async function test() {
  console.log('2');

  await Promise.resolve();

  console.log('3');
}

其中:

console.log('2');

属于当前调用过程中的同步执行。

而:

console.log('3');

需要等待 await 对应的 Promise 完成,并通过微任务继续执行。

即使等待的是一个已经 fulfilled 的 Promise,await 后面的代码也不会直接作为同一段同步代码继续执行。

4.2 一个常见误区

不要把:

await somePromise;

简单理解成:

setTimeout(..., 0);

两者使用的调度机制不同。

另外,await 后续执行与其他 Promise 回调的具体先后关系,还要结合它们实际入队的时间判断。


五、DOM 更新与浏览器绘制不是一回事

来看下面的代码:

const box = document.getElementById('box');

box.style.backgroundColor = 'red';

Promise.resolve().then(() => {
  box.style.backgroundColor = 'blue';
});

setTimeout(() => {
  box.style.backgroundColor = 'green';
}, 0);

从 JavaScript 执行角度看,颜色依次被设置为:

red → blue → green

但是用户是否一定能看到红色、蓝色和绿色三个画面?

不一定。

原因是:

  • JavaScript 修改 DOM 或样式,不代表浏览器立即完成绘制。
  • Promise 微任务通常会在浏览器获得下一次渲染机会前执行。
  • 浏览器可能合并多次样式变化。
  • 定时器执行前是否发生一次绘制,也不能仅凭 setTimeout(0) 保证。

所以,最终 DOM 样式是绿色,不代表屏幕一定经历了三次可见颜色变化。

5.1 requestAnimationFrame 与 setTimeout

requestAnimationFrame 用于安排与浏览器渲染更新相关的回调。

例如:

console.log('A');

requestAnimationFrame(() => {
  console.log('B');
});

Promise.resolve().then(() => {
  console.log('C');
});

setTimeout(() => {
  console.log('D');
}, 0);

console.log('E');

可以确定同步输出:

A
E

然后执行 Promise 微任务:

C

但 requestAnimationFrame 回调 B 与定时器回调 D 的先后,不能简单地写死为一种顺序。

requestAnimationFrame 并不是普通的定时器任务,它与浏览器的渲染更新时机相关;后台标签页中还可能受到节流。

因此不能武断地说:

requestAnimationFrame 永远比 setTimeout 快。


六、进入 Vue2:为什么修改数据后 DOM 不会立即更新?

现在开始理解 Vue2 的异步更新机制。

假设组件中有:

data() {
  return {
    message: 'old'
  };
}

模板:

<div ref="title">{{ message }}</div>

然后执行:

this.message = 'hello';

console.log(this.message);

console.log(this.$refs.title.textContent);

在组件已挂载、使用默认异步更新的情况下,可能输出:

hello
old

为什么?

因为 Vue2 的响应式 setter 会立即更新数据,但组件 DOM 的更新通常被安排到异步刷新队列中。

可以简单理解为:

this.message = 'hello'
          ↓
响应式 setter
          ↓
通知相关 Watcher
          ↓
Watcher 进入更新队列
          ↓
安排异步刷新
          ↓
执行渲染 Watcher
          ↓
更新真实 DOM

这里的 Watcher 是 Vue2 响应式系统的重要组成部分。

6.1 什么是 Watcher?

Watcher 可以理解为负责观察依赖并响应变化的对象。

Vue2 中常见的 Watcher 包括:

  • 组件渲染 Watcher:负责组件重新渲染。
  • 用户 Watcher:例如通过 watch 或 $watch 创建。
  • 计算属性 Watcher:配合计算属性的懒求值和缓存机制。

这些 Watcher 并不是完全相同的调度方式。

本文后面讨论的批量更新,主要针对默认异步模式下的组件渲染 Watcher。


七、Vue2 为什么连续修改三次数据,却只更新一次 DOM?

经典代码:

this.count = 1;
this.count = 2;
this.count = 3;

this.$nextTick(() => {
  console.log(this.$refs.count.textContent);
});

假设模板:

<div ref="count">{{ count }}</div>

最终输出:

3

而且在典型情况下,同一个组件的渲染 Watcher 在这一批更新中只需要运行一次。

7.1 Vue2 的 Watcher 队列

Vue2 不会在每次响应式赋值时立即重新渲染整个组件。

它会通过调度器把需要更新的 Watcher 加入队列。

假设渲染 Watcher 的 ID 为 7。

执行:

this.count = 1;

Watcher 队列:

[Watcher #7]

继续执行:

this.count = 2;

Watcher 队列仍然是:

[Watcher #7]

继续执行:

this.count = 3;

队列还是:

[Watcher #7]

为什么不会重复入队?

因为 Vue2 使用 Watcher ID 进行去重。

7.2 queueWatcher 的核心思想

简化实现:

const queue = [];
const has = {};
let waiting = false;

function queueWatcher(watcher) {
  const id = watcher.id;

  if (has[id] == null) {
    has[id] = true;

    queue.push(watcher);

    if (!waiting) {
      waiting = true;

      nextTick(flushSchedulerQueue);
    }
  }
}

其中:

queue 用于保存待更新的 Watcher。

has 用于判断某个 Watcher 是否已经入队。

waiting 用于避免重复安排同一轮刷新。

所以:

this.count = 1;
this.count = 2;
this.count = 3;

虽然发生了三次赋值,但同一个渲染 Watcher 通常只进入当前队列一次。

7.3 flushSchedulerQueue 做什么?

flushSchedulerQueue 是 Vue2 调度器中负责执行 Watcher 队列的重要函数。

它的核心工作包括:

  1. 对 Watcher 队列排序。
  2. 依次执行 Watcher。
  3. 处理刷新相关的生命周期逻辑。
  4. 重置调度状态,为后续更新做准备。

当渲染 Watcher 真正运行时:

count === 3

所以组件渲染读取到的是最新值 3。

这就是 Vue2 批量更新的核心优化。

注意:去重并不意味着 setter 只执行了一次,而是避免同一批次内重复安排相同的异步 Watcher。


八、nextTick 的原理到底是什么?

我们经常写:

this.message = 'hello';

this.$nextTick(() => {
  console.log(this.$refs.title.textContent);
});

很多人认为 $nextTick 就等于:

Promise.resolve().then(callback);

这种理解过于简单。

Vue2 的 $nextTick 本身还维护了一份内部回调列表。

8.1 nextTick 的简化实现

const callbacks = [];

let pending = false;

function nextTick(cb) {
  callbacks.push(cb);

  if (!pending) {
    pending = true;

    Promise.resolve().then(flushCallbacks);
  }
}

function flushCallbacks() {
  pending = false;

  const copies = callbacks.slice();

  callbacks.length = 0;

  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

这里的代码是原理简化版,并非 Vue2 完整源码。

需要注意两个不同层次:

Vue 内部回调列表:

[flushSchedulerQueue, 用户 nextTick 回调]

浏览器微任务队列:

[Vue flushCallbacks]

它们不是同一个队列。

flushSchedulerQueue 是 Vue 内部回调,通常由 flushCallbacks 调用;并不是每一个 flushSchedulerQueue 都直接对应一个独立的浏览器微任务。

这也是理解 Vue2 和原生 Promise 注册顺序的关键。


九、Vue2 nextTick 与 Promise.then,到底谁先执行?

这是本文最值得反复理解的一节。

9.1 情况一:先修改响应式数据,再注册 Promise

console.log('A');

this.message = 'hello';

this.$nextTick(() => {
  console.log('B');
});

Promise.resolve().then(() => {
  console.log('C');
});

console.log('D');

在 Vue 2.6 的典型现代浏览器环境中,输出:

A
D
B
C

原因:

执行:

this.message = 'hello';

Vue 会安排自己的刷新微任务。

然后:

this.$nextTick(() => {
  console.log('B');
});

B 加入 Vue 内部回调列表。

之后才注册原生 Promise C。

所以浏览器微任务队列为:

[Vue flushCallbacks, Promise C]

Vue 先更新 DOM,再执行 B。

然后才执行 C。

9.2 情况二:先注册 Promise,再修改响应式数据

把顺序改一下:

console.log('A');

Promise.resolve().then(() => {
  console.log('C');
});

this.message = 'hello';

this.$nextTick(() => {
  console.log('B');
});

console.log('D');

这次输出:

A
D
C
B

为什么?

因为原生 Promise C 比 Vue 的刷新微任务更早入队。

微任务队列:

[Promise C, Vue flushCallbacks]

所以 C 先执行,B 后执行。

9.3 结论

$nextTick 不具有天然高于 Promise 的执行优先级。

在 Vue2 使用 Promise 微任务调度时,执行顺序取决于:

  • Vue 的刷新微任务何时被安排。
  • 原生 Promise 回调何时入队。
  • 用户的 $nextTick 回调位于 Vue 内部回调列表的什么位置。

因此,在 Vue2 项目中需要读取更新后的 DOM 时,应该明确使用:

this.$nextTick(() => {
  // 读取更新后的 DOM
});

而不是依赖某个 Promise 回调恰好在 Vue 更新之后执行。


十、嵌套 nextTick:为什么会出现两批 DOM 更新?

看下面的代码:

this.count = 1;
this.count = 2;

this.$nextTick(() => {
  this.count = 3;

  this.$nextTick(() => {
    console.log(this.$refs.count.textContent);
  });
});

最终输出:

3

在典型情况下,组件渲染 Watcher 会运行两次。

10.1 第一批更新

同步执行:

this.count = 1;
this.count = 2;

由于 Watcher 去重,只安排一次组件渲染。

第一批更新后:

count = 2
DOM = 2

然后执行外层 $nextTick。

10.2 第二批更新

外层回调执行:

this.count = 3;

此时第一批 Watcher 调度已经完成,相关去重状态已经重置。

所以渲染 Watcher 可以再次入队。

第二批更新后:

count = 3
DOM = 3

随后执行内层 $nextTick,输出 3。

10.3 去重范围不是永久的

Vue2 的 Watcher 去重主要服务于当前调度批次。

它不会因为某个 Watcher 曾经执行过,就永远不允许它再次更新。

另外,两批 Vue 更新不等于浏览器一定绘制了两帧。

两批刷新完全可能在浏览器下一次绘制之前连续完成。


十一、nextTick 中注册 Promise 和新的 nextTick

继续看一道稍复杂的题:

console.log('A');

this.count = 1;

this.$nextTick(() => {
  console.log('B');

  Promise.resolve().then(() => {
    console.log('C');
  });

  this.$nextTick(() => {
    console.log('D');
  });
});

console.log('E');

输出:

A
E
B
C
D

11.1 为什么 C 在 D 前面?

同步阶段:

A
E

Vue 执行第一次 flushCallbacks:

更新 DOM
执行外层 nextTick
输出 B

在 B 所在的回调中:

Promise.resolve().then(() => {
  console.log('C');
});

先安排原生 Promise C 微任务。

然后:

this.$nextTick(() => {
  console.log('D');
});

Vue 将 D 加入新的内部回调列表,并安排第二次 flushCallbacks。

因此微任务队列变成:

[Promise C, Vue flushCallbacks #2]

所以先输出 C,再输出 D。

11.2 如果交换注册顺序?

this.$nextTick(() => {
  console.log('B');

  this.$nextTick(() => {
    console.log('D');
  });

  Promise.resolve().then(() => {
    console.log('C');
  });
});

在相同前提下,这部分会变成:

B
D
C

原因很简单:Vue 的第二次刷新微任务先于 Promise C 入队。

这再次证明:

不要死记 API 的优先级,要追踪微任务实际入队的时间。


十二、微任务饥饿:为什么 Promise 也可能卡死页面?

微任务虽然很方便,但如果使用不当,也可能造成严重的性能问题。

例如:

function loop() {
  Promise.resolve().then(loop);
}

loop();

setTimeout(() => {
  console.log('定时器执行');
}, 0);

这里每次执行 loop,都会再次创建一个 Promise 微任务。

结果就是:

执行微任务
   ↓
创建下一个微任务
   ↓
执行下一个微任务
   ↓
继续创建微任务
   ↓
……

微任务队列始终无法清空。

这样会导致:

  • 定时器迟迟无法执行。
  • 浏览器无法正常处理后续任务。
  • 页面可能无法及时响应点击。
  • 浏览器可能长时间得不到绘制机会。

这就是微任务饥饿。

12.1 如何避免?

如果需要处理大量数据,可以分批执行。

例如:

function processInChunks(items) {
  let index = 0;
  const chunkSize = 100;

  function runChunk() {
    let processed = 0;

    while (
      index < items.length &&
      processed < chunkSize
    ) {
      processItem(items[index]);

      index++;
      processed++;
    }

    if (index < items.length) {
      setTimeout(runChunk, 0);
    }
  }

  runChunk();
}

通过将后续工作安排到新的任务中,浏览器有机会在任务之间处理其他工作。

但 setTimeout 不保证精确的绘制时机。

如果涉及动画,可以考虑 requestAnimationFrame;如果是大量纯计算任务,可以考虑 Web Worker。


十三、Vue2 实际开发中的几个建议

13.1 修改数据后读取 DOM,使用 nextTick

推荐:

this.list.push(newItem);

this.$nextTick(() => {
  const height = this.$refs.list.offsetHeight;

  console.log(height);
});

不要为了等待 DOM 更新而随意写:

setTimeout(() => {
  // 读取 DOM
}, 0);

定时器不是 Vue DOM 更新完成的语义保证。

13.2 不要滥用嵌套 nextTick

一般情况下,一次 $nextTick 就够了。

只有当你在第一个回调中又修改了响应式数据,并且确实需要等待这次修改对应的 DOM 更新,才需要再次等待。

13.3 不要误以为 nextTick 等于浏览器完成绘制

$nextTick 保证的是 Vue 对已安排更新的 DOM 刷新时机,而不是保证屏幕已经完成绘制。

如果你的逻辑依赖浏览器渲染帧,例如动画、过渡或某些布局时序,需要结合 requestAnimationFrame 等机制具体分析。

13.4 不要把 Watcher 去重理解成所有更新都只执行一次

默认异步渲染 Watcher 的批量去重,是 Vue2 的重要优化。

但同步 Watcher、计算属性 Watcher、刷新过程中再次触发更新等情况,都有各自的行为。

不能把“一个 tick 只更新一次”当成没有例外的绝对规则。


十四、一张表总结 Event Loop 与 Vue2 更新机制

知识点核心结论
同步代码当前调用栈内直接执行
setTimeout安排后续任务,不保证立即执行
Promise.then回调通过微任务调度
async/awaitawait 后续执行与 Promise 微任务机制有关
嵌套 Promise新微任务追加到队列尾部
requestAnimationFrame与浏览器渲染更新时机相关
Vue2 响应式 setter数据值立即改变
Vue2 Watcher 队列负责批量调度响应式更新
Watcher 去重避免当前批次内重复安排相同 Watcher
flushSchedulerQueue执行 Watcher 更新队列
flushCallbacks执行 Vue nextTick 内部回调列表
$nextTick等待 Vue 已安排的 DOM 更新
嵌套 $nextTick可能产生新的回调批次
微任务饥饿微任务不断追加,可能阻塞其他任务和渲染

十五、最后留一道综合面试题

如果你已经理解前面的内容,可以试着独立分析下面的代码。

假设 Vue2 组件已挂载,count 是响应式属性,模板直接渲染 count。

console.log('A');

this.count = 1;

Promise.resolve().then(() => {
  console.log('B');

  this.count = 2;

  this.$nextTick(() => {
    console.log(
      'D',
      this.$refs.count.textContent
    );
  });
});

this.$nextTick(() => {
  console.log(
    'C',
    this.$refs.count.textContent
  );
});

console.log('E');

思考三个问题:

  1. Console 的完整输出顺序是什么?
  2. 组件渲染 Watcher 通常会执行几次?
  3. 为什么 C 和 D 读取到的 DOM 内容可能不同?

这道题同时涉及原生 Promise、Vue2 Watcher、两批响应式更新和 $nextTick 的回调顺序。

如果能独立分析清楚,说明你已经不只是会背 Event Loop 的执行顺序,而是真正理解了 Vue2 异步更新机制。


总结

理解 JavaScript Event Loop,不能只记住一句“同步代码先执行,微任务再执行,最后执行宏任务”。

真正重要的是理解:

第一,异步回调是什么时候被安排的。

第二,回调进入了哪个调度队列。

第三,执行过程中是否又创建了新的微任务。

第四,Vue2 如何在浏览器微任务机制之上,实现自己的 Watcher 批量更新和 nextTick 回调调度。

把这些机制串起来,很多过去看似随机的执行顺序就有迹可循了。

一句话概括:Event Loop 决定 JavaScript 回调何时执行,Vue2 Scheduler 决定响应式更新如何合并,nextTick 则提供了等待 Vue DOM 更新完成的接口。

理解这三者的关系,才算真正掌握 Vue2 的异步更新原理。


文章适用范围:浏览器 JavaScript、Vue 2.6 默认异步更新机制。不同浏览器环境、Vue 版本和特殊调度配置可能存在实现差异。