在前端面试和日常开发中,”事件循环(Event Loop)”是被问到频率最高的概念之一。然而,很多开发者只是背诵了”宏任务先执行,微任务后执行”这样的口诀,却并不真正理解它的工作原理。当你遇到动画卡顿、定时器不准时、Promise 链路嵌套地狱、或者
1 | requestAnimationFrame |
与
1 | setTimeout |
行为不一致的问题时,真正的事件循环知识才是解决问题的关键。本文将带你从浏览器多进程架构出发,深入剖析事件循环的每一个阶段,配合大量可运行代码示例,让你彻底掌握这一核心概念。
一、浏览器进程架构与渲染进程
在理解事件循环之前,必须先明白浏览器是如何组织各个模块的。现代浏览器(以 Chrome 为代表)采用多进程架构,主要包括 Browser 进程、GPU 进程、网络进程和多个渲染进程。每个标签页通常对应一个独立的渲染进程,而事件循环就运行在渲染进程的主线程上。
1.1 渲染进程包含哪些线程
渲染进程内部并不是单一线程,而是包含多个协作线程:
- 主线程(Main Thread):执行 JavaScript、解析 HTML/CSS、计算样式、布局(Layout)、绘制(Paint)。事件循环就跑在这里。
- 合成线程(Compositor Thread):接收绘制指令,将图层合成为最终图像,可以独立处理滚动、变换等不触发布局的操作。
- Worker 线程:包括 Web Worker、Service Worker,它们有自己独立的事件循环。
- 网络线程与定时器线程:
1setTimeout
的计时由独立线程管理,时间到了才把回调推入主线程的任务队列。
这种架构决定了 JavaScript 是单线程的(主线程),但浏览器本身是多线程的。理解这一点能解释很多”反直觉”的现象,比如为什么
1 | setTimeout(fn, 0) |
不会立即执行。
二、事件循环的核心模型
HTML 规范定义了标准的事件循环处理模型。每个事件循环都有一个或多个任务队列(Task Queue),一个微任务队列(Microtask Queue),以及一个执行栈(Call Stack)。事件循环的每一轮(称为一个 tick)大致遵循如下流程:
- 从任务队列中取出一个最旧的可执行任务(宏任务),推入执行栈执行。
- 执行完毕后,清空整个微任务队列(执行所有微任务,如果在执行过程中又产生了新的微任务,也会一并执行)。
- 检查是否需要渲染(Render Steps):如果浏览器认为需要更新视图,则执行
1requestAnimationFrame
回调、Resize、Scroll、样式计算、布局、绘制。
- 回到步骤 1,重复上述循环。
这个流程可以用伪代码表示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 // 事件循环简化模型
while (true) {
// 1. 取出一个宏任务执行
const task = taskQueue.shift();
if (task) task();
// 2. 清空所有微任务
while (microtaskQueue.length) {
const microtask = microtaskQueue.shift();
microtask();
}
// 3. 渲染阶段(约 16.67ms 一次,对应 60fps)
if (needsRender) {
runAnimationFrameCallbacks();
layout();
paint();
}
}
注意第三步:渲染不是每个 tick 都会发生。浏览器会根据屏幕刷新率(通常 60Hz)和页面是否有更新来决定是否渲染。这意味着微任务总是在下一次渲染之前执行完毕,而宏任务可能在两次渲染之间执行多次。
三、宏任务与微任务的来源
3.1 常见的宏任务
宏任务(Macro Task,也叫 Task)由宿主环境(浏览器)调度,常见的来源包括:
-
1setTimeout
/
1setInterval的回调
- I/O 操作完成后的回调(如文件读取)
- UI 事件(点击、输入、滚动等)的回调
-
1postMessage
接收到的消息
- 网络请求完成的回调(如
1XMLHttpRequest
的
1onload)
-
1requestIdleCallback
(这是一个特殊的低优先级任务)
3.2 常见的微任务
微任务(Microtask)由 JavaScript 引擎自身调度,一旦当前宏任务执行完毕,所有排队的微任务都会在渲染前被清空。常见的微任务来源:
-
1Promise.then / catch / finally
的回调
-
1queueMicrotask(fn)
显式注册的微任务
-
1MutationObserver
的回调
-
1IntersectionObserver
的回调(注意:部分实现将其归为宏任务)
-
1async/await
中
1await后的代码(本质是 Promise 的 then)
3.3 对比一览
| 维度 | 宏任务 | 微任务 |
|---|---|---|
| 调度者 | 宿主环境(浏览器) | JavaScript 引擎 |
| 执行时机 | 每轮 tick 执行一个 | 每轮 tick 清空全部 |
| 优先级 | 较低 | 较高(在渲染前执行) |
| 典型代表 | setTimeout | Promise.then |
| 能否阻塞渲染 | 单个任务可阻塞,但下一次渲染前会切换 | 会阻塞,微任务不清空不渲染 |
四、经典执行顺序实战
光看理论不够,我们用一段代码来验证执行顺序。建议你在浏览器控制台运行这段代码,观察输出结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 console.log('1. 同步代码开始');
setTimeout(() => {
console.log('4. 宏任务 setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('2. 微任务 Promise.then');
});
Promise.resolve().then(() => {
console.log('3. 微任务 Promise.then 第二个');
});
console.log('1.1 同步代码结束');
// 输出顺序:
// 1. 同步代码开始
// 1.1 同步代码结束
// 2. 微任务 Promise.then
// 3. 微任务 Promise.then 第二个
// 4. 宏任务 setTimeout
解释:同步代码先执行完毕,然后清空微任务队列(两个 Promise.then 按注册顺序执行),最后才执行宏任务 setTimeout。这就是”微任务优先于宏任务”的真正含义——它指的是在同一轮 tick 中,微任务在下一个宏任务之前执行。
4.1 async/await 的本质
1 | async/await |
是 Promise 的语法糖,
1 | await |
之后的代码等价于
1 | .then() |
的回调,因此也是微任务。看下面这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end'); // 微任务
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise');
resolve();
}).then(() => {
console.log('promise.then'); // 微任务
});
console.log('script end');
// 输出顺序:
// script start
// async1 start
// async2
// promise
// script end
// async1 end
// promise.then
// setTimeout
关键点:执行到
1 | await async2() |
时,
1 | async2() |
本身是同步调用的(打印 “async2″),但
1 | await |
会让出主线程,
1 | async1 end |
被推入微任务队列。因此同步代码继续执行,最后才依次清空微任务(async1 end、promise.then),再执行宏任务 setTimeout。
五、requestAnimationFrame:渲染前的钩子
1 | requestAnimationFrame |
(简称 rAF)是一个特殊的 API,它的回调在浏览器准备渲染下一帧之前执行,紧跟在微任务清空之后、布局和绘制之前。这使得 rAF 成为执行动画的最佳选择——它能保证回调与屏幕刷新同步,避免丢帧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 // 错误示范:用 setTimeout 做动画
function animateBad() {
const el = document.getElementById('box');
let left = 0;
function step() {
left += 2;
el.style.transform = `translateX(${left}px)`;
if (left < 300) setTimeout(step, 0);
}
step();
}
// 正确示范:用 requestAnimationFrame 做动画
function animateGood() {
const el = document.getElementById('box');
let left = 0;
function step() {
left += 2;
el.style.transform = `translateX(${left}px)`;
if (left < 300) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
用
1 | setTimeout |
做动画的问题在于:它无法与屏幕刷新对齐,可能在同一帧内多次触发样式变更(浪费计算),也可能跳过某些帧(卡顿)。rAF 则保证一帧只回调一次,且在浏览器即将绘制时执行,性能最优。
5.1 rAF 与微任务的顺序
一个容易混淆的点:rAF 回调既不是宏任务也不是微任务,它属于渲染阶段的独立步骤,位于微任务清空之后。验证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 requestAnimationFrame(() => {
console.log('rAF callback');
});
Promise.resolve().then(() => {
console.log('microtask');
});
setTimeout(() => {
console.log('macrotask');
}, 0);
// 输出顺序:
// microtask
// rAF callback
// macrotask
微任务最先执行,然后是 rAF(如果这一 tick 触发了渲染),最后是下一个宏任务。但要注意:如果当前 tick 没有渲染需求(比如标签页在后台),rAF 回调会被延迟到下一次实际渲染时才执行。
六、实战:为什么 setTimeout 不准
很多开发者发现
1 | setTimeout(fn, 1000) |
实际上经常超过 1 秒才执行。原因在于:setTimeout 的第二个参数只是”最小延迟”,而非”精确延迟”。事件循环必须等当前宏任务及所有微任务执行完毕,才会去检查任务队列。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 // 模拟一个耗时同步任务
function heavyWork() {
const start = Date.now();
while (Date.now() - start < 500) {
// 阻塞主线程 500ms
}
}
console.time('timer');
setTimeout(() => {
console.timeEnd('timer'); // 实际耗时远超 100ms
}, 100);
heavyWork(); // 阻塞了主线程
// 输出示例:timer: 500.123ms
这个例子清楚地展示了:只要主线程被占用,定时器就无法及时触发。这也是为什么长时间同步计算会”冻结”页面——所有宏任务、微任务、渲染都被阻塞。
6.1 解决方案:任务分片
对于耗时计算,应该把任务切分成小块,用
1 | setTimeout(fn, 0) |
或更好的
1 | requestIdleCallback |
/
1 | scheduler.postTask() |
让出主线程,给浏览器喘息的机会:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 // 将大数据处理分片执行
function processChunked(data, chunkSize, processFn) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, data.length);
while (index < end) {
processFn(data[index]);
index++;
}
if (index < data.length) {
// 让出主线程,给浏览器处理交互和渲染的机会
setTimeout(processChunk, 0);
}
}
processChunk();
}
// 使用示例:处理 10 万条数据不卡顿
const bigArray = Array.from({ length: 100000 }, (_, i) => i);
processChunked(bigArray, 1000, (item) => {
// 对每条数据执行操作
});
七、Promise 与微任务的陷阱
7.1 微任务递归会饿死宏任务
由于微任务队列是在每轮 tick 中”全部清空”的,如果你在微任务里不断创建新的微任务,会导致宏任务永远得不到执行,页面会卡死:
1
2
3
4
5
6
7
8 // 危险!这段代码会让页面冻结
function infiniteMicrotask() {
Promise.resolve().then(() => {
console.log('微任务不断生成...');
infiniteMicrotask(); // 递归创建微任务
});
}
// infiniteMicrotask(); // 不要在生产环境运行!
这个例子说明:微任务优先级高并不意味着它总是好的。无限递归的微任务会饿死所有宏任务(包括 UI 事件、渲染),页面完全无响应。
7.2 Promise 链的错误处理顺序
多个 Promise 链的错误处理也依赖微任务顺序。看下面的对比:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // 场景 A:catch 在 then 之后
Promise.resolve()
.then(() => { throw new Error('出错了'); })
.then(() => console.log('我不会执行'))
.catch((err) => console.log('捕获:', err.message));
// 场景 B:先 catch 再 then
Promise.resolve()
.then(() => { throw new Error('出错了'); })
.catch((err) => console.log('捕获:', err.message))
.then(() => console.log('我会执行,因为 catch 已经处理了错误'));
// 场景 A 输出:捕获:出错了
// 场景 B 输出:捕获:出错了 然后输出:我会执行...
理解这一点对于编写健壮的异步错误处理逻辑至关重要。
1 | catch |
本质上是
1 | then(undefined, onRejected) |
的语法糖,它返回一个新的已 fulfilled 的 Promise。
八、Web Worker:真正的多线程
当主线程确实需要执行密集计算时,Web Worker 是正解。Worker 运行在独立线程,有自己独立的事件循环,不会阻塞主线程。主线程与 Worker 通过
1 | postMessage |
通信,消息传递是异步的(走宏任务队列)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // main.js(主线程)
const worker = new Worker('worker.js');
worker.postMessage({ command: 'calculate', data: largeArray });
worker.onmessage = function (e) {
console.log('Worker 返回结果:', e.data.result);
};
// worker.js(Worker 线程)
self.onmessage = function (e) {
const { command, data } = e.data;
if (command === 'calculate') {
// 执行耗时计算,不阻塞主线程
const result = data.reduce((sum, n) => sum + n * n, 0);
self.postMessage({ result });
}
};
需要注意的是:Worker 不能直接操作 DOM(没有
1 | document |
对象),只能通过消息传递数据。对于 UI 密集型和计算密集型分离的场景,Worker 是最佳实践。SharedArrayBuffer + Atomics 还能实现真正的共享内存并发,但需要考虑线程安全和 COOP/COEP 安全头。
九、调度新 API:scheduler.postTask
Chrome 94+ 引入了
1 | scheduler.postTask() |
API,提供了比 setTimeout 更精细的优先级控制。它支持
1 | user-blocking |
(最高)、
1 | user-visible |
(默认)、
1 | background |
(最低)三个优先级:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 // 高优先级任务(如用户输入响应)
scheduler.postTask(() => {
updateUIBasedOnUserInput();
}, { priority: 'user-blocking' });
// 后台任务(如数据预取、日志上报)
scheduler.postTask(() => {
prefetchNextPageData();
reportAnalytics();
}, { priority: 'background' });
// 配合 AbortController 取消任务
const controller = new AbortController();
scheduler.postTask(
() => console.log('可能被取消的任务'),
{ signal: controller.signal }
);
// 某些条件下取消
controller.abort();
这个 API 让浏览器能更智能地调度任务,特别适合需要精确控制优先级的复杂应用。目前兼容性还在完善中,建议结合
1 | requestIdleCallback |
作为兜底方案。
十、调试技巧与常见问题排查
10.1 用 Performance 面板分析
Chrome DevTools 的 Performance 面板可以可视化每个任务的执行时机。录制一段操作后,你会看到:
- 黄色块:脚本执行(Scripting),对应宏任务和微任务
- 紫色块:渲染(Rendering),包括样式计算、布局
- 绿色块:绘制(Painting),将像素绘制到屏幕
如果黄色块过长(超过 50ms),说明有长任务(Long Task),需要优化。可以利用
1 | PerformanceObserver |
监控长任务:
1
2
3
4
5
6
7
8
9
10
11
12 // 监控长任务并上报
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`长任务耗时:${entry.duration.toFixed(2)}ms`);
// 可以上报到监控系统
navigator.sendBeacon('/api/long-task', JSON.stringify({
duration: entry.duration,
startTime: entry.startTime,
}));
}
});
observer.observe({ entryTypes: ['longtask'] });
10.2 常见误区清单
| 误区 | 真相 |
|---|---|
| setTimeout(fn, 0) 立即执行 | 至少延迟 4ms(嵌套调用时),且要等当前任务和微任务完成 |
| 微任务一定比宏任务快 | 在同一 tick 内是这样,但跨 tick 不一定 |
| await 会阻塞后续代码 | 不会,它只是把后续代码包装成微任务 |
| rAF 是微任务 | 不是,它是渲染阶段的独立步骤 |
| Promise 是异步的 | 构造函数内的代码是同步的,只有 then 回调才是异步微任务 |
总结
事件循环是前端开发者的”内功心法”,理解它能让你写出更流畅的动画、更高效的异步代码、更少卡顿的页面。回顾本文的几个核心要点:
- 每个 tick:执行一个宏任务 → 清空所有微任务 → 渲染(如果需要)。
- 微任务优先级高于宏任务,但无限递归的微任务会饿死页面。
- rAF 是做动画的最佳选择,与屏幕刷新对齐。
- 长任务(超过 50ms)会阻塞渲染和交互,必须分片或迁移到 Web Worker。
- 新 API 如
1scheduler.postTask
提供了更精细的调度控制。
掌握这些原理后,建议你在实际项目中多用 Performance 面板观察任务执行情况,把理论知识转化为工程能力。当你能准确预测一段异步代码的执行顺序、能解释动画卡顿的根本原因、能用分片优化长任务时,说明你已经真正理解了事件循环。

汤不热吧