“事件循环”大概是前端面试里被问烂了的话题,但很多人背下了”宏任务、微任务”的顺序,却答不上来一个更实际的问题:我的动画为什么会卡? 这篇想把事件循环放回它真正的用途里——它决定了你的代码在什么时刻运行,以及屏幕在什么时刻更新。
为什么需要事件循环↗
JavaScript 是单线程的:同一时刻只有一段代码在跑。这个设计避免了多线程的锁与竞态,但也带来一个问题——如果某个操作要等很久(网络请求、定时器、用户点击),单线程岂不是要一直干等?
答案是:不等。JavaScript 把这类”要等”的操作交给宿主环境(浏览器或 Node)去处理,自己继续往下跑;等结果就绪,再通过一套调度机制把回调”塞回来”执行。这套调度机制,就是事件循环。
它的核心结构其实很简单:
- 一个调用栈(Call Stack):当前正在执行的函数。
- 一个宏任务队列(Task Queue / Macrotask):存放
setTimeout、事件回调、I/O 等。 - 一个微任务队列(Microtask Queue):存放
Promise.then、queueMicrotask、MutationObserver等。
一轮循环到底做了什么↗
事件循环的一”轮”(tick),可以精确地描述成这样几步:
1. 从宏任务队列取出一个任务,执行它(直到调用栈清空)。
2. 清空整个微任务队列——
注意:期间新产生的微任务,也要在本轮一并执行完。
3. 如果需要,执行渲染(样式、布局、绘制、合成)。
4. 回到第 1 步,取下一个宏任务。
这里有两个最容易被忽略、却最关键的细节:
每执行完一个宏任务,就会把微任务队列”一次性清空”。 微任务不是”每轮执行一个”,而是”执行到一个不剩”。
渲染发生在微任务之后、下一个宏任务之前。 也就是说,微任务是”插队在渲染之前”的。
理解了这两点,很多现象就顺理成章了。
经典题:执行顺序↗
先看一段几乎每个前端都见过的代码:
console.log('1 script start');
setTimeout(() => console.log('2 setTimeout'), 0);
Promise.resolve()
.then(() => console.log('3 promise 1'))
.then(() => console.log('4 promise 2'));
console.log('5 script end');
输出顺序是:
1 script start
5 script end
3 promise 1
4 promise 2
2 setTimeout
用上面的模型来解释:
- 整段
<script>本身就是第一个宏任务。同步代码先跑完,打印1和5。 - 期间
setTimeout的回调进了宏任务队列,两个.then进了微任务队列。 - 同步代码结束、调用栈清空后,先清空微任务队列:打印
3,这又产生一个新微任务(第二个.then),继续清空,打印4。 - 微任务清空后,才轮到下一个宏任务:打印
2。
结论一句话:同一轮里,微任务永远排在宏任务前面。 这也是”Promise 总比 setTimeout 快”的真正原因——不是 Promise 更快,而是它们在不同的队列里。
微任务的双刃剑:饿死渲染↗
既然微任务会在渲染前被”一次性清空”,那如果微任务里不断产生新的微任务呢?
function loop() {
Promise.resolve().then(loop); // 每个微任务又产生一个微任务
}
loop();
这段代码会让页面彻底卡死——因为微任务队列永远清不空,事件循环卡在第 2 步,永远走不到第 3 步的渲染。页面既不重绘,也不响应点击。
这是一个重要的直觉:微任务不是”更高级的异步”,它是”插在渲染前面的加急队列”。 用得好能保证状态一致性(比如 Vue 的 nextTick、React 的批处理都建立在微任务上),用不好就会饿死渲染。
和渲染对齐:requestAnimationFrame↗
既然渲染发生在每轮循环的固定时机,那么”在渲染前做点事”就有了专门的入口——requestAnimationFrame(rAF)。它的回调会在下一次绘制之前执行,因此是做动画的正确姿势:
function animate() {
element.style.transform = `translateX(${x}px)`;
x += 2;
requestAnimationFrame(animate); // 与刷新率对齐,通常每 16.7ms 一帧
}
requestAnimationFrame(animate);
对比一下三种”定时”手段的定位:
| 手段 | 时机 | 适合做什么 |
|---|---|---|
Promise.then / 微任务 | 当前宏任务结束、渲染之前 | 状态合并、批量更新 |
requestAnimationFrame | 每次绘制之前,与刷新率对齐 | 视觉动画、随帧更新 |
setTimeout(fn, 0) | 作为宏任务,下一轮才执行 | 让出主线程、拆分长任务 |
一个常见误区是用 setTimeout 做动画——它既不保证和刷新率对齐,最小延迟也常被浏览器限制在 4ms 以上,结果就是掉帧和不均匀。动画请用 rAF。
用宏任务”让路”:拆分长任务↗
反过来,如果你有一段计算量很大的同步代码,它会长时间占住调用栈,导致这期间无法渲染、无法响应交互(这正是”长任务”卡顿的来源)。这时可以主动把它切成小块,用宏任务让出主线程:
async function processInChunks(items) {
for (let i = 0; i < items.length; i++) {
doHeavyWork(items[i]);
// 每处理一批就让一次路,给渲染和交互留出机会
if (i % 100 === 0) {
await new Promise(resolve => setTimeout(resolve));
}
}
}
现代浏览器还提供了更语义化的 scheduler.yield() 和 isInputPending() 来做这件事,但底层思路都是一样的:主动交还主线程,让事件循环有机会去渲染和处理输入。
小结↗
把这篇的要点收束成几句话:
- 事件循环让单线程的 JavaScript 得以处理异步——它在调用栈、宏任务、微任务之间调度。
- 每个宏任务之后,微任务队列会被一次性清空;渲染排在微任务之后、下一个宏任务之前。
- 微任务是”渲染前的加急队列”,能保证一致性,也可能饿死渲染。
- 视觉动画用
requestAnimationFrame;拆分长任务用宏任务让路。
事件循环不是一道用来背的题。它描述的是”你的代码”和”屏幕更新”之间的节奏关系——一旦你能用它解释卡顿,就说明真正理解了。