~/ ?.log $
返回文章列表
8 min read
更新于 2026年3月4日

我不知道的 V8(13)— 宏任务的调度逻辑:事件循环的主干

setTimeout(fn, 0) 的 fn 为什么不是"立即"执行,而是"稍后"执行?为什么两个延时相同的 setTimeout 之间还有顺序关系?这些问题的答案在 V8 的事件循环设计里,宏任务(Macrotasks)是这套设计的核心单元。

setTimeout(fn, 0)fn 为什么不是”立即”执行,而是”稍后”执行?为什么两个延时相同的 setTimeout 之间还有顺序关系?这些问题的答案在 V8 的事件循环设计里,宏任务(Macrotasks)是这套设计的核心单元。

一、宏任务包括哪些

宏任务是事件循环里”主干”级别的任务类型,每个宏任务代表一次完整的代码执行周期。常见的宏任务来源:

  • setTimeoutsetInterval 的回调
  • DOM 事件处理函数(如 clickscroll
  • I/O 回调(Node.js 中的文件、网络操作)
  • setImmediate(Node.js 专有)
  • 页面的初始 JavaScript 执行(<script> 标签加载执行)

微任务(Promise.thenqueueMicrotask)不是宏任务——它们的执行时机不同,在每个宏任务结束后立即清空。

二、事件循环的完整节奏

V8 的事件循环(结合浏览器的任务调度器)执行顺序:

初始执行

1. 执行当前宏任务(同步代码)
2. 执行过程中产生的所有微任务(清空微任务队列)
3. 浏览器渲染(如有需要)
4. 取出下一个宏任务,回到步骤 1

用代码验证这个顺序:

console.log('1. 同步代码开始');

setTimeout(() => {
  console.log('4. 宏任务(setTimeout)');
  Promise.resolve().then(() => {
    console.log('5. 宏任务内部的微任务');
  });
}, 0);

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

console.log('2. 同步代码结束');

// 输出顺序:
// 1. 同步代码开始
// 2. 同步代码结束
// 3. 微任务(Promise.then)
// 4. 宏任务(setTimeout)
// 5. 宏任务内部的微任务

三、宏任务队列的管理机制

宏任务由浏览器(或 Node.js 的 libuv)的任务调度器管理,而不是 V8 直接管理。V8 只是”执行代码”的引擎,任务的调度和排队由外部运行时负责。

setTimeout(fn, 0) 为例:

调用 setTimeout(fn, 0)
  → 浏览器的 Web API(定时器模块)接管
  → 0ms 后(实际最小延迟约 4ms),将 fn 包装成任务推入宏任务队列
  → V8 的当前宏任务和所有微任务清空后
  → 事件循环从宏任务队列取出 fn
  → V8 执行 fn

宏任务队列是 FIFO(先进先出):两个都延时 0ms 的 setTimeout,先注册的先执行:

setTimeout(() => console.log('First'), 0);
setTimeout(() => console.log('Second'), 0);
// 始终输出:First → Second

四、setTimeout 的时序误区

setTimeout(fn, 0) 不意味着”立即”

即使延时设为 0,fn 也不能在当前同步代码执行期间运行。它必须等当前宏任务(包括微任务)全部完成,才能成为下一个宏任务:

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

// 即使这个循环运行 1 秒,timeout 也不会在它结束前执行
const start = Date.now();
while (Date.now() - start < 1000) {}

console.log('end');
// 输出:start → end → timeout(1 秒后)

延时是”最短等待时间”,不是”精确执行时间”

宏任务队列里如果已经有很多任务在排队,fn 要等到前面所有任务都完成才能执行,实际延迟会比设定值更长。

浏览器的最小延时约为 4ms

即使设置 setTimeout(fn, 0),浏览器规范要求最小延迟约 4ms(嵌套 setTimeout 超过 5 层后更为明显)。

五、宏任务与渲染的关系

浏览器的渲染(重绘、回流)发生在每个宏任务结束后(不是每帧必然发生,由浏览器决定)。这个设计意味着:

// 在一个宏任务里连续修改 DOM
button.addEventListener('click', () => {
  div.style.background = 'red'; // 修改 1
  div.style.width = '200px'; // 修改 2
  div.style.height = '200px'; // 修改 3
  // 这三次修改都在同一个宏任务里
  // 浏览器只在宏任务结束后统一渲染,不会中间渲染三次
});

这是 JavaScript 单线程模型的优点:一个宏任务里的多次 DOM 修改,浏览器会合并成一次渲染,避免闪烁。

但如果一个宏任务执行时间过长(如超过 16ms),浏览器就无法在 60fps 的节奏下及时渲染,造成卡顿:

// Bad Case:长宏任务阻塞渲染
button.addEventListener('click', () => {
  // 处理 10000 个数据项——这可能需要 50ms+
  for (let i = 0; i < 10000; i++) {
    heavyProcess(data[i]);
  }
  updateUI();
});

// Better:分批执行,让渲染穿插进来
function processBatch(data, start) {
  const batchSize = 100;
  const end = Math.min(start + batchSize, data.length);

  for (let i = start; i < end; i++) {
    heavyProcess(data[i]);
  }

  if (end < data.length) {
    setTimeout(() => processBatch(data, end), 0); // 下一个宏任务继续
  } else {
    updateUI();
  }
}
processBatch(data, 0);

六、Node.js 的宏任务优先级

浏览器里所有宏任务的优先级大体相同(除了不同队列有区分,如 requestAnimationFrame 有特殊处理)。Node.js 里的宏任务有更明确的优先级分层:

// Node.js 宏任务优先级(由高到低):
// 1. process.nextTick(技术上是微任务,但优先级高于 Promise)
// 2. Promise.then(微任务)
// 3. setImmediate(宏任务,当前轮循环结束后)
// 4. setTimeout(fn, 0)(宏任务,下一轮循环)
// 5. I/O 回调

setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
// Node.js 中输出顺序可能是:setImmediate → setTimeout
// 但在 I/O 回调内部,setImmediate 总是先于 setTimeout

七、总结

宏任务是事件循环的基本执行单元,每次事件循环从队列里取一个宏任务执行,完成后清空微任务,然后进入下一个宏任务。

理解这套节奏,能解释:

  • 为什么 setTimeout(fn, 0) 不是”立即”执行(等待当前宏任务 + 微任务完成)
  • 为什么同一宏任务里的多次 DOM 修改不会导致多次渲染(宏任务结束才渲染)
  • 为什么长时间执行的同步代码会让页面卡顿(占用宏任务,阻塞渲染)
  • 如何用 setTimeout 分批处理长任务,让渲染穿插其中

本系列其他文章:

share.ts

// 觉得这篇文章有帮助?

// 欢迎分享给更多人

export const  subscribe  =  "/rss.xml" ;