ToolkitX
知识库工具箱

事件循环深入

宏任务、微任务、requestAnimationFrame

25min·高级

01. JavaScript 是单线程的——那异步怎么来的

JavaScript 的单线程意味着同一时间只能做一件事——只有一个调用栈。如果一个请求卡住,后面所有操作都等着。 但浏览器/Node.js 不是单线程的——它们有 Web APIs(浏览器)或 libuv(Node.js)提供的线程池。JS 代码调异步操作时(setTimeout、fetch、文件读写),实际工作交给这些幕后线程去干。 干完之后怎么通知 JS?通过事件循环——幕后线程把回调函数放到一个任务队列里。JS 主线程执行完当前代码后,从任务队列取出回调执行。这就是 JavaScript 异步非阻塞的秘密。 打个比方:你(JS 主线程)是前台收银,顾客下单后你告诉厨房(Web API),然后继续收下一位。菜好了厨房按铃(回调进队列),你手上的顾客处理完了就回头端菜。
javascript
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

// 输出:1 4 3 2
// 为什么?因为微任务(Promise)比宏任务(setTimeout)优先级高

02. 宏任务与微任务——两个队列

事件循环里任务分两个级别: 宏任务(Macro-task / Task)——setTimeout、setInterval、I/O 回调、UI 渲染(浏览器)、setImmediate(Node.js)。 微任务(Micro-task)——Promise.then/catch/finally、async/await(await 后面的部分)、MutationObserver(浏览器)、queueMicrotask()、process.nextTick(Node.js,比微任务还先执行)。 事件循环的每次迭代(tick): 1. 执行一个宏任务(从宏任务队列取一个) 2. 执行所有微任务(微任务队列清空——包括微任务执行中产生的新微任务,也一并执行完) 3. 如果需要且时间到了,渲染更新(浏览器) 4. 开始下一次迭代 这就是为什么同一个流程里微任务总比下一个宏任务先执行——每次拿一个宏任务后必先把微任务队列清空。
javascript
// 执行顺序测试
console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve()
  .then(() => { console.log('promise 1'); })
  .then(() => { console.log('promise 2'); });

console.log('end');

// 输出:
// start
// end
// promise 1
// promise 2
// timeout
//
// 流程:同步代码先执行 → 开始事件循环
// 微任务队列有 promise → 全部清空
// 宏任务队列有 timeout → 执行
process.nextTick(Node.js)的优先级比 Promise.then 还高,在微任务队列之前执行。一个 tick 里 nextTick 队列会先清空。

03. async/await 在事件循环里的行为

async/await 本质上是 Promise 的语法糖,但在事件循环里的行为有个细节: async 函数被调用时同步部分立即执行(new Promise 的 executor 是同步的)。await 后面会被拆成 .then()——await 之前的代码同步执行,await 之后的代码变成微任务。 所以:await promise 后面的代码相当于 promise.then(() => { 后面的代码 }),被放入微任务队列。 多个 async/await 的执行顺序取决于 Promise 何时 resolve。用 await 写的代码看起来像同步,但事件循环里的实际顺序还是异步的。
javascript
async function main() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

main();

Promise.resolve().then(() => console.log('C'));

console.log('D');

// 输出:A D B C
// 但注意!B 和 C 实际上都在微任务队列里
// 因为 await 后面的 B 相当于 .then(() => console.log('B'))
// 它是第一个进入微任务队列的,所以 B 先于 C 执行
await 后面的代码优先级跟 .then 一样——都是微任务。多个 await 的代码会被排队到微任务队列里依次执行。

04. Node.js 的事件循环与浏览器有什么不同

Node.js 的事件循环基于 libuv,比浏览器的要复杂: Node.js 的事件循环分 6 个阶段,每个阶段处理特定的回调: 1. timers——setTimeout/setInterval 回调 2. pending callbacks——系统操作的回调(如 TCP 错误) 3. idle/prepare——内部使用 4. poll——获取新的 I/O 事件(大部分回调在这执行) 5. check——setImmediate 回调 6. close callbacks——close 事件的回调(如 socket.on('close')) 每个阶段之间会检查是否有 process.nextTick 和微任务需要执行。 setTimeout(fn, 0) 和 setImmediate(fn) 的区别:在主模块里直接调用时 setTimeout 先执行(取决于当时事件循环在哪个阶段),在 I/O 回调里 setImmediate 先执行。
javascript
// Node.js 中 setTimeout vs setImmediate
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

// 在主模块里:输出顺序不确定
// 在 I/O 回调里:setImmediate 一定先执行

const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // 输出:immediate 先,timeout 后
});
Node.js 的 process.nextTick 在同一个 tick 里,在任何微任务之前执行。可以用来做关键操作的优先级保障。

05. 事件循环的实际影响——卡顿与性能

事件循环被阻塞是 Node.js 性能问题的根本原因——一个同步的 CPU 密集操作会卡住事件循环,所有 I/O 都在排队等。 常见阻塞场景:大 JSON 解析、大量同步加密解密、复杂的正则匹配(灾难性回溯)、大循环里的同步操作。 解决方案: 1. 把 CPU 密集任务拆成小块用 setImmediate 或 async/await 让步事件循环 2. Worker Threads 把计算放到独立线程 3. child_process 开子进程跑重任务 4. 用 stream 而不是一次性读取大文件 排查工具:Node.js 内置的 perf_hooks、clinic.js、0x 火焰图、--trace-event-categories。这些工具能看出事件循环哪个阶段吃了多少时间。
javascript
// 阻塞事件循环的代码(坏)
function blockingLoop() {
  let sum = 0;
  for (let i = 0; i < 10_000_000_000; i++) {
    sum += i;
  }
  return sum;
}

// 让步式——把大任务拆开
async function nonBlockingLoop() {
  let sum = 0;
  for (let i = 0; i < 1000; i++) {
    for (let j = 0; j < 10_000_000; j++) {
      sum += j;
    }
    // 每轮让步事件循环处理其他任务
    await new Promise(resolve => setImmediate(resolve));
  }
  return sum;
}
CPU 密集任务不要放在主线程——一次 10 亿的循环能把事件循环卡住几秒。期间服务器对所有请求都不响应。

知识测验

1/5正确 0

JavaScript 是单线程的,那异步操作怎么来的?