从 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图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。