2015年8月,Google开发者倡导者Jake Archibald发布了一篇题为《Tasks, microtasks, queues and schedules》的文章,用一个简单的代码示例揭示了JavaScript开发者普遍存在的认知盲区:

console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise1'))
                 .then(() => console.log('promise2'));
console.log('script end');

大多数开发者预期的输出顺序是:script startscript endsetTimeoutpromise1promise2。毕竟setTimeout的延迟是0,而且它在代码中先于Promise被调用。

实际的执行结果却是:script startscript endpromise1promise2setTimeout

这不是浏览器的bug,也不是规范实现的不一致——这是事件循环机制经过精心设计后的必然结果。

一个不存在的概念:宏任务

在深入讨论之前,需要先澄清一个广泛流传的误解:“宏任务”(macrotask)这个词在规范中并不存在

HTML规范定义的是任务队列(task queue)微任务队列(microtask queue)。中文技术社区习惯将"任务队列中的任务"称为"宏任务",这个简称在面试和博客中广泛使用,但它从未出现在官方规范中。

根据HTML Living Standard(最后更新于2026年2月27日),事件循环的核心算法可以概括为以下步骤:

  1. 从任务队列中取出一个可运行的任务并执行
  2. 执行微任务检查点(perform a microtask checkpoint),排空微任务队列
  3. 更新渲染(如果是窗口事件循环)
  4. 回到第1步

关键在于第2步:微任务队列的处理发生在每个任务结束之后下一个任务开始之前。这就是为什么Promise回调总是先于setTimeout执行——Promise回调进入微任务队列,而setTimeout回调进入任务队列。

任务队列的本质:不是队列的队列

HTML规范对任务队列的定义出人意料:任务队列不是一个队列,而是一个集合(set)

真正的队列遵循FIFO(先进先出)原则,第一个元素总是最先被取出。但任务队列不同——它需要找到第一个"可运行"的任务。一个任务可能在队列中,但暂时不可运行(比如其关联的Document尚未完全激活)。这种设计允许浏览器灵活地选择执行哪个任务。

相比之下,微任务队列是真正的队列:微任务按照入队顺序依次执行,没有例外。

任务来源的多样性

事件循环维护多个任务来源(task source),每个来源关联一个任务队列:

任务来源 示例
DOM操作任务来源 事件回调、DOM事件
用户交互任务来源 鼠标点击、键盘输入
网络任务来源 fetch回调、XHR
定时器任务来源 setTimeout、setInterval

浏览器可以选择从哪个队列取任务,但同一来源内的任务必须按顺序执行。这种设计允许浏览器优先处理用户输入等高优先级任务。

微任务的优先级机制

微任务队列的设计初衷是为了处理"需要在当前任务结束后、下一个任务开始前立即执行"的操作。典型的微任务包括:

  • Promise.then()/catch()/finally()的回调
  • MutationObserver的回调
  • queueMicrotask()注册的函数

微任务检查点的触发时机

微任务检查点会在以下情况被触发:

  1. 每个任务执行完毕后
  2. 每个回调执行完毕后(如果调用栈为空)
  3. 渲染更新之前

这意味着如果在一个任务中生成了新的微任务,这些微任务会在下一个任务开始前全部执行完毕——即使这个过程中又产生了新的微任务。

Promise.resolve().then(function resolver() {
  console.log('微任务执行');
  return Promise.resolve().then(resolver); // 递归创建微任务
});
setTimeout(() => console.log('setTimeout'), 0);
// 输出:无数的"微任务执行",setTimeout永远不会执行

这段代码会导致微任务饥饿(microtask starvation):微任务队列永远无法清空,事件循环被阻塞,setTimeout的回调永远没有机会执行,UI也会完全冻结。

浏览器与Node.js:两种不同的实现哲学

事件循环不是JavaScript语言的一部分——它属于宿主环境。ECMA-262规范中甚至没有"事件循环"这个词,只定义了"作业(Job)“和"作业队列(Job Queue)“的概念。HTML规范和Node.js各自定义了自己的事件循环实现。

浏览器的简洁模型

浏览器事件循环遵循HTML规范,采用简洁的"任务-微任务-渲染"循环:

┌─────────────────────────────────────┐
│           事件循环                    │
│  ┌─────────┐    ┌─────────────────┐ │
│  │取一个任务│───→│执行微任务检查点   │ │
│  └─────────┘    └────────┬────────┘ │
│       ↑                   │          │
│       │              ┌────▼────┐    │
│       │              │更新渲染  │    │
│       │              └────┬────┘    │
│       └───────────────────┘          │
└─────────────────────────────────────┘

Node.js的六阶段模型

Node.js使用libuv库实现事件循环,采用更复杂的六阶段架构:

   ┌───────────────────────┐
┌─>│        timers         │ setTimeout/setInterval
│  └───────────┬───────────┘
│  ┌───────────┴───────────┐
│  │   pending callbacks   │ 执行延迟到下一轮的I/O回调
│  └───────────┬───────────┘
│  ┌───────────┴───────────┐
│  │     idle, prepare     │ 内部使用
│  └───────────┬───────────┘
│  ┌───────────┴───────────┐
│  │        poll           │ 检索新I/O事件
│  └───────────┬───────────┘
│  ┌───────────┴───────────┐
│  │        check          │ setImmediate回调
│  └───────────┬───────────┘
│  ┌───────────┴───────────┐
└──┤    close callbacks    │ close事件回调
   └───────────────────────┘

在Node.js中,微任务队列在每个阶段结束时处理,而不是在每个任务之后。此外,Node.js还有一个特殊的process.nextTick()队列,它的优先级甚至高于Promise微任务队列。

setImmediate与setTimeout的顺序谜题

在Node.js中,setImmediate()setTimeout(() => {}, 0)的执行顺序取决于调用上下文:

// 在主模块中调用,顺序不确定
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 可能输出 timeout → immediate 或 immediate → timeout

// 在I/O回调中调用,setImmediate总是先执行
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
// 总是输出 immediate → timeout

原因在于:setImmediate在check阶段执行,而setTimeout在timers阶段执行。在I/O回调中调用时,事件循环已经处于poll阶段,下一个阶段是check,所以setImmediate先执行。

从libuv 1.45.0(Node.js 20)开始,事件循环的行为发生了变化:定时器只在poll阶段之后运行,而不是像之前那样在poll阶段前后都运行。

渲染时机:requestAnimationFrame的特殊位置

当讨论事件循环时,渲染是一个不可忽视的环节。对于窗口事件循环,每次循环都可能触发渲染更新:

任务执行 → 微任务检查点 → requestAnimationFrame回调 → 样式计算 → 布局 → 绘制

requestAnimationFrame(rAF)的回调在渲染更新之前执行,这使得它成为动画的完美选择:

  • setTimeout(fn, 16)试图模拟60fps,但无法与显示器的刷新率同步
  • requestAnimationFrame(fn)由浏览器调度,保证在下一帧渲染前执行
// 使用setTimeout的动画(不推荐)
function animateWithTimeout() {
  // 动画逻辑
  setTimeout(animateWithTimeout, 16);
}

// 使用requestAnimationFrame的动画(推荐)
function animateWithRAF() {
  // 动画逻辑
  requestAnimationFrame(animateWithRAF);
}

当浏览器标签页处于后台时,requestAnimationFrame会被暂停以节省资源,而setTimeout仍会继续触发——这可能导致后台标签页消耗不必要的CPU。

从INP指标看长任务的影响

2024年3月,Google将INP(Interaction to Next Paint)列为Core Web Vitals的正式指标,取代了FID(First Input Delay)。INP测量的是从用户交互到浏览器绘制下一帧的时间,这直接关联到事件循环的行为。

一次交互的延迟由三部分组成:

  • 输入延迟:从用户操作到事件处理器开始执行
  • 处理时间:事件处理器执行的时间
  • 呈现延迟:处理器执行完毕到帧绘制完成

任何超过50毫秒的任务都被视为长任务,会阻塞主线程,导致INP变差。

微任务饥饿的现实危害

// 危险示例:递归Promise导致UI冻结
async function badProcessData(data) {
  for (const item of data) {
    await Promise.resolve(); // 每次迭代都创建微任务
    // 处理数据...
  }
}

// 正确做法:定期让出主线程
async function goodProcessData(data) {
  for (let i = 0; i < data.length; i++) {
    if (i % 100 === 0) {
      await new Promise(r => setTimeout(r, 0)); // 让出主线程
    }
    // 处理数据...
  }
}

更现代的解决方案是使用scheduler.yield()API:

async function modernProcessData(data) {
  for (const item of data) {
    // 处理数据...
    await scheduler.yield(); // 让出主线程,但保持优先级
  }
}

scheduler.yield()的优势在于:它的后续代码会被优先执行,而不会像setTimeout那样排到队列末尾。

为什么微任务优先级更高

微任务优先机制的设计并非偶然,它解决了几个关键问题:

状态一致性。Promise链式调用依赖于前一个Promise的状态。如果微任务和任务混在一起执行,中间可能插入其他任务,导致状态不一致。

let value = 0;
Promise.resolve().then(() => {
  console.log(value); // 保证输出0,不会被其他代码修改
});
// 如果这里是任务而非微任务,中间可能插入其他代码修改value

批处理效率。MutationObserver可以在一次DOM变更后批量处理多个变化,而不是每次变化都触发回调。微任务机制确保所有DOM变更记录完毕后才执行回调。

避免回调地狱。Promise的设计目标是解决回调嵌套问题。如果Promise回调的执行时机不确定,开发者就难以预测代码行为,async/await的语义也会变得模糊。

调试事件循环的工具

Chrome DevTools的Performance面板提供了观察事件循环行为的窗口:

  • Main部分显示任务的执行顺序和时长
  • 红色三角形标记长任务(超过50ms)
  • 可以展开查看每个任务的调用栈

对于Node.js,可以使用node --inspect启动调试,或在代码中检查事件循环延迟:

const { performance } = require('perf_hooks');
const start = performance.now();
setImmediate(() => {
  const delay = performance.now() - start;
  console.log(`事件循环延迟: ${delay}ms`);
});

规范层面的统一

ECMAScript规范的"作业(Job)“概念与HTML规范的"微任务"概念最终达成了统一:

  • ECMAScript定义HostEnqueuePromiseJob来调度Promise作业
  • HTML规范将此映射到微任务队列
  • V8引擎的实现确认了这种映射关系
// V8中Promise.resolve的部分实现
MicrotaskQueue* microtask_queue = then_context->microtask_queue();
if (microtask_queue) microtask_queue->EnqueueMicrotask(*task);

这种协作确保了Promise行为在不同JavaScript环境中的可预测性。

最后的执行顺序

回到开头的代码,完整的执行过程如下:

  1. 主脚本作为第一个任务开始执行
  2. console.log('script start')输出
  3. setTimeout注册回调到任务队列(定时器任务来源)
  4. Promise.resolve()创建已解决的Promise
  5. .then()注册回调到微任务队列
  6. console.log('script end')输出
  7. 第一个任务结束,触发微任务检查点
  8. 执行微任务:输出promise1,返回新Promise,.then()注册新微任务
  9. 继续执行微任务:输出promise2
  10. 微任务队列为空,事件循环继续
  11. 取出setTimeout回调作为第二个任务
  12. 执行:输出setTimeout

理解这个顺序,不仅有助于写出可预测的异步代码,更是在设计高性能Web应用时的基础。当INP成为搜索引擎排名的因素之一,对事件循环的深入理解就从面试题变成了生产环境的必需品。


参考资料

  • HTML Living Standard - Event Loops. WHATWG. 2026.
  • The Node.js Event Loop. Node.js Documentation.
  • Tasks, microtasks, queues and schedules. Jake Archibald. 2015.
  • Interaction to Next Paint (INP). web.dev. 2025.
  • Optimize long tasks. web.dev. 2024.
  • Event Loop. Myths and reality. Frontend Almanac. 2024.
  • Using microtasks in JavaScript with queueMicrotask(). MDN Web Docs.
  • V8 Engine Source Code - Promise Implementation. Google.
  • ECMAScript 2026 Language Specification. TC39.