console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1 4 3 2

setTimeout(fn, 0) 不会”立即执行”。JavaScript 的单线程背后有一套精密的调度机制——Event Loop。这道题同时考了同步/异步、宏任务/微任务、事件循环三层知识。

浏览器运行时架构

┌──────────────────────────────────┐
│          JavaScript 引擎          │
│  ┌──────────┐                    │
│  │Call Stack│ ← 同步代码执行       │
│  │  [main]  │   一次只做一件事      │
│  └──────────┘                    │
└──────────────────────────────────┘
      ↕ 调用          ↕ 调度
┌────────────┐  ┌──────────────┐
│  Web APIs  │  │  Event Loop  │
│ setTimeout │  │  (持续轮询)   │
│ DOM Events │  └──────────────┘
│ fetch/XHR  │        ↕
└────────────┘  ┌────────────────┐
      ↓ 放入    │MicroTask Queue │ ← Promise.then
  ┌─────────┐   │(微任务,优先)    │   await (之后)
  │MacroTask│   └────────────────┘
  │ Queue   │
  │(宏任务)  │
  └─────────┘

一步步推演

console.log('Hi');
setTimeout(function cb() {
  console.log('cb');
}, 5000);
console.log('Bye');
// 输出:Hi → Bye → (5 秒后) cb
步骤Call Stack发生了什么
1console.log('Hi')执行,打印 Hi,弹出
2setTimeout(cb, 5000)调用 Web APIs,启动 5 秒定时器。计时由浏览器负责,不占 JS 线程
3console.log('Bye')执行,打印 Bye,弹出
4空闲Call Stack 清空,Event Loop 开始轮询
5空闲5 秒后,Web APIs 将 cb 放入 MacroTask Queue
6cb()Event Loop 取出 cb 推入 Call Stack 执行,打印 cb

DOM 事件也走同一套机制——click 事件发生时,回调放入宏任务队列,等 Call Stack 空闲后才调度。所以如果主线程被占满(比如长循环),点击事件的响应会延迟甚至”卡住”。

宏任务 vs 微任务

类型定义方包含
宏任务 (MacroTask)浏览器(W3C/WHATWG)setTimeoutsetInterval、I/O、UI rendering、postMessagesetImmediate(Node)
微任务 (MicroTask)ECMAScript 规范Promise.then/catch/finallyawait(之后)、queueMicrotask()MutationObserver

一个完整的 Event Loop 周期

1. 从 MacroTask Queue 取出一个宏任务执行(首次执行是整体脚本——也是一个宏任务)
2. 执行过程中产生的微任务进入 MicroTask Queue
3. 宏任务执行完毕,Call Stack 清空
4. 清空 MicroTask Queue(包括微任务中新增的微任务——这是深度的清空)
5. 判断是否需要渲染(不一定每个 cycle 都渲染,浏览器有自己的节流策略)
6. 如需要渲染,执行渲染(Style → Layout → Paint → Composite)
7. requestAnimationFrame 回调执行
8. 回到步骤 1,取出下一个宏任务

核心区别:微任务队列是一次清空(直到队列为空),宏任务队列是一次取一个。

验证

console.log(100);
setTimeout(() => console.log(200)); // 宏任务
Promise.resolve().then(() => console.log(300)); // 微任务
console.log(400);
// 输出:100 400 300 200

执行过程:

  1. 整体脚本作为宏任务执行,打印 100
  2. setTimeout → 回调放入宏任务队列
  3. Promise.then → 回调放入微任务队列
  4. 打印 400,当前宏任务结束
  5. 清空微任务队列 → 打印 300
  6. 取出下一个宏任务 → 打印 200

微任务中创建微任务

Promise.resolve().then(() => {
  console.log(1);
  Promise.resolve().then(() => console.log(2));
});
Promise.resolve().then(() => console.log(3));
// 输出:1 3 2
// 解释:第一轮清空微任务时打印 1 和 3,但 1 中又创建了新的微任务 2
// 微任务清空是深度的——2 在下一轮微任务检查时被立即执行

为什么微任务比宏任务早

Promise 是 ES 规范定义的,回调直接进入 MicroTask Queue——不经过 Web APIs 的异步延迟。而 setTimeout 的路径是:Web APIs(启动计时)→ MacroTask Queue → Event Loop 调度。每次宏任务结束后,引擎会立刻清空当轮积累的所有微任务。

DOM 渲染与 Event Loop

微任务在 DOM 渲染之前执行,宏任务通常在 DOM 渲染之后执行:

$('#container').append($('<p>一段文字</p>'));
 
Promise.resolve().then(() => {
  console.log($('#container').children().length); // 3 —— DOM 结构已更新
  alert('Promise then'); // 页面还没绘制新元素
});
 
setTimeout(() => {
  alert('setTimeout'); // 页面已经可以看到新元素了
});

微任务在渲染前执行这一特性,使得 Vue 的 nextTick(默认用微任务)能够在 DOM 更新后、浏览器绘制前拿到最新的 DOM 状态。React 的批量更新也是类似思路。

requestAnimationFrame 的位置

requestAnimationFrame(rAF)在渲染之前调用,但也在微任务之后:

宏任务结束 → 清空微任务 → requestAnimationFrame → 渲染 → 下一个宏任务
setTimeout(() => console.log('宏任务'));
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('微任务'));
// 输出:微任务 → rAF → 宏任务

综合执行顺序

async function async1() {
  console.log('async1 start'); // 2. 同步
  await async2(); // async2 函数体同步执行
  console.log('async1 end'); // 6. 微任务
}
async function async2() {
  console.log('async2'); // 3. 同步
}
 
console.log('script start'); // 1. 同步
 
setTimeout(() => {
  console.log('setTimeout'); // 8. 宏任务
}, 0);
 
async1();
 
new Promise((resolve) => {
  console.log('promise1'); // 4. 同步(Promise 构造函数立即执行)
  resolve();
}).then(() => {
  console.log('promise2'); // 7. 微任务
});
 
console.log('script end'); // 5. 同步
 
// 输出顺序:script start → async1 start → async2 → promise1 → script end
//           → async1 end → promise2 → setTimeout

几个关键点:

  • await async2() —— async2 的函数体同步执行,只有 await 后面的代码才进入微任务
  • new Promise(fn) —— fn 同步执行,只有 .then() 的回调才进入微任务
  • await 后的代码等价于 Promise.resolve().then(...)

Node.js 的 Event Loop

Node.js 的 Event Loop 与浏览器不同,分为六个阶段:

timers → pending callbacks → idle, prepare → poll → check → close callbacks

process.nextTickPromise 是 Node 中的微任务,nextTick 的优先级高于 PromisesetImmediate 在 check 阶段执行,setTimeout(fn, 0) 在 timers 阶段执行。

相关笔记