问题场景
前端性能优化里,最常被问的一句话是:"为什么我的页面滚动会卡?为什么这段代码跑完界面才更新?"
举几个每天都在发生的"玄学":
setTimeout(() => xxx, 0)想"立即"执行,结果却排在后面,甚至没生效。Promise.then明明写在setTimeout之前,却总是先执行。- 一个
while死循环,把整个页面锁死,连动画都停了。 requestAnimationFrame里改样式不卡,setTimeout里改就卡。
这些问题的答案,全都藏在**事件循环(Event Loop)**里。它是浏览器单线程下"调度一切"的底层规则,也是理解渲染时机、异步并发、性能优化绕不开的核心。本文不聊皮毛,直接讲透:宏任务/微任务/渲染的完整模型、Node 与浏览器的差异、以及真实的卡顿排查思路。
原理深入
1. 事件循环的完整模型
浏览器主线程是单线程,一次只做一件事。它靠一个"事件循环"不断从各类队列里抓任务执行。标准的处理流程是:
text
┌─────────────────────────────────────────┐
│ 1. 从【宏任务队列】取一个宏任务 │
│ (setTimeout/setInterval/I/O/UI事件/ │
│ postMessage/messageChannel) │
│ ↓ │
│ 2. 执行该宏任务(同步代码 + 内部逻辑) │
│ ↓ │
│ 3. 【清空整个微任务队列】 │
│ (Promise.then/catch/finally/ │
│ queueMicrotask/MutationObserver) │
│ ★ 注意:清空过程中新产生的微任务 │
│ 会继续推入队列,一并清完 │
│ ↓ │
│ 4. 更新渲染(如果该帧需要渲染) │
│ - 执行 requestAnimationFrame 回调 │
│ - 执行样式计算/布局/绘制/合成 │
│ ↓ │
│ 5. 回到第 1 步,取下一个宏任务 │
└─────────────────────────────────────────┘
三个铁律(务必记牢):
- 宏任务一帧一个:每轮只取一个宏任务执行,避免单次长时间占用。
- 微任务每帧清空 :一个宏任务结束后,必须把整个微任务队列清空(包括执行过程中新产生的)才进入下一阶段。
- rAF 卡在渲染前 :
requestAnimationFrame回调在"更新渲染"这一帧的开头执行,是所有视觉更新的第一优先级。
2. 为什么 Promise.then 永远比 setTimeout 先跑?
看这段代码,输出顺序是什么?
js
setTimeout(() => console.log('2setTimeout'), 0); // 宏任务
Promise.resolve().then(() => console.log('3promise')); // 微任务
console.log('1sync'); // 同步
// 输出: 1sync → 3promise → 2setTimeout
原因拆解:
console.log('1sync')是同步代码,当前宏任务里立刻 执行 → 先出1。- 当前宏任务结束 → 进入"清空微任务队列 "阶段 → 执行
Promise.then→ 出3。 - 下一轮 才从宏任务队列取
setTimeout→ 出2。
核心:微任务在当前宏任务结束后的"清空阶段"执行,永远早于下一个宏任务。 这就是为什么"微任务比宏任务先"------不是顺序问题,而是执行阶段不同。
3. 一段代码彻底看透执行顺序
js
console.log('1');
setTimeout(() => console.log('2'), 0); // 宏任务 A
new Promise((resolve) => {
console.log('3'); // Promise 执行器是同步的!
resolve();
}).then(() => console.log('4')) // 微任务 B1
.then(() => console.log('5')); // 微任务 B2
queueMicrotask(() => console.log('6')); // 微任务 B3
requestAnimationFrame(() => console.log('7')); // rAF(渲染前)
console.log('8');
// 输出: 1 3 8 4 5 6 2 7
逐步拆解:
| 阶段 | 执行 | 说明 |
|---|---|---|
| 同步 | 1 |
第一行 |
| 同步 | 3 |
Promise 执行器同步调用,resolve 只注册后续 then |
| 同步 | 8 |
最后一行 |
| 清空微任务 | 4, 5, 6 |
当前宏任务结束,按入队顺序清空 |
| 下一轮宏任务 | 2 |
取 setTimeout |
| 渲染前 | 7 |
rAF 在渲染帧开头 |
踩坑点: 很多人以为 new Promise(resolve => { console.log('3'); resolve() }) 里是异步的------错!Promise 执行器是同步执行 的,只有 .then 的回调才是微任务。
4. 宏任务队列不止一个:任务分类与优先级
浏览器把宏任务分成几种"源(source)",同源任务有先后,不同源也有大致顺序(虽无标准保证,但现代浏览器大体如下):
- 渲染/绘制(每帧)
- 用户交互(click、keydown、scroll → 高优先)
- 定时器(setTimeout/setInterval → 到期才入队)
- 网络/IO(fetp 完成、XHR)
- 微任务(不在此,单独清空)
所以即使都是宏任务,用户点击事件 往往比 setTimeout(0) 优先响应------这也是为什么"用户输入应该优先于非关键任务"。
5. 与渲染的关系:为什么长任务卡顿
浏览器不是执行完一段 JS 就立刻渲染 ,而是按帧(通常 60fps,每帧约 16.7ms)批量渲染。事件循环的关键影响:
- 如果当前宏任务是长任务(Long Task,>50ms),它会把这一整帧的渲染时间吃掉 → 用户看到的画面"卡住"。
- 微任务、rAF 都在这帧内完成,若它们太耗时,同样拖慢渲染。
requestAnimationFrame让你在渲染前 拿到最新状态,是动画和视觉更新的首选 ------比setTimeout更精准地贴合渲染帧,省电且流畅。
js
// 想让动画平滑,绝不用 setTimeout 模拟
function animate() {
box.style.transform = `translateX(${x}px)`;
requestAnimationFrame(animate); // 每帧渲染前执行,帧率同步
}
6. 微任务"饿死"渲染的极端坑
微任务每轮都清空,如果你在微任务里无限递归 ,主线程就被微任务占满,永远进不到渲染阶段 → 页面白屏/卡死:
js
function badLoop() {
Promise.resolve().then(badLoop); // 无限微任务 → 渲染被饿死
}
badLoop(); // 页面直接卡死
真实场景: 有人用微任务做递归遍历大对象,结果页面假死。递归/长循环别用微任务 ,改用 rAF 分帧或 setTimeout 分片。
实战代码:事件循环在性能优化里的三个实际应用
应用 1:用 rAF 合并高频 DOM 写入(渲染批处理)
js
// 多次修改 style,如果不合并,浏览器会多次触发样式计算
const taskQueue = [];
let scheduled = false;
function scheduleStyle(fn) {
taskQueue.push(fn);
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
// 在渲染前一次性执行所有更新 → 只触发一次样式计算/布局
const tasks = taskQueue.splice(0);
tasks.forEach((fn) => fn());
scheduled = false;
});
}
// 滚动时疯狂触发,也只每帧渲染一次
window.addEventListener('scroll', () => {
scheduleStyle(() => updatePosition());
});
应用 2:用 setTimeout 分片,避免长任务阻塞渲染
js
// 一次性处理 10 万条数据,会卡死
// ✅ 分片:每片让出主线程给渲染
const items = new Array(100000).fill(0);
let i = 0;
function processChunk() {
const CHUNK = 5000; // 每片 5000 条,约几毫秒
const end = Math.min(i + CHUNK, items.length);
for (; i < end; i++) { /* 处理 items[i] */ }
if (i < items.length) {
setTimeout(processChunk, 0); // 让出主线程,允许渲染插入
} else {
console.log('完成');
}
}
processChunk();
应用 3:用宏任务/微任务控制"等待渲染时机"
js
// 需要等一帧渲染完成后再读取测量值(如真实 offsetHeight)
async function measureAfterPaint() {
await requestAnimationFrame(() => {}); // 等渲染前
await new Promise((r) => setTimeout(r, 0)); // 等渲染后(宏任务)
const h = el.getBoundingClientRect().height; // 拿到正确布局值
}
要点总结
- 事件循环模型:取宏任务 → 执行 → 清空微任务 →(rAF + 渲染)→ 回到取宏任务。
- 宏任务一帧一个、微任务每帧清空、rAF 卡在渲染前,三句话记牢。
- Promise 执行器同步、then 是微任务------这是最容易踩的认知坑。
- 微任务无限递归会饿死渲染 ;长任务(>50ms)会卡顿掉帧。
- 性能实操:高频 DOM 更新用 rAF 合并;大任务用 setTimeout 分片;等渲染完成用 rAF+宏任务。
- 排查卡顿:打开 DevTools Performance,看有没有长任务(红块)、主线程是否被微任务/同步大循环独占。
- 面试常考:
1 3 8 4 5 6 2 7这个排序,能默写出原理才算真懂。
一句话:主线程就一条道,事件循环是它的调度规则------宏任务一帧一个、微任务每帧清空、rAF 卡在渲染前。吃透它,异步和性能问题就解决了一大半。