事件循环是一个持续运行的过程,它监视调用栈和任务队列。当调用栈为空时,事件循环会从队列中取出任务执行。关键在于它维护了两种队列:
- 宏任务队列 (Macrotask Queue) :包括整个
script代码块、setTimeout、setInterval、I/O 操作、UI 渲染等 。 - 微任务队列 (Microtask Queue) :包括
Promise.then/catch/finally的回调、MutationObserver的回调、queueMicrotask()等。
核心执行规则是:在一个宏任务执行完毕后,事件循环会立即清空整个微任务队列,然后再从宏任务队列中取出下一个宏任务执行。 这意味着微任务的优先级高于宏任务。
一个形象比喻
可以把事件循环想象成一个餐厅厨房的操作流程:
- 宏任务:就像厨师接到的大菜单,比如做一份牛排、煮一锅汤。这些菜需要花时间准备。
- 微任务:就像厨师做菜过程中收到的加急小单,比如“这份牛排多加一份酱汁”、“客人要一杯水”。
规则是这样的:厨师每次只做一道大菜(执行一个宏任务)。这道菜做完后,他不会马上做下一道大菜,而是先把所有积压的加急小单全部处理完(清空微任务队列),然后再从菜单上取下道大菜继续做。
为什么这么设计?
因为加急小单(微任务)通常需要快速响应,比如 Promise 的回调、DOM 变化观察,如果拖到后面,客人可能就不耐烦了。所以每做完一道大菜,必须立刻把所有紧急小事搞定,才能开始下一道大菜。
注意:如果处理小单的过程中又来了新小单(比如微任务里又产生微任务),厨师也得把它们都干完,才能回到大菜上。这就是“清空整个微任务队列”的意思。
这样一来,既保证了主要菜品(宏任务)能持续推进,又确保了紧急需求(微任务)不会被耽误。
复杂场景代码执行顺序分析
结合上述规则,分析以下包含 setTimeout (宏任务)、async/await (基于Promise) 和 Promise (微任务) 的代码:
javascript
async function async1() {
console.log('async1 start'); // 同步代码
await async2(); // 执行 async2,然后等待其 Promise 解析
console.log('async1 end'); // 被阻塞的部分,作为微任务
}
async function async2() {
console.log('async2'); // 同步代码
}
console.log('script start'); // 同步代码
setTimeout(function () {
console.log('setTimeout'); // 宏任务
}, 0);
async1();
new Promise(function (resolve) {
console.log('promise1'); // 同步代码
resolve();
}).then(function () {
console.log('promise2'); // 微任务
});
console.log('script end'); // 同步代码
根据事件循环的执行顺序,我们可以一步步推导输出结果:
| 步骤 | 执行的代码 | 说明 |
|---|---|---|
| 1 | console.log('script start') | 执行第一个同步代码。 |
| 2 | setTimeout(...) | 将其回调函数放入宏任务队列,标记为 H1。 |
| 3 | async1() 被调用 | 函数开始执行。 |
| 3.1 | console.log('async1 start') | async1 内部的第一个同步代码,直接执行。 |
| 3.2 | await async2() | 执行 async2。async2 内部的 console.log('async2') 是同步代码,直接执行。await 关键字使后续代码暂停,并将 console.log('async1 end') 作为微任务放入队列,标记为 W1 。 |
| 4 | new Promise(executor) | Promise 的构造函数是同步执行的,所以立即输出 promise1。随后 resolve() 被调用,将其 .then() 中的回调作为微任务放入队列,标记为 W2。 |
| 5 | console.log('script end') | 执行最后一个同步代码。 |
| 6 | 清空微任务队列 | 同步代码执行完毕,调用栈为空。事件循环检查微任务队列,并开始依次执行。先执行 W1 (async1 end),后执行 W2 (promise2)。 |
| 7 | 执行下一个宏任务 | 微任务队列清空后,从宏任务队列取出 H1 (setTimeout 回调) 并执行,输出 setTimeout。 |
因此,最终输出结果为:
text
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
注意:在 Chrome 72 版本之前,由于规范差异,
async1 end可能会在promise2之后输出。但新版本已优化,使其更符合上述顺序 。
利用事件循环优化长任务,避免UI阻塞
JavaScript 运行在单线程上,如果执行一个耗时的同步任务(如大量数据计算、复杂循环),它会长时间占用主线程,导致无法响应用户点击、无法更新UI,造成页面卡顿甚至“假死”。利用事件循环机制,可以将长任务拆解,让出主线程。
1. 将任务拆分为微任务(适用于可分解的轻量级操作)
可以使用 queueMicrotask 或 Promise 将一个大任务拆分成多个步骤,在每个微任务中处理一小部分。但需谨慎使用,因为微任务队列会在当前宏任务结束后被持续清空。 如果递归地创建微任务而不结束,会一直阻塞后续宏任务(如UI渲染),导致页面无法更新,这种现象称为“微任务饥饿”。
javascript
// 不推荐:递归微任务会阻塞 UI
function recursiveMicrotask() {
Promise.resolve().then(recursiveMicrotask); // 无限循环,UI 永远不会更新 [citation:9]
}
2. 将任务拆分为宏任务(推荐方案)
更安全的方式是使用 setTimeout 将长任务切分为多个宏任务。这样,在每个宏任务执行完毕后,浏览器都有机会在清空微任务后执行UI渲染,从而保持页面响应 。
假设我们有一个耗时的数据处理函数 processData,可以将其拆分为多个块执行:
javascript
function processLargeArray(largeArray) {
const chunkSize = 100; // 每批处理 100 条数据
let index = 0;
function processChunk() {
// 处理当前批次的数据
const chunk = largeArray.slice(index, index + chunkSize);
for (const item of chunk) {
// 执行一些复杂计算或 DOM 操作
console.log('Processing:', item);
}
index += chunkSize;
if (index < largeArray.length) {
// 如果还有数据,安排下一个宏任务继续处理
setTimeout(processChunk, 0); // 使用 setTimeout 让出主线程
} else {
console.log('All data processed');
}
}
// 启动第一个宏任务
setTimeout(processChunk, 0);
}
在这个例子中,setTimeout(..., 0) 将 processChunk 放入宏任务队列。处理完一批数据后,如果还有数据,再次通过 setTimeout 安排下一批。在每个宏任务之间,浏览器可以处理用户交互、渲染UI更新(如显示加载状态)。
3. 使用 requestIdleCallback 或 Web Workers
requestIdleCallback:允许在浏览器空闲时段执行低优先级任务,避免影响关键操作。- Web Workers:对于纯计算的复杂任务,最好的方案是将其完全移出主线程,在后台线程中运行,完成后通过消息传递结果回主线程。这从根本上解决了主线程阻塞问题。
| 优化策略 | 核心思想 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 微任务拆分 | 将任务分解,利用 queueMicrotask 或 Promise.then 在清空微任务阶段执行。 | 需要尽快执行、但量级不大的异步操作。 | 优先级高,在当前宏任务结束后立即执行。 | 容易造成微任务队列无限循环,导致UI无法更新。 |
| 宏任务拆分 | 将任务分解,利用 setTimeout 或 setInterval 将任务分块放入宏任务队列。 | 耗时较长、需要让出主线程以响应用户操作或渲染的任务。 | 有效避免主线程长时间阻塞,UI能够保持响应。 | 执行优先级较低,可能被高优先级任务插队。 |
requestIdleCallback | 在浏览器空闲时段执行任务。 | 非紧急的后台任务,如数据上报、预加载等。 | 不会影响关键路径上的操作。 | 浏览器兼容性问题,执行时间不确定。 |
| Web Workers | 在独立的后台线程执行JavaScript。 | 纯计算密集型任务,如图像/视频处理、大量数据计算。 | 彻底解放主线程,不影响UI交互和渲染 。 | 无法直接操作DOM,需要通过消息传递数据。 |