JavaScript 事件循环与异步编程深度解析:从宏任务到微任务的完整链路
先从一个 bug 说起
去年同事写了一个轮询函数,页面跑了 30 秒后浏览器直接卡死:
function pollServer() {
while (true) {
const response = fetch('/api/status'); // 本意是每秒查一次
// ...
}
}
他一眼没看出问题——fetch 是异步的,while(true) 怎么会阻塞?这就是经典的「把异步当多线程」误区。while(true) 在主线程上死循环,事件循环根本没有机会去执行 fetch 的回调。理解事件循环,就是理解 JavaScript 的单线程并发模型。你会发现面试官最爱问的那些 Promise 执行顺序题,解起来其实有规律可循。
调用栈(Call Stack)
JavaScript 引擎只有一个调用栈。函数调用往里压,返回往外弹:
function multiply(a, b) {
return a * b; // 4. 执行乘法,返回结果
}
function square(n) {
const result = multiply(n, n); // 3. 调用 multiply,压入栈
return result; // 5. 返回
}
function main() {
const value = square(5); // 2. 调用 square,压入栈
console.log(value); // 6. 输出 25
}
main(); // 1. 调用 main,压入栈
用图来表示这个压栈过程:
栈底 → 栈顶
main() 入栈
main() → square(5) 入栈
main() → square(5) → multiply(5, 5) 入栈
main() → square(5) → multiply 返回 → 出栈
main() → square 返回 → 出栈
main() → console.log(25) 入栈 → 出栈
main 返回 → 出栈
栈空
调用栈爆了就是 RangeError: Maximum call stack size exceeded——通常是无限递归。在 Chrome 里调用栈上限约 10000 帧。
任务队列(Task Queue)
调用栈空了,事件循环从任务队列里取一个任务执行。任务队列里的每一项叫宏任务(MacroTask)。
哪些操作会产生宏任务?
| 来源 | 示例 |
|---|---|
setTimeout / setInterval |
setTimeout(() => {}, 0) |
| I/O 操作 | 文件读写、网络请求回调 |
| UI 渲染 | requestAnimationFrame(渲染前)、浏览器渲染帧 |
| 用户交互 | click、keydown 事件回调 |
setImmediate |
Node.js 专用 |
MessageChannel |
port.postMessage() |
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 输出:1 → 3 → 2
// 因为 setTimeout 的回调被放入宏任务队列,
// 必须等当前调用栈清空后才能执行
即使用 setTimeout(fn, 0),fn 也要等当前所有同步代码执行完。这不是延迟 0ms,而是「尽快,但不在当前这个 tick」。
微任务(MicroTask)
微任务是 ECMAScript 规范中的概念——每个宏任务执行完毕后,事件循环会清空整个微任务队列,再取下一个宏任务。
产生微任务的操作:
| 来源 | 示例 |
|---|---|
Promise.then/catch/finally |
Promise.resolve().then(fn) |
async/await(await 之后的部分) |
await something |
queueMicrotask |
queueMicrotask(fn) |
MutationObserver |
DOM 变化回调 |
这是面试题的核心考点:微任务在宏任务之前执行。
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => console.log('5'), 0);
});
console.log('6');
// 请先自己推算,再看答案 ↓
// 输出:1 → 6 → 4 → 2 → 3 → 5
//
// 解析:
// 1. 同步代码:输出 1、6
// 2. 微任务队列:[then(()=>4)] → 输出 4,将 setTimeout(5) 放入宏任务
// 3. 宏任务队列:[setTimeout(2), setTimeout(5)]
// 4. 取第一个宏任务:输出 2,then(()=>3) 放入微任务
// 5. 清空微任务:输出 3
// 6. 取第二个宏任务:输出 5
Promise 执行顺序终极归纳
把上面两个队列的关系串起来,得到完整的事件循环模型:
┌──────────────────────────────────────┐
│ 调用栈(同步) │
│ ┌────────────────────────────────┐ │
│ │ function A → function B → ... │ │
│ └────────────────────────────────┘ │
│ ↓ 调用栈空了 │
│ ┌────────────────────────────────┐ │
│ │ 微任务队列(清空整个队列) │ │
│ │ then → then → queueMicrotask │ │
│ └────────────────────────────────┘ │
│ ↓ 微任务空了 │
│ ┌────────────────────────────────┐ │
│ │ 宏任务队列(取一个执行) │ │
│ │ setTimeout → click → I/O → ...│ │
│ └────────────────────────────────┘ │
│ ↓ 回到调用栈 │
│ 循环往复... │
└──────────────────────────────────────┘
一个完整的 tick:
- 取一个宏任务,执行
- 执行过程中产生的微任务全部在当前 tick 内执行完
- 微任务中产生的微任务也会在同一个 tick 执行完(递归清空)
- 渲染(如果需要)
- 取下一个宏任务
复合题
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 输出:script start → async1 start → async2 → promise1
// → script end → async1 end → promise2 → setTimeout
//
// 关键理解:
// - Promise 构造器里的代码是同步的
// - async 函数内部 await 之前的代码是同步的
// - await 相当于 Promise.resolve().then(...),await 后面的代码进微任务
async/await 的底层转换
async/await 本质上是 Generator + Promise 的语法糖。
// 我们写的:
async function fetchUser() {
const response = await fetch('/api/user');
const user = await response.json();
return user;
}
// 等价于:
function fetchUser() {
return Promise.resolve().then(() => {
return fetch('/api/user');
}).then((response) => {
return response.json();
});
}
// 更精确的等价(用 Generator 模拟):
function asyncToGenerator(generatorFunc) {
return function () {
const gen = generatorFunc.apply(this, arguments);
return new Promise((resolve, reject) => {
function step(key, arg) {
let result;
try {
result = gen[key](arg);
} catch (error) {
return reject(error);
}
const { value, done } = result;
if (done) {
return resolve(value);
} else {
return Promise.resolve(value).then(
(val) => step('next', val),
(err) => step('throw', err)
);
}
}
step('next');
});
};
}
这里有一个关键细节:await 后面的表达式会用 Promise.resolve() 包装。如果 await 后面不是一个 Promise,会被自动转为已解决的 Promise。所以 await 42 等价于 await Promise.resolve(42)。
Node.js 中的事件循环
Node.js 的事件循环比浏览器复杂——它分了 6 个阶段,每个阶段有自己的 FIFO 队列:
┌───────────────────────┐
┌─>│ timers │ setTimeout、setInterval 回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ pending callbacks │ 延迟到下一轮的 I/O 回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │ 内部使用
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ poll │ 获取新的 I/O 事件(核心阶段)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ check │ setImmediate 回调
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └──────────┬────────────┘
└──────────────┘
每个阶段结束后 → 清空微任务队列
Node.js vs 浏览器的关键差异
| 差异点 | 浏览器 | Node.js |
|---|---|---|
| 宏任务类型 | setTimeout, I/O, UI 渲染 | 6 个阶段各有不同队列 |
setImmediate |
不存在 | check 阶段执行 |
process.nextTick |
不存在 | 优先级高于微任务 |
| 微任务时机 | 每个宏任务后 | 每个阶段切换时 + 每个宏任务后 |
await 后的微任务 |
插在当前微任务队列 | 同浏览器 |
process.nextTick 的陷阱
console.log('1');
process.nextTick(() => {
console.log('2');
process.nextTick(() => console.log('3'));
});
Promise.resolve().then(() => console.log('4'));
console.log('5');
// 输出:1 → 5 → 2 → 3 → 4
// 注意:nextTick 优先于 Promise 微任务!
// 而且 nextTick 回调里的 nextTick 会插入当前队列头部
process.nextTick 并不属于事件循环的任何阶段。它在当前操作完成后、事件循环继续前立即执行。递归调用会饿死事件循环:
// ❌ 危险:事件循环永远不会前进
function starve() {
process.nextTick(starve);
}
实战调优:避免长任务阻塞
问题:一次渲染 10000 条数据
// ❌ 主线程卡死数秒
function renderAll(items) {
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.name;
container.appendChild(div);
});
}
方案一:时间切片(Time Slicing)
function renderChunked(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
const div = document.createElement('div');
div.textContent = items[index].name;
container.appendChild(div);
}
if (index < items.length) {
// 交出控制权,让浏览器处理 UI
setTimeout(processChunk, 0);
}
}
processChunk();
}
方案二:requestIdleCallback
function renderIdle(items) {
let index = 0;
function processIdle(deadline) {
// deadline.timeRemaining() 返回当前帧剩余时间
while (index < items.length && deadline.timeRemaining() > 1) {
const div = document.createElement('div');
div.textContent = items[index].name;
container.appendChild(div);
index++;
}
if (index < items.length) {
requestIdleCallback(processIdle);
}
}
requestIdleCallback(processIdle);
}
方案三:Web Worker(真正并行)
// main.js
const worker = new Worker('/workers/data-processor.js');
worker.postMessage({ data: rawData, action: 'transform' });
worker.onmessage = (event) => {
// 渲染处理好的数据
renderData(event.data);
};
// workers/data-processor.js
self.onmessage = (event) => {
const { data, action } = event.data;
if (action === 'transform') {
const processed = data.map(item => ({
...item,
displayName: item.name.toUpperCase(),
formattedDate: new Date(item.createdAt).toLocaleDateString(),
}));
self.postMessage(processed);
}
};
三种方案的选择:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| DOM 批量操作 | 时间切片 | Worker 无法操作 DOM |
| 非紧急后台任务 | requestIdleCallback | 利用空闲时间 |
| CPU 密集型计算 | Web Worker | 真正独立线程 |
| 超大数据流 | ReadableStream + Worker | 流式处理 |
监控微任务堆积
// 检测长时间运行的微任务
let microtaskCount = 0;
const originalThen = Promise.prototype.then;
Promise.prototype.then = function (onFulfilled, onRejected) {
microtaskCount++;
if (microtaskCount > 1000) {
console.warn('微任务可能堆积!当前计数:', microtaskCount);
console.trace();
}
setTimeout(() => {
microtaskCount = Math.max(0, microtaskCount - 1);
}, 0);
return originalThen.call(this, onFulfilled, onRejected);
};
更实用的方式是用 Performance Observer 监控长任务:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`检测到长任务:${entry.duration.toFixed(1)}ms`);
// 上报到监控系统
}
}
});
observer.observe({ type: 'longtask', buffered: true });
常见面试题速览
Q:setTimeout(fn, 0) 和 Promise.resolve().then(fn) 谁先执行?
A:Promise.then 先执行。因为它是微任务,setTimeout 是宏任务。每个宏任务执行完,微任务队列必须清空。
Q:requestAnimationFrame 在事件循环的哪个位置?
A:在渲染阶段之前。浏览器可能在一次事件循环中执行多个宏任务后才渲染一次。rAF 在渲染前执行,适合做动画。
Q:Node.js 中 setTimeout(fn, 0) 和 setImmediate(fn) 谁先?
A:在主模块中不确定(依赖进程性能),但在 I/O 回调中 setImmediate 一定先于 setTimeout。
Q:await 后面的代码一定在微任务里执行吗?
A:不一定。如果 await 右边的值已经是 resolved 的(比如 await 42),V8 会优化为同步执行。取决于具体实现。
相关工具
- JSON 格式化 — 调试 API 返回的异步数据
- Base64 编码解码 — 处理异步文件读取与编码转换
本站提供浏览器本地工具,免注册即可试用 →