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 start → script end → setTimeout → promise1 → promise2。毕竟setTimeout的延迟是0,而且它在代码中先于Promise被调用。
实际的执行结果却是:script start → script end → promise1 → promise2 → setTimeout。
这不是浏览器的bug,也不是规范实现的不一致——这是事件循环机制经过精心设计后的必然结果。
一个不存在的概念:宏任务
在深入讨论之前,需要先澄清一个广泛流传的误解:“宏任务”(macrotask)这个词在规范中并不存在。
HTML规范定义的是任务队列(task queue)和微任务队列(microtask queue)。中文技术社区习惯将"任务队列中的任务"称为"宏任务",这个简称在面试和博客中广泛使用,但它从未出现在官方规范中。
根据HTML Living Standard(最后更新于2026年2月27日),事件循环的核心算法可以概括为以下步骤:
- 从任务队列中取出一个可运行的任务并执行
- 执行微任务检查点(perform a microtask checkpoint),排空微任务队列
- 更新渲染(如果是窗口事件循环)
- 回到第1步
关键在于第2步:微任务队列的处理发生在每个任务结束之后、下一个任务开始之前。这就是为什么Promise回调总是先于setTimeout执行——Promise回调进入微任务队列,而setTimeout回调进入任务队列。
任务队列的本质:不是队列的队列
HTML规范对任务队列的定义出人意料:任务队列不是一个队列,而是一个集合(set)。
真正的队列遵循FIFO(先进先出)原则,第一个元素总是最先被取出。但任务队列不同——它需要找到第一个"可运行"的任务。一个任务可能在队列中,但暂时不可运行(比如其关联的Document尚未完全激活)。这种设计允许浏览器灵活地选择执行哪个任务。
相比之下,微任务队列是真正的队列:微任务按照入队顺序依次执行,没有例外。
任务来源的多样性
事件循环维护多个任务来源(task source),每个来源关联一个任务队列:
| 任务来源 | 示例 |
|---|---|
| DOM操作任务来源 | 事件回调、DOM事件 |
| 用户交互任务来源 | 鼠标点击、键盘输入 |
| 网络任务来源 | fetch回调、XHR |
| 定时器任务来源 | setTimeout、setInterval |
浏览器可以选择从哪个队列取任务,但同一来源内的任务必须按顺序执行。这种设计允许浏览器优先处理用户输入等高优先级任务。
微任务的优先级机制
微任务队列的设计初衷是为了处理"需要在当前任务结束后、下一个任务开始前立即执行"的操作。典型的微任务包括:
Promise.then()/catch()/finally()的回调MutationObserver的回调queueMicrotask()注册的函数
微任务检查点的触发时机
微任务检查点会在以下情况被触发:
- 每个任务执行完毕后
- 每个回调执行完毕后(如果调用栈为空)
- 渲染更新之前
这意味着如果在一个任务中生成了新的微任务,这些微任务会在下一个任务开始前全部执行完毕——即使这个过程中又产生了新的微任务。
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环境中的可预测性。
最后的执行顺序
回到开头的代码,完整的执行过程如下:
- 主脚本作为第一个任务开始执行
console.log('script start')输出setTimeout注册回调到任务队列(定时器任务来源)Promise.resolve()创建已解决的Promise.then()注册回调到微任务队列console.log('script end')输出- 第一个任务结束,触发微任务检查点
- 执行微任务:输出
promise1,返回新Promise,.then()注册新微任务 - 继续执行微任务:输出
promise2 - 微任务队列为空,事件循环继续
- 取出setTimeout回调作为第二个任务
- 执行:输出
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.