AI图鉴

AI图鉴首页 › AI 资讯 › 从 14 道面试题,彻底搞懂 Event Loop 与 Vue2 nextTic

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

来源:掘金 · 发布时间:10-29 · 由 AI图鉴每小时自动聚合

从 14 道经典执行顺序题入手,由浅入深解析 JavaScript Event Loop、宏任务与微任务、Promise、async/await 及浏览器渲染机制。

从 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 后执行。

整个过程:

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

四、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 队列的重要函数。

它的核心工作包括:

- 对 Watcher 队列排序。

- 依次执行 Watcher。

- 处理刷新相关的生命周期逻辑。

- 重置调度状态,为后续更新做准备。

当渲染 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 更新机制

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

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

假设 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');

思考三个问题:

- Console 的完整输出顺序是什么?

- 组件渲染 Watcher 通常会执行几次?

- 为什么 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 的异步更新原理。

文

本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。

相关 AI 工具

相关免费羊毛

同源资讯

阅读原文(juejin.cn)

标题、摘要与正文均来自该信源的公开页面,版权归原作者所有;本站自动聚合与翻译,仅作出处跳转。

← 返回 AI 资讯列表AI工具图鉴首页

返回 AI图鉴首页 · 提交工具 · 商务合作 · 关于本站 · 597 款 AI 工具分类导航 · 每日 AI 热门项目与动态