1. 引言
JavaScript 是单线程语言,却能在浏览器中流畅地处理动画、网络请求和用户交互,这背后离不开**事件循环(Event Loop)**机制。理解事件循环,是掌握异步编程、避免页面卡顿、写出高性能代码的关键。
很多初学者会困惑:为什么 setTimeout(0) 里的回调不是立即执行?为什么 Promise.then 有时比 setTimeout 先执行?这些看似「反直觉」的现象,根源都在于事件循环对宏任务 与微任务的调度规则。
本文将带你从 setTimeout 到 Promise,系统梳理宏任务与微任务的区别、Event Loop 的执行阶段、渲染时机,以及面试中高频出现的题目拆解。读完你会明白:事件循环不是玄学,而是一套可预测的调度规则。
2. 为什么需要事件循环
JavaScript 运行在单线程上,意味着同一时间只能执行一段代码。如果遇到耗时操作(如网络请求、文件读取),线程会被阻塞,页面就会卡死。
想象一下:如果 fetch 请求是同步的,那么当浏览器等待服务器响应时,用户点击按钮、滚动页面、输入文字都会毫无反应,整个页面像「冻结」了一样。这种体验显然是无法接受的。
为了解决这个问题,JavaScript 引入了异步回调机制:将耗时任务交给浏览器或 Node.js 的底层线程处理,完成后把回调放入任务队列,等待主线程空闲时执行。
事件循环就是连接「调用栈」与「任务队列」的调度器,它决定了何时执行哪个任务。它的核心职责可以概括为三点:
- 调度:决定下一个要执行的任务;
- 优先级:区分宏任务与微任务,微任务优先;
- 协调渲染:在合适的时机让浏览器绘制页面。
理解了这个「为什么」,后面所有看似复杂的规则都会变得顺理成章。
下面用一张图直观对比「同步阻塞」与「异步回调」两种模型:
3. 宏任务与微任务
事件循环的核心,是把任务分成**宏任务(MacroTask)与微任务(MicroTask)**两类,并规定它们的执行顺序。理解这两类任务的来源、特点和调度规则,是掌握事件循环的第一步。
3.1 宏任务(MacroTask)
宏任务是由宿主环境(浏览器/Node.js)提供的任务,常见来源包括:
setTimeout/setIntervalsetImmediate(Node.js)- I/O 操作
- UI 渲染(浏览器)
requestAnimationFrame(浏览器)
每个宏任务执行完毕后,事件循环会检查是否需要渲染页面。
宏任务的特点是一次只取一个:每轮事件循环从宏任务队列头部取出一个任务执行,执行完后再处理微任务队列,然后才进入下一轮。这意味着即使宏任务队列中有 100 个任务,它们也不会被一口气全部执行,而是被微任务和渲染「打断」。
javascript
// 宏任务队列中有多个任务,但每轮只取一个
setTimeout(() => console.log('宏任务 1'), 0);
setTimeout(() => console.log('宏任务 2'), 0);
setTimeout(() => console.log('宏任务 3'), 0);
// 输出顺序:宏任务 1 → 宏任务 2 → 宏任务 3
// 但每个宏任务之间,都会先清空微任务队列、检查渲染
为什么宏任务要「一次只取一个」? 因为宏任务之间可能插入渲染。如果一口气执行完所有宏任务,浏览器就没有机会绘制页面,用户会看到明显的卡顿。把宏任务拆成「一次一个」,浏览器就能在任务间隙完成渲染,保持界面流畅。
3.2 微任务(MicroTask)
微任务是由 JavaScript 引擎自身提供的任务,常见来源包括:
Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(Node.js,优先级高于普通微任务)
微任务的特点是在当前宏任务结束后、下一个宏任务开始前,一次性清空队列。
与宏任务「一次取一个」不同,微任务队列是一次性全部清空的。也就是说,如果微任务执行过程中又产生了新的微任务,这些新微任务也会被继续执行,直到微任务队列为空。这也是为什么微任务可以用于「异步但尽快执行」的场景。
javascript
// 微任务队列一次性清空,包括执行过程中新产生的微任务
Promise.resolve()
.then(() => {
console.log('微任务 1');
// 在微任务中又产生新的微任务
queueMicrotask(() => console.log('微任务 2'));
})
.then(() => console.log('微任务 3'));
// 输出顺序:微任务 1 → 微任务 3 → 微任务 2
// 注意:微任务 2 虽然是在微任务 1 中产生的,但会在当前宏任务结束前全部执行完
为什么微任务要「一次性清空」? 微任务的设计初衷是「尽快执行、不打断渲染」。它被安排在宏任务结束后、渲染之前执行,因此可以安全地修改 DOM 而不会造成多次渲染。如果微任务也像宏任务那样「一次一个」,那么 DOM 修改就会被拆散到多个渲染周期,反而降低性能。
3.3 两者的核心区别
| 对比项 | 宏任务 | 微任务 |
|---|---|---|
| 来源 | 宿主环境 | JS 引擎 |
| 执行时机 | 每轮事件循环取一个 | 当前宏任务结束后全部清空 |
| 优先级 | 低 | 高 |
| 典型代表 | setTimeout |
Promise.then |
| 队列清空方式 | 逐个执行 | 一次性清空 |
| 是否阻塞渲染 | 可能阻塞 | 在渲染前清空,不阻塞渲染 |
| 产生新任务 | 新宏任务进入下一轮 | 新微任务在当前轮继续执行 |
一句话总结:宏任务是「排队叫号」,微任务是「插队优先」。微任务永远在下一个宏任务之前执行完毕。
一个直观的类比:想象你在餐厅点餐。宏任务就像「叫号取餐」------每叫一个号,取一份餐,然后服务员可能去收拾桌子(渲染)。微任务就像「加急订单」------只要当前顾客的餐取完,所有加急订单会一次性全部做完,然后才轮到下一位顾客(下一个宏任务)。
下面用一张图梳理宏任务与微任务的调度关系:
4. Event Loop 执行阶段
4.1 浏览器中的事件循环
浏览器的事件循环可以简化为以下步骤:
- 从宏任务队列中取出一个宏任务执行;
- 执行过程中产生的微任务,全部推入微任务队列;
- 宏任务执行完毕后,清空微任务队列;
- 如果需要渲染,执行渲染;
- 回到第 1 步,继续下一轮循环。
这里有一个容易忽略的细节:微任务队列的清空是「递归」的 。如果在清空微任务的过程中又产生了新的微任务,这些新微任务会继续被加入当前队列并执行,直到队列完全为空。这意味着微任务可以无限嵌套,理论上可能阻塞事件循环------这也是为什么在微任务中递归调用 queueMicrotask 需要格外小心。
4.2 Node.js 中的事件循环
Node.js 的事件循环分为多个阶段(phase),依次循环:
- timers :执行
setTimeout/setInterval回调; - pending callbacks:执行延迟到下一轮循环的 I/O 回调;
- idle / prepare:内部使用;
- poll:获取新的 I/O 事件,执行 I/O 回调;
- check :执行
setImmediate回调; - close callbacks :执行
close事件回调。
每个阶段之间,Node.js 也会清空微任务队列(process.nextTick 优先于 Promise)。
Node.js 与浏览器最大的差异在于:Node.js 的微任务清空发生在每个阶段之间,而不是每个宏任务之后 。这意味着在 Node.js 中,setTimeout 回调执行完后,会先清空微任务,再进入下一个阶段(如 poll),而不是像浏览器那样在同一个宏任务结束后立即清空。
另外,process.nextTick 的优先级高于 Promise,它会在当前阶段结束后、其他微任务之前执行。这也是面试中常见的考点。
下面用一张图展示 Node.js 事件循环的完整阶段流转:
5. 渲染时机
浏览器并不是每执行完一个任务就立刻渲染,而是遵循以下原则:
- 微任务执行完毕后,浏览器会检查是否需要渲染;
- 多个宏任务之间,浏览器可能合并渲染,避免频繁重绘;
requestAnimationFrame的回调会在下一次渲染之前执行,适合做动画。
因此,如果我们在微任务中修改 DOM,浏览器会在当前宏任务结束后统一渲染一次,而不是多次渲染。
这里有一个实用的性能优化技巧:把 DOM 批量修改放在微任务中。例如,在一个循环中多次修改 DOM,如果每次都触发渲染,会造成严重的性能问题。但如果把修改合并到微任务中,浏览器只会渲染一次。
javascript
// 不推荐:循环中多次修改 DOM,可能触发多次渲染
for (let i = 0; i < 100; i++) {
document.body.style.marginLeft = i + 'px';
}
// 推荐:合并到微任务中,只渲染一次
let margin = 0;
for (let i = 0; i < 100; i++) {
margin = i;
}
queueMicrotask(() => {
document.body.style.marginLeft = margin + 'px';
});
另外,requestAnimationFrame 与 setTimeout 的区别也值得注意:requestAnimationFrame 的回调会在渲染前执行,且浏览器会自动优化帧率(通常 60fps),而 setTimeout 则可能因为事件循环的调度而错过渲染时机,导致动画卡顿。
5.1 渲染的完整时序
要理解渲染时机,需要先明确浏览器一帧(frame)的完整流程。以 60fps 为例,一帧大约 16.7ms,其典型时序如下:
- 执行宏任务:从宏任务队列取出一个任务执行;
- 清空微任务:执行完宏任务后,一次性清空微任务队列;
- 执行
requestAnimationFrame回调:在渲染之前执行,适合更新动画状态; - 样式计算与布局(Layout):浏览器计算元素的最终样式和几何位置;
- 绘制(Paint):将元素绘制到屏幕上;
- 合成(Composite):将各图层合成,最终呈现在屏幕上。
关键点 :requestAnimationFrame 的回调发生在渲染之前 ,而不是渲染之后。这意味着在 requestAnimationFrame 中修改 DOM,浏览器会在同一帧内完成渲染,不会产生额外的帧延迟。
5.2 为什么微任务中修改 DOM 只渲染一次
很多开发者会疑惑:为什么在微任务中连续修改 DOM,浏览器只渲染一次?
原因在于:渲染不是每执行一个任务就触发,而是按「帧」为单位批量进行的。微任务队列在当前宏任务结束后被一次性清空,期间所有 DOM 修改都会被记录,但浏览器不会立即渲染,而是等到这一帧的渲染阶段统一处理。
javascript
// 在微任务中连续修改 DOM
Promise.resolve().then(() => {
document.body.style.background = 'red';
document.body.style.color = 'white';
document.body.style.fontSize = '20px';
});
// 浏览器只会渲染一次,而不是三次
这带来一个重要的性能启示:把多次 DOM 修改合并到同一个微任务(或同一个宏任务)中,可以显著减少渲染次数,从而提升页面性能。
5.3 requestAnimationFrame 与 setTimeout 的实战对比
requestAnimationFrame 和 setTimeout 都能实现「延迟执行」,但它们的调度机制完全不同:
| 对比项 | requestAnimationFrame |
setTimeout |
|---|---|---|
| 执行时机 | 下一次渲染之前 | 宏任务队列中,按延迟时间调度 |
| 帧率 | 跟随浏览器刷新率(通常 60fps) | 不保证与刷新率同步 |
| 页面隐藏时 | 自动暂停 | 继续执行(可能浪费资源) |
| 适合场景 | 动画、视觉更新 | 通用延迟任务 |
javascript
// 用 requestAnimationFrame 实现平滑动画
function animate() {
element.style.transform = `translateX(${x}px)`;
x += 2;
if (x < 300) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
// 用 setTimeout 实现动画(可能卡顿)
function animateWithTimeout() {
element.style.transform = `translateX(${x}px)`;
x += 2;
if (x < 300) {
setTimeout(animateWithTimeout, 16);
}
}
setTimeout(animateWithTimeout, 16);
为什么 setTimeout 动画可能卡顿? 因为 setTimeout 的回调进入宏任务队列,执行时机受事件循环调度影响,可能与浏览器的渲染帧不同步。如果回调在两次渲染之间执行,动画就会出现跳帧;而 requestAnimationFrame 的回调被安排在渲染之前,能保证每一帧都更新一次,动画自然流畅。
5.4 实战:用渲染时机优化列表渲染
假设我们要渲染一个包含 10000 条数据的列表,直接同步渲染会导致页面长时间无响应。利用渲染时机,我们可以分批渲染:
javascript
const list = document.getElementById('list');
const data = Array.from({ length: 10000 }, (_, i) => i);
let index = 0;
const batchSize = 100;
function renderBatch() {
// 每次渲染 100 条,避免一次性阻塞主线程
for (let i = 0; i < batchSize && index < data.length; i++, index++) {
const item = document.createElement('li');
item.textContent = `Item ${data[index]}`;
list.appendChild(item);
}
if (index < data.length) {
// 用 requestAnimationFrame 在下一帧继续渲染
requestAnimationFrame(renderBatch);
}
}
requestAnimationFrame(renderBatch);
这样,浏览器可以在每批渲染之间绘制页面,用户不会感到页面「卡死」,同时动画和交互也能保持流畅。
6. setTimeout 0 的真相
setTimeout(fn, 0) 并不是「立即执行」,而是至少等待 0 毫秒后,把回调放入宏任务队列。
这意味着:
- 它会在当前同步代码执行完毕后,再进入事件循环;
- 如果微任务队列中有任务,微任务会先于
setTimeout(0)执行; - 实际延迟时间受浏览器最小定时器间隔(通常 4ms)影响。
javascript
console.log('同步代码');
setTimeout(() => {
console.log('setTimeout 0');
}, 0);
Promise.resolve().then(() => {
console.log('微任务');
});
// 输出顺序:
// 同步代码
// 微任务
// setTimeout 0
6.1 为什么不是「立即执行」
很多初学者会误以为 setTimeout(fn, 0) 会立刻执行回调,但实际上它只是把回调放入宏任务队列,真正的执行要等到事件循环的下一次迭代。
javascript
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
console.log('C');
// 输出顺序:A C B
// 而不是 A B C
原因 :setTimeout 的回调不会打断当前正在执行的同步代码。只有当前调用栈清空后,事件循环才会从宏任务队列中取出回调执行。所以 setTimeout(0) 的本质是「把任务推迟到当前同步代码执行完之后」。
6.2 最小延迟时间(4ms 钳制)
浏览器对 setTimeout 的延迟有一个**最小钳制(clamping)**规则:当嵌套层级超过 5 层时,最小延迟会被强制提升到 4ms。
javascript
// 嵌套 setTimeout,触发 4ms 钳制
let start = Date.now();
let count = 0;
function nested() {
count++;
if (count < 10) {
setTimeout(nested, 0);
} else {
console.log(`10 次 setTimeout(0) 共耗时:${Date.now() - start}ms`);
}
}
setTimeout(nested, 0);
// 实际输出:10 次 setTimeout(0) 共耗时:约 36ms 以上
// 因为第 6 次开始,每次至少等待 4ms
关键点:
- 前 5 层嵌套,
setTimeout(0)可以接近 0ms 执行; - 第 6 层开始,浏览器强制最小延迟为 4ms;
- 这是为了防止开发者用
setTimeout无限循环阻塞主线程。
6.3 setTimeout 0 的常见用途
用途一:把任务推迟到渲染之后
javascript
// 先让浏览器渲染当前状态,再执行耗时操作
button.addEventListener('click', () => {
// 立即更新 UI 状态
button.disabled = true;
button.textContent = '处理中...';
// 用 setTimeout(0) 让浏览器先完成渲染
setTimeout(() => {
// 执行耗时操作
heavyTask();
}, 0);
});
用途二:避免长任务阻塞渲染
javascript
// 不推荐:一次性执行大量计算,阻塞主线程
function processAll(items) {
for (const item of items) {
process(item); // 可能耗时很长
}
}
// 推荐:分批处理,每批之间让出主线程
function processInBatches(items, batchSize = 100) {
let index = 0;
function nextBatch() {
const end = Math.min(index + batchSize, items.length);
for (; index < end; index++) {
process(items[index]);
}
if (index < items.length) {
setTimeout(nextBatch, 0); // 让出主线程,允许渲染
}
}
nextBatch();
}
用途三:模拟「异步」行为,统一代码风格
javascript
// 某些场景下,希望回调始终异步执行,避免「同步回调」带来的时序问题
function loadData(callback) {
const data = fetchDataSync(); // 同步获取数据
setTimeout(() => callback(data), 0); // 强制异步回调
}
// 调用方可以放心地假设 callback 一定是异步的
loadData((data) => {
console.log('数据加载完成', data);
});
console.log('loadData 调用结束');
// 输出顺序:
// loadData 调用结束
// 数据加载完成
6.4 setTimeout 0 与 Promise 的执行顺序
这是面试中最高频的考点之一:setTimeout(0) 和 Promise.then 谁先执行?
javascript
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
});
// 输出顺序:
// promise
// setTimeout
原因 :Promise.then 进入微任务队列 ,setTimeout 进入宏任务队列 。微任务在当前宏任务结束后立即清空,而宏任务要等到下一轮事件循环才执行。所以微任务永远先于宏任务。
6.5 一个容易踩的坑:setTimeout 0 并不保证 0ms
javascript
// 在浏览器中实测
const start = Date.now();
setTimeout(() => {
console.log(`实际延迟:${Date.now() - start}ms`);
}, 0);
// 输出:实际延迟:4ms(或更大)
// 而不是 0ms
原因:
- 浏览器的最小定时器间隔(通常 4ms)会钳制延迟;
- 事件循环的调度本身有开销;
- 如果主线程繁忙,回调会被进一步推迟。
结论 :setTimeout(0) 的语义是「尽快 」,而不是「立即」。它保证的是「不早于 0ms」,而不是「恰好 0ms」。
7. 面试题拆解
面试中事件循环相关的题目,核心都是考察你对「同步 → 微任务 → 宏任务」执行顺序的理解。下面从易到难,拆解几道高频题目。
7.1 经典输出顺序题
javascript
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
执行过程分析:
- 同步代码依次执行,输出
1、4; setTimeout回调进入宏任务队列;Promise.then进入微任务队列;- 同步代码结束,清空微任务队列,输出
3; - 下一轮事件循环取出宏任务,输出
2。
最终输出:1 4 3 2
解题口诀:先同步、再微任务、最后宏任务。遇到这类题,先在纸上画出「调用栈 → 微任务队列 → 宏任务队列」三个容器,逐个往里放,再按顺序取出。
7.2 宏任务嵌套微任务
javascript
setTimeout(() => {
console.log('宏任务1');
Promise.resolve().then(() => {
console.log('微任务1');
});
}, 0);
setTimeout(() => {
console.log('宏任务2');
}, 0);
执行过程分析:
- 两个
setTimeout依次进入宏任务队列; - 取出宏任务1,输出
宏任务1,产生微任务1; - 宏任务1结束,清空微任务队列,输出
微任务1; - 取出宏任务2,输出
宏任务2。
最终输出:宏任务1 微任务1 宏任务2
关键点 :微任务是在当前宏任务结束后清空,而不是在所有宏任务结束后。所以宏任务1 产生的微任务1,会在宏任务2 之前执行。这是「宏任务嵌套微任务」类题目的核心考点。
7.3 async / await 与微任务
javascript
async function test() {
console.log('async 开始');
await Promise.resolve();
console.log('async 结束');
}
console.log('同步开始');
test();
console.log('同步结束');
执行过程分析:
- 输出
同步开始; - 调用
test(),同步执行到await之前,输出async 开始; await后面的代码被放入微任务队列;- 输出
同步结束; - 清空微任务队列,输出
async 结束。
最终输出:同步开始 async 开始 同步结束 async 结束
易错点 :很多初学者以为 await 会「阻塞」后面的代码,实际上 await 只是把后续代码推迟到微任务队列 ,并不会阻塞同步代码的执行。所以 同步结束 会先于 async 结束 输出。
7.4 微任务中嵌套宏任务
javascript
Promise.resolve().then(() => {
console.log('微任务1');
setTimeout(() => {
console.log('宏任务2');
}, 0);
});
setTimeout(() => {
console.log('宏任务1');
}, 0);
执行过程分析:
Promise.then进入微任务队列,setTimeout进入宏任务队列;- 同步代码结束,清空微任务队列,输出
微任务1,同时产生宏任务2; - 宏任务队列中现在有宏任务1、宏任务2,按顺序取出;
- 输出
宏任务1,再输出宏任务2。
最终输出:微任务1 宏任务1 宏任务2
关键点 :微任务中产生的宏任务,会追加到宏任务队列末尾,而不是插队。所以即使宏任务2 是在微任务1 中产生的,它仍然排在宏任务1 之后。
7.5 综合题:宏任务、微任务、async 混合
javascript
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('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
执行过程分析:
- 同步代码依次执行:输出
script start; setTimeout回调进入宏任务队列;- 调用
async1(),同步执行到await之前:输出async1 start,调用async2()输出async2; await async2()后面的代码(async1 end)被放入微任务队列;new Promise的构造函数是同步执行 的,输出promise1,promise2进入微任务队列;- 输出
script end; - 同步代码结束,清空微任务队列:先输出
async1 end(先入队),再输出promise2; - 下一轮事件循环取出宏任务,输出
setTimeout。
最终输出:script start async1 start async2 promise1 script end async1 end promise2 setTimeout
易错点:
new Promise的构造函数是同步执行 的,只有.then里的回调才是异步的;await后面的代码进入微任务队列,且先于 后面Promise.then的回调(因为先入队);- 微任务队列是 FIFO(先进先出),所以
async1 end先于promise2输出。
7.6 面试答题模板
面试中遇到事件循环的输出顺序题,可以按以下步骤作答:
- 先找同步代码 :从上到下执行,遇到
console.log直接输出; - 标记异步任务 :遇到
setTimeout放入宏任务队列,遇到Promise.then/await放入微任务队列; - 注意
new Promise构造函数 :它是同步执行的,只有.then是异步的; - 清空微任务:同步代码结束后,一次性清空微任务队列(注意 FIFO 顺序);
- 执行宏任务:从宏任务队列头部取出一个执行,执行完再清空微任务,如此循环。
一句话总结 :同步代码先跑完,微任务队列清空,宏任务逐个执行,每个宏任务结束再清空微任务。
下面用一张图总结面试题的通用推导流程:
8. 总结
- 宏任务由宿主环境提供,每轮事件循环取一个执行;
- 微任务由 JS 引擎提供,在当前宏任务结束后全部清空;
- 渲染发生在微任务清空之后、下一轮宏任务之前;
setTimeout(0)不是立即执行,而是进入宏任务队列;- 面试中遇到输出顺序题,先画调用栈,再按「同步 → 微任务 → 宏任务」的顺序推导。
掌握事件循环,不仅能应对面试,更能帮助你写出流畅、可预测的异步代码。