JavaScript 事件循环深度解析
JavaScript 是单线程语言,但它能处理异步操作而不阻塞主线程——这一切都依赖于**事件循环(Event Loop)**机制。理解事件循环不仅能帮你预测代码的执行顺序,还能写出更高效的异步代码。
在开始前,先来做一道测试题:
热身 Quiz
请预测以下代码的输出顺序:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
答案是:1 → 4 → 3 → 2
为什么 3 在 2 前面?即使 setTimeout 的延迟是 0?这就是事件循环的核心问题。
核心概念
调用栈(Call Stack)
调用栈是 JavaScript 执行同步代码的地方。代码按照**后进先出(LIFO)**的顺序执行。
function a() {
b();
console.log("a");
}
function b() {
console.log("b");
}
a();
// 输出:b → a
// 调用栈变化:[a] → [a, b] → [a](b 执行完出栈)→ [](a 执行完出栈)
任务队列的两种类型
JavaScript 的异步任务分为两大类,优先级不同:
微任务(Microtask)——优先级高,当前任务结束后立刻执行,清空队列才进行下一轮:
Promise.then/.catch/.finallyqueueMicrotask()MutationObserver
宏任务(Macrotask / Task)——优先级低,每次事件循环只取一个执行:
setTimeoutsetIntervalsetImmediate(Node.js)- I/O 操作
- UI 渲染
事件循环的执行流程
┌──────────────────────────────────────┐
│ Call Stack(调用栈) │
│ 执行同步代码,遇到异步任务交给 Web API │
└──────────────┬───────────────────────┘
│ 同步代码执行完毕
▼
┌──────────────────────────────────────┐
│ Microtask Queue(微任务队列) │
│ 清空所有微任务(包括新产生的微任务) │
└──────────────┬───────────────────────┘
│ 微任务队列清空
▼
┌──────────────────────────────────────┐
│ Macrotask Queue(宏任务队列) │
│ 取出一个宏任务执行 │
└──────────────┬───────────────────────┘
│ 宏任务执行完毕,再次检查微任务
└──────────────────── (循环)
一句话记住核心规则:每个宏任务执行完后,都要清空所有微任务,然后才执行下一个宏任务。
逐步分析 Quiz
回到开头的例子,逐步拆解:
console.log("1"); // 同步,立刻入栈执行 → 输出 1
setTimeout(() => console.log("2"), 0); // 异步,注册宏任务
Promise.resolve().then(() => console.log("3")); // 异步,注册微任务
console.log("4"); // 同步,立刻入栈执行 → 输出 4
// 此时调用栈清空
// 检查微任务队列:有 console.log("3") → 输出 3,微任务队列清空
// 检查宏任务队列:有 console.log("2") → 输出 2
输出:1 → 4 → 3 → 2 ✓
进阶 Quiz:链式 Promise
Promise.resolve()
.then(() => {
console.log("A");
return Promise.resolve("B");
})
.then((val) => console.log(val));
Promise.resolve().then(() => console.log("C"));
请先思考输出顺序……
答案是:A → C → B
关键点:return Promise.resolve("B") 会在微任务队列中多插入一层。等价于:
// 内部行为类似:
.then(() => {
// 需要等待 Promise.resolve("B") resolve
// 这会额外消耗一个微任务 tick
return Promise.resolve("B");
})
所以 C 比 B 更早执行,因为 C 的 .then 回调是单独的微任务,而 B 需要额外等一个 tick 才能传递到下一个 .then。
async/await 的本质
async/await 是 Promise 的语法糖,理解它的展开形式有助于预测执行顺序:
async function fetchData() {
console.log("fetch start");
const result = await Promise.resolve("data");
console.log("fetch end:", result);
}
console.log("before");
fetchData();
console.log("after");
展开后等价于:
console.log("before");
// async 函数体开始同步执行
console.log("fetch start");
// await 相当于 .then,后面的代码注册为微任务
Promise.resolve("data").then((result) => {
console.log("fetch end:", result);
});
// async 函数暂停,控制权返回调用处
console.log("after");
// 调用栈清空,执行微任务
// 输出:fetch end: data
输出:before → fetch start → after → fetch end: data
requestAnimationFrame 的位置
requestAnimationFrame(rAF)的执行时机比较特殊,它不是微任务也不是普通宏任务,而是在浏览器准备绘制下一帧时执行(通常约 16.6ms 一次)。
执行顺序:宏任务 → 微任务 → rAF → 浏览器渲染 → 下一个宏任务
setTimeout(() => console.log("setTimeout"), 0);
requestAnimationFrame(() => console.log("rAF"));
Promise.resolve().then(() => console.log("Promise"));
console.log("sync");
输出(通常):sync → Promise → setTimeout → rAF
注意:
setTimeout和rAF的相对顺序在不同场景下可能不固定,取决于浏览器的渲染调度,但Promise始终最先。
实际应用:避免长任务阻塞
理解事件循环后,可以用它来优化性能。如果一个计算任务很耗时,可以将它拆分成多个宏任务,避免阻塞渲染:
// ❌ 糟糕:10000 次循环直接阻塞页面
function processData(data) {
for (let i = 0; i < data.length; i++) {
heavyCompute(data[i]);
}
}
// ✅ 好:用 setTimeout 把任务切片,让浏览器有机会渲染
function processDataInChunks(data, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
heavyCompute(data[i]);
}
index = end;
if (index < data.length) {
setTimeout(processChunk, 0); // 交还控制权给浏览器
}
}
processChunk();
}
现代浏览器也提供了更优雅的 API:
// 使用 scheduler.postTask(Chrome 94+)按优先级调度任务
scheduler.postTask(() => heavyTask(), { priority: "background" });
// 使用 requestIdleCallback 在浏览器空闲时执行
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
processNextItem();
}
});
终极 Quiz
综合以上知识,预测输出:
console.log("start");
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("promise in timeout"));
}, 0);
Promise.resolve()
.then(() => {
console.log("promise 1");
setTimeout(() => console.log("timeout 2"), 0);
})
.then(() => console.log("promise 2"));
console.log("end");
逐步分析:
- 同步执行:
start→end - 微任务队列有
promise 1的回调:输出promise 1,注册timeout 2到宏任务队列 - 链式
.then产生微任务:输出promise 2 - 微任务队列清空,取出第一个宏任务
timeout 1:输出timeout 1,注册微任务promise in timeout - 宏任务执行完,清空微任务:输出
promise in timeout - 取出下一个宏任务
timeout 2:输出timeout 2
最终输出:start → end → promise 1 → promise 2 → timeout 1 → promise in timeout → timeout 2
总结
| 类型 | 代表 API | 执行时机 | 优先级 |
|---|---|---|---|
| 同步代码 | 普通语句 | 立即 | 最高 |
| 微任务 | Promise.then, queueMicrotask | 调用栈清空后立刻 | 高 |
| requestAnimationFrame | rAF | 浏览器绘制前 | 中 |
| 宏任务 | setTimeout, setInterval | 每轮事件循环一个 | 低 |
掌握事件循环的核心规则——每个宏任务执行完都要清空所有微任务——就能准确预测几乎所有异步代码的执行顺序。这也是面试中的高频考点,更是写好异步代码的理论基础。