3、事件循环与任务队列:宏任务与微任务、事件循环机制、setTimeout与requestAnimationFrame的区别
说到UI响应延迟,很多同学第一反应是「代码写慢了」。但我在实际项目中踩过不少坑,发现很多时候问题出在事件循环上。你写的代码明明很快,但浏览器就是卡着不动——这往往不是计算量的问题,而是任务排队的顺序出了问题。
今天我们就来彻底搞懂这件事。我会结合自己调试过的真实案例,把事件循环、宏任务、微任务以及那两个容易混淆的定时器API讲清楚。
3.1 事件循环机制:浏览器的心脏
JavaScript是单线程的,这大家都知道。但单线程怎么处理网络请求、用户点击、动画渲染这些并发的事情?答案就是事件循环(Event Loop)。
说白了,事件循环就是一个永不停歇的「排队叫号」系统。浏览器内部维护着一个任务队列,主线程不断从队列头部取出任务执行,执行完再取下一个。这个过程我习惯叫它「一次Tick」。
核心要点:事件循环的每一次迭代,都会先处理一个宏任务,然后清空所有微任务,最后决定是否更新渲染。
嗯,这里要注意:渲染更新并不是每次Tick都发生的。浏览器会尽量在16.6ms内完成一次渲染(60fps),但如果你的任务执行时间过长,渲染就会被推迟——这就是卡顿的根源。
我曾经调试过一个列表滚动卡顿的问题,排查了半天,最后发现是一个setInterval里做了大量DOM操作,把渲染线程堵死了。从那以后,我对任务队列的调度就格外敏感。
3.2 宏任务与微任务:谁先谁后?
任务队列其实分两种:宏任务(MacroTask)和微任务(MicroTask)。它们的执行顺序,是面试常考题,也是实际调试中容易忽略的点。
| 类型 | 常见来源 | 执行时机 |
|---|---|---|
| 宏任务 | setTimeout、setInterval、I/O、UI渲染、事件回调 | 每次事件循环取一个执行 |
| 微任务 | Promise.then/catch/finally、MutationObserver、queueMicrotask | 当前宏任务结束后、下一个宏任务开始前,全部清空 |
执行顺序的规则其实很简单:
- 执行一个宏任务(从宏任务队列头部取)
- 执行过程中产生的微任务,全部塞入微任务队列
- 宏任务执行完毕,立即清空微任务队列(直到队列为空)
- 执行渲染更新(如果需要)
- 回到第1步,取下一个宏任务
我曾经踩过的坑:在Promise.then里递归调用自身,导致微任务队列无限增长,页面直接卡死。微任务虽然快,但千万别在里面写死循环或无限递归。
为什么会这样?因为微任务队列是在当前宏任务结束后一次性清空的。如果你在微任务里不断添加新的微任务,那渲染就永远轮不到——用户看到的界面就卡住了。
3.3 setTimeout与requestAnimationFrame的区别
这两个API都用来「延迟执行」,但背后的机制完全不同。我见过不少同学把它们混用,结果UI动画出现奇怪的抖动。
3.3.1 setTimeout:不靠谱的定时器
setTimeout的本质是:至少延迟指定时间后,将回调加入宏任务队列。注意是「至少」,不是「精确」。因为前面可能有其他宏任务在排队。
// 这段代码的执行顺序,很多人会猜错
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出顺序:1, 4, 3, 2
即使setTimeout的延迟设为0,它也会被微任务Promise.then插队。这就是为什么setTimeout(fn, 0)不能保证「立即执行」——它只是把任务排到了宏任务队列末尾。
我的个人习惯:如果只是想异步执行一段代码,优先用queueMicrotask或Promise.resolve().then(),而不是setTimeout(fn, 0)。后者有4ms的最小延迟限制(HTML规范),而且会被微任务插队。
3.3.2 requestAnimationFrame:为渲染而生
requestAnimationFrame(简称rAF)是专门为动画和UI更新设计的。它的回调会在下一次浏览器渲染之前执行,而且会跟随显示器的刷新率同步(通常是60Hz)。
你想想看,如果你用setTimeout做动画,每16ms更新一次位置,但浏览器可能在20ms后才渲染——那中间就有4ms的错位,肉眼就能感觉到卡顿。而rAF保证回调执行后,紧接着就是渲染,时间上严丝合缝。
| 特性 | setTimeout | requestAnimationFrame |
|---|---|---|
| 执行时机 | 宏任务队列,不确定 | 渲染之前,与刷新率同步 |
| 最小延迟 | 4ms(嵌套时) | 无,跟随帧率 |
| 页面不可见时 | 继续执行(浪费性能) | 自动暂停 |
| 适用场景 | 延迟执行、轮询、非UI任务 | 动画、DOM更新、布局测量 |
我记得有一次优化一个拖拽组件,原来用setTimeout做位置更新,拖起来总感觉「黏黏的」。换成rAF后,手感立刻顺滑了。原因就是rAF把更新和渲染对齐了,没有多余的延迟。
3.4 事件循环与UI响应延迟的关系
现在我们把知识串起来。UI响应延迟,本质上就是用户事件(点击、滚动、输入)被其他任务堵在了队列里。
常见的几种情况:
- 宏任务执行时间过长:比如一个
for循环跑了100ms,后续的所有事件都得等着。 - 微任务队列爆炸:Promise链过长或递归添加微任务,导致渲染被无限推迟。
- 定时器滥用:用
setInterval做高频更新,任务堆积,渲染跟不上。 - rAF被误用:在rAF回调里做同步网络请求或大量计算,阻塞了紧随其后的渲染。
调试建议:打开Chrome DevTools的Performance面板,录制一段操作。看「Task」那一栏,如果某个任务超过50ms,就说明它阻塞了主线程。再点进去看是宏任务还是微任务,对应调整代码。
3.5 一张图看懂事件循环
下面这张SVG图,把整个事件循环的流程画出来了。我建议你把它保存下来,调试时对照着看。
3.6 实战:如何利用事件循环优化UI响应
知道了原理,我们来看几个具体的优化手段。
3.6.1 拆分长任务
如果一个宏任务执行超过50ms,就应该拆分成多个小任务。我常用的方法是setTimeout配合递归,或者用requestIdleCallback。
// 拆分前:一个宏任务执行100ms,阻塞UI
function processLargeArray(items) {
items.forEach(item => {
heavyComputation(item); // 假设每个耗时1ms,100个就是100ms
});
}
// 拆分后:每次只处理一小批,让出主线程
function processInChunks(items, chunkSize = 10) {
let index = 0;
function doChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
heavyComputation(items[index]);
}
if (index < items.length) {
setTimeout(doChunk, 0); // 排到宏任务队列末尾
}
}
doChunk();
}
3.6.2 用微任务做轻量异步
对于不需要等待渲染的轻量操作,用微任务更高效。比如状态更新后的数据校验。
// 用微任务避免阻塞渲染
function updateState(newData) {
this.data = newData;
queueMicrotask(() => {
this.validate(); // 校验逻辑,不涉及DOM操作
});
}
注意:微任务虽然快,但不要在里面做DOM操作或耗时计算。因为微任务执行期间,浏览器无法渲染,用户会感觉到卡顿。
3.6.3 动画用rAF,定时用setTimeout
这个原则说起来简单,但我见过太多人搞反了。记住:凡是跟视觉更新相关的,都用rAF;跟时间相关的延迟操作,用setTimeout。
// ✅ 正确:动画用rAF
function animate() {
element.style.transform = `translateX(${pos}px)`;
pos += 1;
requestAnimationFrame(animate);
}
// ❌ 错误:动画用setTimeout会导致掉帧
function animateBad() {
element.style.transform = `translateX(${pos}px)`;
pos += 1;
setTimeout(animateBad, 16); // 不精确,容易掉帧
}
3.7 总结一下
事件循环是浏览器调度的核心,理解它才能真正掌握UI响应延迟的调试。我个人的经验是:遇到卡顿,先看任务队列。打开Performance面板,看看是哪个任务占用了主线程,然后判断它是宏任务还是微任务,再决定怎么拆。
记住三个关键数字:
- 50ms:单个任务超过这个时间,用户就能感知到卡顿
- 16.6ms:一帧的时间,rAF会在这个时间窗口内触发
- 0ms:setTimeout(fn, 0)的最小延迟,实际可能更长