事件循环深入
掌握了事件循环的基础(调用栈、宏任务、微任务)之后,还需要看清它的全貌:浏览器与 Node.js 的事件循环并非完全相同,渲染时机夹在哪两个阶段之间,nextTick 又比微任务快在哪。本文从本质出发,把两套事件循环的细节与执行顺序题一次讲透。
一、事件循环的本质
事件循环(Event Loop) 是单线程语言协调异步的调度器:主线程执行同步代码;异步回调先挂起,时机成熟后被放入队列;主线程空闲时从队列取回调执行。循环往复,所以叫"循环"。
┌─────────────────────────────┐
│ 执行同步代码(调用栈) │
└─────────────────────────────┘
↓ 栈空
┌─────────────────────────────┐
│ 处理微任务队列(清空为止) │
└─────────────────────────────┘
↓ 微任务清空
┌─────────────────────────────┐
│ 取一个宏任务执行 → 回到上面 │
└─────────────────────────────┘核心规则:一个宏任务执行完后,先清空全部微任务,再取下一个宏任务。微任务会在每两个宏任务之间被"挤干"。
二、浏览器事件循环细节
2.1 任务队列与微任务队列
浏览器维护两类队列:
| 队列 | 生产者 | 执行时机 | 优先级 |
|---|---|---|---|
| 宏任务队列(Task Queue) | setTimeout、setInterval、I/O、UI 事件、postMessage | 每轮事件循环取一个 | 低 |
| 微任务队列(Microtask Queue) | Promise.then、MutationObserver、queueMicrotask | 每个宏任务之后全部清空 | 高 |
setTimeout(() => console.log("宏任务"));
Promise.resolve().then(() => console.log("微任务"));
console.log("同步");
// 输出顺序:同步 → 微任务 → 宏任务2.2 每帧渲染时机
浏览器把渲染也编排进事件循环:宏任务队列处理完后,在渲染前执行 requestAnimationFrame 回调,然后才可能执行渲染与绘制。
宏任务 → 清空微任务 → requestAnimationFrame 回调 → 渲染/绘制 → 下一轮setTimeout(() => console.log("宏任务"), 0);
requestAnimationFrame(() => console.log("rAF"));
Promise.resolve().then(() => console.log("微任务"));
// 典型输出:微任务 → rAF → 宏任务2.3 requestAnimationFrame 的位置
| 回调类型 | 与渲染的关系 |
|---|---|
| 微任务 | 渲染前,每个宏任务后立即执行,可能多次 |
requestAnimationFrame | 渲染前,每帧最多一次,跟随屏幕刷新率 |
setTimeout(fn, 0) | 渲染后,每帧可能多轮,或滞后一帧 |
let count = 0;
function step() {
console.log("第", ++count, "帧");
requestAnimationFrame(step); // 跟随 60Hz 刷新,每秒约 60 次
}
requestAnimationFrame(step);三、Node.js 事件循环各阶段
Node.js 的事件循环由 libuv 实现,一轮循环分六个阶段,每个阶段有各自的回调队列:
timers → pending → poll → check → close callbacks → 回到 timers| 阶段 | 处理内容 |
|---|---|
| timers | setTimeout / setInterval 到期的回调 |
| pending | 系统级回调(如 TCP 错误) |
| poll | 等待新 I/O 事件,执行 I/O 回调,阻塞等待 |
| check | setImmediate 的回调 |
| close callbacks | socket.on("close") 等关闭回调 |
3.1 poll 阶段的阻塞特性
poll 阶段是事件的"集散地":若 timers 没有到期任务且 check 队列为空,libuv 会阻塞等待 I/O 事件;I/O 事件到达或 timer 到期才醒来。
poll:没有任务 → 阻塞等待 I/O(省电高效)
有任务 → 执行完再进入 check3.2 timers 与 setImmediate 的顺序
两者顺序取决于 poll 阶段的阻塞时机,因此不一定:
setTimeout(() => console.log("timeout"));
setImmediate(() => console.log("immediate"));在脚本顶层,结果不确定;但在 I/O 回调内部,setImmediate 总是先于下一轮的 setTimeout:
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"));
setImmediate(() => console.log("immediate"));
});
// 几乎总是输出:immediate → timeout
// 原因:I/O 回调在 poll 阶段执行,check 紧跟其后,timer 要等下一轮四、process.nextTick 与 queueMicrotask 的区别
process.nextTick 不是事件循环的阶段,它的回调在当前阶段结束后立即执行,比微任务队列更早:
| 特性 | process.nextTick | queueMicrotask / Promise |
|---|---|---|
| 所在队列 | nextTick 队列 | 微任务队列 |
| 执行时机 | 当前阶段结束后、进下一阶段前 | 宏任务之后 |
| 相对顺序 | 先于微任务 | 后于 nextTick |
| 用途 | Node 内部机制,慎用 | 标准 API,通用 |
Promise.resolve().then(() => console.log("微任务"));
process.nextTick(() => console.log("nextTick"));
setTimeout(() => console.log("timeout"));
// 输出:nextTick → 微任务 → timeout注意:nextTick 递归会让事件循环饿死(一直处理 nextTick 不进下一阶段),不要用它做重循环。
五、宏任务与微任务嵌套优先级
嵌套回调会不断把新任务塞回队列,优先级规则始终如一:每执行一个宏任务,就把当前所有微任务清空,包括嵌套产生的微任务;而嵌套的宏任务只能排到下一轮。
setTimeout(() => {
console.log("A");
Promise.resolve().then(() => console.log("A 的微任务"));
}, 0);
setTimeout(() => {
console.log("B");
setTimeout(() => console.log("B 的宏任务"), 0);
}, 0);
// 输出:A → A 的微任务 → B → B 的宏任务
// B 的宏任务排到下一轮,A 的微任务立即执行六、复杂执行顺序题完整分析
把浏览器与 Node 的特性叠加,看一道经典综合题:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => {
console.log("3");
process.nextTick(() => console.log("4"));
});
process.nextTick(() => console.log("5"));
setImmediate(() => console.log("6"));
setTimeout(() => {
console.log("7");
queueMicrotask(() => console.log("8"));
}, 0);
console.log("9");逐步推演(Node 环境):
- 同步阶段:输出
1、9。注册 timer(回调 2、7)、Promise 微任务、nextTick(5)、setImmediate(6)。 - 当前阶段结束:先清空 nextTick 队列 → 输出
5。再清空微任务队列 → 输出3;其中又注册 nextTick(4),微任务执行完后再清空 nextTick → 输出4。 - timers 阶段:两个 timer 到期,先输出
2,再输出7;执行回调 7 时注册微任务(8),当前宏任务结束后微任务立即执行 → 输出8。 - check 阶段:输出
6。
最终输出:1 → 9 → 5 → 3 → 4 → 2 → 7 → 8 → 6要点归纳:nextTick 先于微任务;宏任务回调里注册的微任务在该宏任务结束、下一个宏任务开始前执行;setImmediate 通常排在 timers 之后。
七、浏览器与 Node 事件循环对比
| 维度 | 浏览器 | Node.js |
|---|---|---|
| 实现 | 宿主(HTML 规范) | libuv |
| 阶段划分 | 任务队列 + 微任务队列 | 六个阶段 |
| 渲染 | 每轮可能渲染,rAF 在渲染前 | 无渲染 |
setImmediate | 无 | 有(check 阶段) |
process.nextTick | 无 | 有(当前阶段后立即执行) |
| 微任务时机 | 每个宏任务后 | 每个阶段结束后(libuv 新版与宏任务后) |
| 代表 API | fetch、DOM、MutationObserver | fs、http、worker_threads |
浏览器用 rAF 衔接渲染,Node 用 poll 阻塞等待 I/O,但"同步优先 → 微任务优先于宏任务 → 每轮取一个宏任务"的核心语义一致。
八、异步资源追踪(Async Hooks 简介)
Node.js 提供 Async Hooks 追踪每个异步资源的生命周期(init/before/after/destroy),用于诊断回调与请求的对应关系:
const async_hooks = require("async_hooks");
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
console.log(`初始化:${type}(id=${asyncId},触发于=${triggerAsyncId})`);
},
}).enable();
setTimeout(() => {}, 10);
// 输出类似:初始化:Timeout(id=2,触发于=1)AsyncLocalStorage 是它的高级封装,可在异步链中传递上下文(如请求 ID、用户信息):
const { AsyncLocalStorage } = require("async_hooks");
const store = new AsyncLocalStorage();
store.run({ userId: 42 }, () => {
setTimeout(() => {
console.log(store.getStore()); // { userId: 42 },跨异步仍可读取
}, 100);
});Async Hooks 有额外开销,生产环境建议仅用于诊断期。结合本章的知识,可以在浏览器与 Node 中准确预测任何异步代码的执行顺序,也能在排查"回调为什么没执行"时快速定位阶段与队列。