事件循环(Event Loop)是 JavaScript 运行时中的核心调度机制,它决定了异步代码在何时被放入调用栈执行。虽然 ECMAScript 规范本身没有定义事件循环,但所有主流宿主环境都实现了相似模型:一个调用栈配合多个任务队列,循环检查调用栈是否为空,并按照优先级取出任务执行。要理解执行顺序,必须先分清三种结构:调用栈、宏任务队列、微任务队列。

调用栈是 JavaScript 引擎执行代码的场所。例如一段脚本执行时,全局上下文首先入栈,函数调用再次入栈,函数返回后出栈。同步代码可以连续压栈出栈,而异步操作不会直接被放入调用栈,而是先在宿主环境中注册。当异步条件满足时,对应的回调会进入相应的任务队列,等待调用栈清空后被执行。这个过程就是事件循环的基本轮廓。
一、宏任务与微任务的分类依据
宏任务是事件循环中最基本的任务单元。一段整体脚本本身就是一个宏任务,此外还包括 setTimeout、setInterval、I/O 操作、UI 渲染等。宏任务的特点是按顺序从队列中取出一个执行,执行过程中可能产生新的宏任务或微任务,新产生的任务会被追加到队列末尾。
微任务是一类比宏任务优先级更高的异步任务。常见来源包括 Promise.then、Promise.catch、Promise.finally、queueMicrotask 以及浏览器中的 MutationObserver。微任务队列通常由 JavaScript 引擎直接维护,当当前宏任务执行完毕、调用栈清空后,引擎会立即清空微任务队列,直到队列为空才继续下一轮循环。
| 任务类型 | 常见来源 | 执行特征 |
|---|---|---|
| 宏任务 | 整体 script、setTimeout、setInterval、I/O | 每次只执行一个,队列可能被新任务追加 |
| 微任务 | Promise 回调、queueMicrotask、MutationObserver | 当前宏任务结束后一次性清空所有微任务 |
从表中可以看出,宏任务与微任务并非简单的先后关系,而是存在嵌套和优先级差异。一个宏任务执行过程中,凡是调用 Promise.resolve().then(...) 或 queueMicrotask(...),回调都会进入微任务队列。即使在这段代码后面还有另一个 setTimeout,微任务也一定先于下一个宏任务执行,因为清空微任务队列是每一轮循环的固定步骤。
二、执行流程与代码输出验证
标准的事件循环一轮可以概括为以下步骤:先执行一个宏任务;执行完该宏任务后,如果调用栈为空,则清空微任务队列;微任务清空后,浏览器会选择是否进行渲染;最后进入下一轮循环,取出下一个宏任务。这里需要特别强调:宏任务每次只执行一个,微任务每次清空整个队列。
下面这段代码可以验证这个规则:
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function () {
console.log('promise1');
}).then(function () {
console.log('promise2');
});
console.log('script end');
输出结果是:script start、script end、promise1、promise2、setTimeout。原因在于整体脚本是第一个宏任务,执行时先同步打印 script start 和 script end。setTimeout 回调被注册为宏任务,进入宏任务队列。两个 Promise.then 回调进入微任务队列。当前宏任务结束后,引擎清空微任务队列,依次打印 promise1 和 promise2。之后才进入下一轮循环,执行 setTimeout 宏任务,打印 setTimeout。
如果微任务中继续产生新的微任务,执行顺序依然不会跳出清空阶段。例如一个 Promise.then 回调里再次调用 Promise.resolve().then,新产生的微任务会追加到当前微任务队列末尾,并在本轮内被执行。这是微任务与宏任务最显著的区别之一,也是某些异步递归可能导致页面长时间不渲染的原因。
三、Promise 和 async/await 中的顺序陷阱
async/await 本质上是对 Promise 的语法封装,但它会带来一些容易混淆的执行顺序。async 函数调用时,其内部代码会同步执行到第一个 await 表达式。await 后面的表达式会先求值,而 await 之后的代码会被包裹成微任务,等待当前同步代码执行完毕后进入微任务队列。
看下面的例子:
async function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar');
}
console.log('global start');
foo();
console.log('global end');
输出顺序是:global start、foo start、bar、global end、foo end。调用 foo 时同步打印 foo start,然后执行 await bar(),bar 是 async 函数,会同步打印 bar 并返回一个已解决状态的 Promise。注意 await 之后的 console.log('foo end') 并不会立即执行,而是被放入微任务队列。此时 foo 函数暂停,控制权返回到全局作用域,继续打印 global end。当前宏任务结束后,微任务队列中的回调执行,打印 foo end。
另一个常见陷阱是 return await 与直接 return 的区别。return await promise 会比 return promise 多产生一次微任务并影响后续链式回调的顺序。虽然结果值可能相同,但在 try/catch 捕获错误、堆栈信息以及顺序敏感场景下存在差异。理解 await 总是会拆解一层 Promise 并让后续代码延后到微任务,是吃透 async/await 的关键。
四、微任务与浏览器渲染及 Vue nextTick 的关联
浏览器渲染并不是一个独立的任务队列,而是事件循环中的可选阶段。每轮循环在清空微任务之后,浏览器会判断当前是否需要重新渲染。如果需要渲染,会先执行 requestAnimationFrame 回调,再进行样式计算、布局和绘制。因此,微任务总是在渲染之前执行。这意味着如果在微任务中修改 DOM,用户几乎感觉不到中间状态。
Vue 的 nextTick 就是利用微任务这一特性来实现 DOM 更新后的回调。在数据变化时,Vue 并不会立即更新真实 DOM,而是将组件更新函数推入一个队列,并在下一个微任务中批量执行。nextTick 提供的回调会在组件更新完成后运行,因此开发者可以在回调中安全地访问已更新的 DOM。不同实现版本的回退策略不同,早期可能会使用 MutationObserver 或 setTimeout,目前主流版本优先使用 Promise.then,属于典型的微任务实现。
由于微任务会在下一次渲染前执行完毕,Vue 的异步更新可以让多次数据变更只触发一次 DOM 更新,从而提升性能。这也解释了为什么在修改数据后立即读取 DOM,获取到的可能还是旧值,而放在 nextTick 回调中就能读到新值。理解这一点,能帮助开发者避免许多响应式数据相关的时序问题。
综合以上分析,事件循环的执行顺序可以总结为:一个宏任务 → 清空所有微任务 → 可选渲染 → 下一个宏任务。微任务内部新增的微任务仍会在本轮清空,宏任务内部新增的宏任务则必须等待下一轮。掌握这个规则后,无论是 setTimeout 与 Promise 混合使用,还是 async/await 中的 await 后续代码,都能准确预判其执行顺序。