事件循环与异步模型
JavaScript 是单线程语言,却能处理定时器、网络请求、用户交互等各种并发任务,这背后靠的就是事件循环(Event Loop)。理解调用栈、任务队列、宏任务与微任务的分工,才能准确预测异步代码的执行顺序。本文层层拆解这套机制,并用经典面试题验证理解。
一、单线程与异步的必要性
JavaScript 运行在浏览器主线程上,同一时刻只能执行一段代码。如果执行耗时操作时不让出线程,页面就会卡死、无法响应点击:
// 同步阻塞:耗时 3 秒,期间页面完全无响应
function block() {
const start = Date.now();
while (Date.now() - start < 3000) {
// 空转 3 秒
}
}
block(); // 阻塞主线程
console.log("3 秒后才执行");因此 JavaScript 采用异步回调模型:耗时的 I/O 操作交给浏览器(或 Node.js)的底层 API 处理,完成后把回调放进队列,等主线程空闲再执行。这样主线程始终能快速响应。
| 场景 | 同步写法 | 异步写法 |
|---|---|---|
| 网络请求 | 阻塞等待响应,页面卡死 | 发起后继续执行,回调中处理结果 |
| 定时器 | 无 | setTimeout 到点执行回调 |
| 文件读取 | 阻塞 | 读取完成触发回调 |
二、调用栈(Call Stack)
2.1 执行过程
调用栈记录函数调用关系:每进入一个函数就压栈,函数返回就出栈,栈空表示脚本执行完毕。
function third() {
return 3;
}
function second() {
return third(); // 压入 third
}
function first() {
return second(); // 压入 second
}
first();
// 栈的变化:first → first,second → first,second,third → 依次弹出2.2 栈溢出(Stack Overflow)
递归没有退出条件时,栈被无限压满,抛出 RangeError: Maximum call stack size exceeded:
function recursion() {
return recursion(); // 无限递归
}
recursion(); // RangeError: Maximum call stack size exceeded三、Web APIs 与回调队列
浏览器为 JavaScript 提供了一组 Web APIs(setTimeout、fetch、addEventListener、XMLHttpRequest 等)。它们由浏览器其他线程执行,不占用 JS 主线程。完成后,回调进入**任务队列(Task Queue)**等待:
console.log("1");
setTimeout(() => {
console.log("2"); // 回调先进入队列,等主线程空闲
}, 0);
console.log("3");
// 输出顺序:1 3 2执行过程:
- 主线程依次执行
1,遇到setTimeout交给浏览器计时; - 继续执行
3,主线程空闲; - 浏览器把定时器回调放入任务队列;
- 事件循环取出回调执行,打印
2。
四、宏任务与微任务
4.1 两类任务
| 任务类型 | 常见来源 | 特点 |
|---|---|---|
| 宏任务(macrotask/task) | setTimeout、setInterval、I/O、UI 渲染、setImmediate(Node) | 每轮事件循环执行一个 |
| 微任务(microtask) | Promise.then、queueMicrotask、MutationObserver、process.nextTick(Node) | 宏任务结束后一次性清空全部 |
console.log("同步代码");
setTimeout(() => console.log("宏任务"), 0);
Promise.resolve().then(() => console.log("微任务"));
queueMicrotask(() => console.log("另一个微任务"));
// 输出:同步代码 → 微任务 → 另一个微任务 → 宏任务4.2 为什么微任务先执行
微任务队列属于当前宏任务的"收尾"阶段:一个宏任务执行完后,事件循环会先清空所有微任务,再进入渲染和下一个宏任务。因此微任务总是"插队"在宏任务之前。
五、事件循环执行流程(一个 tick)
浏览器的每一轮事件循环(tick)大致如下:
执行一个宏任务(从宏任务队列取队首)
└→ 执行过程中产生的微任务全部入队
清空微任务队列(挨个执行,执行中新产生的微任务也继续执行)
执行渲染(requestAnimationFrame 回调、样式计算、绘制,按需触发)
回到第一步,取下一个宏任务// 用代码验证"微任务先于下一个宏任务"
setTimeout(() => console.log("宏任务 A"), 0);
Promise.resolve().then(() => console.log("微任务 1"));
Promise.resolve().then(() => console.log("微任务 2"));
setTimeout(() => console.log("宏任务 B"), 0);
// 输出:微任务 1 → 微任务 2 → 宏任务 A → 宏任务 B微任务执行中产生的微任务也会在当前轮清空,不会推迟到下一轮:
queueMicrotask(() => {
console.log("微任务 1");
queueMicrotask(() => console.log("微任务 1 内产生的微任务"));
});
queueMicrotask(() => console.log("微任务 2"));
// 输出:微任务 1 → 微任务 2 → 微任务 1 内产生的微任务六、经典执行顺序题
6.1 第一题:setTimeout 与 Promise
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
new Promise((resolve) => {
console.log("3");
resolve();
}).then(() => {
console.log("4");
});
console.log("5");
// 输出顺序:1 3 5 4 2分析:
console.log("1")同步执行;setTimeout回调注册为宏任务;new Promise的 executor 同步执行,打印3,调用resolve();.then回调注册为微任务;- 打印
5,主线程同步代码结束; - 清空微任务队列,打印
4; - 执行宏任务,打印
2。
6.2 第二题:多层嵌套
setTimeout(() => {
console.log("外层 setTimeout");
setTimeout(() => {
console.log("内层 setTimeout");
}, 0);
}, 0);
Promise.resolve().then(() => {
console.log("微任务 1");
Promise.resolve().then(() => console.log("微任务 2"));
});
// 输出:微任务 1 → 微任务 2 → 外层 setTimeout → 内层 setTimeout分析:主线程结束后,第一轮 tick 先清空微任务(打印 微任务 1、微任务 2),再执行宏任务"外层 setTimeout";其内部注册的"内层 setTimeout"作为新宏任务,进入下一轮才执行。
6.3 第三题:嵌套 + 宏微混合
console.log("start");
setTimeout(() => {
console.log("timeout1");
Promise.resolve().then(() => {
console.log("promise-in-timeout");
});
}, 0);
Promise.resolve().then(() => {
console.log("promise1");
setTimeout(() => {
console.log("timeout2");
}, 0);
});
console.log("end");
// 输出顺序:start → end → promise1 → timeout1 → promise-in-timeout → timeout2分析:
- 同步打印
start、end; - 清空微任务:打印
promise1,此时注册宏任务timeout2; - 执行宏任务
timeout1,打印timeout1,注册微任务promise-in-timeout; - 当前宏任务结束后清空微任务,打印
promise-in-timeout; - 下一轮宏任务打印
timeout2。
6.4 第四题:async/await 与 then
async function foo() {
console.log("1");
await bar();
console.log("2");
}
async function bar() {
console.log("3");
}
console.log("4");
foo();
console.log("5");
// 输出顺序:4 1 3 5 2分析:await 右侧先同步执行(打印 1、3),之后 await 后续代码被当作微任务(打印 2 延后到同步代码结束后)。
七、浏览器与 Node.js 事件循环差异
7.1 浏览器
宏任务队列与微任务队列构成单一循环,微任务每轮宏任务后清空。
7.2 Node.js
Node.js 的事件循环分为多个阶段(phase),每个阶段处理一类任务:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ timers │→ │ poll │→ │ check │→ ...
│ setTimeout│ │ I/O 回调 │ │ setImmediate│
└──────────┘ └──────────┘ └──────────┘
每个阶段之间都会清空微任务队列| 对比项 | 浏览器 | Node.js |
|---|---|---|
| 宏任务模型 | 单队列,每轮取一个 | 分阶段,每阶段有独立队列 |
setImmediate | 不支持 | check 阶段执行 |
process.nextTick | 不支持 | 微任务之前执行(优先级最高) |
requestAnimationFrame | 渲染前执行 | 不支持 |
// Node.js 中:nextTick 优先于 Promise 微任务
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("Promise"));
console.log("同步");
// 输出:同步 → nextTick → Promise7.3 setTimeout 与 setImmediate
在 Node.js 中两者顺序不固定(取决于进入事件循环的时机),I/O 回调内 setImmediate 总是先执行:
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("setTimeout"), 0);
setImmediate(() => console.log("setImmediate"));
});
// I/O 回调内:setImmediate 先于 setTimeout八、requestAnimationFrame 与微任务
requestAnimationFrame(rAF)的回调在渲染前执行,位于微任务清空之后、绘制之前:
console.log("1");
setTimeout(() => console.log("宏任务"), 0);
Promise.resolve().then(() => console.log("微任务"));
requestAnimationFrame(() => console.log("rAF"));
// 通常输出:1 → 微任务 → rAF → 宏任务执行顺序总结(单次渲染帧内):
宏任务 → 所有微任务 → rAF 回调 → 布局与绘制 → 下一个宏任务这也是为什么动画应使用 rAF 而非 setTimeout:rAF 与浏览器刷新频率同步,且能在绘制前拿到最新帧数据。
九、阻塞事件循环的后果
长耗时任务会阻塞整个事件循环,定时器、Promise、事件全部延迟:
setTimeout(() => console.log("定时器"), 0);
// 同步阻塞 2 秒
const start = Date.now();
while (Date.now() - start < 2000) {}
console.log("阻塞结束");
// 定时器回调要等 2 秒阻塞结束后才执行实践中应避免在主线程执行重计算,可拆分为小任务分批执行,或交给 Web Worker:
// 用 setTimeout 把大任务拆成小块,避免长时间占用主线程
function processInChunks(items, chunkSize) {
let index = 0;
function next() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
// 处理 items[index]
}
if (index < items.length) {
setTimeout(next, 0); // 让出主线程,下一轮继续
}
}
next();
}
processInChunks(Array.from({ length: 10000 }, (_, i) => i), 1000);