
关于 Event Loop,你大概已经看过很多文章了。
大部分文章讲到最后,就是让你做这道题👇:
javascript
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1 4 3 2
你背下来了,面试过了,然后呢?🤷♂️
然后你遇到了一个真实的性能问题:一段 JavaScript 逻辑执行完之后,页面上的 DOM 变化明明已经生效了,但屏幕上看不到更新。你百思不得其解------代码执行完了,DOM 也改了,为什么页面不动?
因为代码执行完和浏览器把变化画到屏幕上是两件完全不同的事情,中间隔着一整套你从来没搞清楚过的流程。
今天这篇文章不讲 setTimeout vs Promise 的面试八股。我要把浏览器一帧(16.6ms)里发生的所有事情,按精确的时间顺序拆给你看。看完之后,你会知道:
- 为什么
requestAnimationFrame比setTimeout更适合做动画? - 为什么微任务能阻塞渲染?
- 为什么连续修改
DOM不会触发多次重绘? requestIdleCallback到底在什么时候执行?
先纠正一个认知错误!
大多数人脑子里的 Event Loop 长这样:

这个模型不是错的,但严重不完整。 它只描述了 JavaScript 引擎的调度逻辑,完全没有涉及浏览器的渲染流水线。
而你遇到的绝大多数性能问题------掉帧、卡顿、视觉延迟------都和渲染流水线有关,和 setTimeout vs Promise 的执行顺序一点关系都没有。
浏览器一帧里到底发生了什么?
浏览器的目标是每秒渲染 60 帧,每帧大约 16.6ms 。在这 16.6ms 里,浏览器要完成以下步骤,严格按顺序执行:

这才是完整的模型。下面逐步拆解👇。
第一步:取一个宏任务执行
宏任务队列(Task Queue)里存放的是:setTimeout 回调、setInterval 回调、用户交互事件(click、input)、网络请求回调、MessageChannel 等。
关键点:每一轮 Event Loop 只取一个宏任务执行,不是清空整个队列。
javascript
// 这两个 setTimeout 不会在同一帧里执行
// 它们分别在两轮 Event Loop 里各执行一次
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
这就是为什么 setTimeout(fn, 0) 并不是立即执------它是 在下一轮 Event Loop 的宏任务阶段执行 ,而下一轮 Event Loop 可能要等当前帧的渲染流水线全部走完才开始。
第二步:清空所有微任务
宏任务执行完之后,浏览器会一次性清空所有微任务,包括在清空过程中新产生的微任务。
javascript
Promise.resolve().then(() => {
console.log('微任务 1');
// 在微任务里又产生了一个微任务
Promise.resolve().then(() => {
console.log('微任务 2');
});
});
// 输出顺序:微任务 1 → 微任务 2
// 两个都在同一轮微任务清空阶段完成
这里藏着一个很多人不知道的性能陷阱:如果你的微任务不断地产生新的微任务,浏览器会一直在这一步循环下去,永远走不到渲染阶段。
javascript
// 这段代码会冻结页面
function loop() {
Promise.resolve().then(loop);
}
loop();
// 微任务永远清不完 → 浏览器永远不会渲染 → 页面卡死
而同样的逻辑用 setTimeout 就不会卡死:
javascript
// 这段代码不会冻结页面
function loop() {
setTimeout(loop, 0);
}
loop();
// 每次 setTimeout 都进入宏任务队列
// 每轮只执行一个宏任务,中间会穿插渲染
微任务能阻塞渲染,宏任务不会。 这就是两者在性能层面的根本区别,远比谁先执行谁后执行重要得多。
第三步:判断是否需要渲染
不是每一轮 Event Loop 都会触发渲染。 浏览器会根据多个因素判断这一帧需不需要画:

这就解释了一个常见的困惑:为什么连续修改 DOM 不会触发多次重绘?
javascript
const el = document.getElementById('box');
el.style.width = '100px'; // 修改 1
el.style.height = '200px'; // 修改 2
el.style.background = 'red'; // 修改 3
// 这三次修改只会触发一次渲染
// 因为它们都在同一个宏任务里执行
// 浏览器会等这个宏任务和后续微任务全部执行完
// 再统一计算样式、布局、绘制
浏览器的这个批量合并机制是天然的性能优化。你不需要手动做 DOM 操作的 batch,浏览器已经帮你做了。
第四步:requestAnimationFrame
如果浏览器判定这一帧需要渲染,它会在计算样式和布局之前,先执行所有排队中的 requestAnimationFrame 回调。
javascript
requestAnimationFrame(() => {
// 这段代码在渲染之前执行
// 你在这里修改 DOM 或样式,修改结果会在这一帧里被渲染出来
el.style.transform = `translateX(${position}px)`;
});
这就是为什么动画应该用 requestAnimationFrame 而不是 setTimeout:
requestAnimationFrame保证在渲染之前执行,修改的结果在这一帧就能画出来setTimeout在宏任务阶段执行,修改的结果可能要等到下一帧才能画出来------也可能恰好赶上这一帧,时机不可控
javascript
// 用 setTimeout 做动画:时机不可控,容易掉帧
function animate() {
el.style.transform = `translateX(${position++}px)`;
setTimeout(animate, 16);
}
// 用 requestAnimationFrame 做动画:和渲染节奏精确同步
function animate() {
el.style.transform = `translateX(${position++}px)`;
requestAnimationFrame(animate);
}
还有一个细节:requestAnimationFrame 回调里产生的微任务,会在这一步就被清空,不会推迟到下一轮。 但 requestAnimationFrame 回调里再调用 requestAnimationFrame,新的回调会推迟到下一帧。
第五到八步:渲染流水线
requestAnimationFrame 执行完之后,浏览器进入真正的渲染阶段:

Style:遍历DOM树,计算每个元素的最终样式(哪些CSS规则匹配了哪个元素)Layout:根据样式计算每个元素的位置和大小(宽、高、坐标)Paint:把元素画成像素(文字、颜色、边框、阴影)Composite:把多个图层合成到一起,交给GPU显示到屏幕上
这四步是浏览器的内部流程,JavaScript 代码不能直接介入。但你的代码可以间接影响这些步骤的耗时:
javascript
// 强制触发同步 Layout(又叫强制回流/ Forced Reflow)
el.style.width = '100px'; // 修改样式(此时只是标记需要重新 Layout)
const height = el.offsetHeight; // 读取布局信息 → 浏览器被迫立即执行 Layout
el.style.height = height + 'px'; // 又修改样式 → 又标记需要重新 Layout
// 这种写-读-写的模式会导致浏览器在一个宏任务里多次 Layout
// 这就是Layout Thrashing(布局抖动),性能杀手
正确的做法是把所有的读操作集中在一起,然后把所有的写操作集中在一起:
javascript
// 先读后写,避免 Layout Thrashing
const height = el.offsetHeight; // 读
const width = el.offsetWidth; // 读(不会触发额外 Layout,因为上次 Layout 的结果还有效)
el.style.height = height + 10 + 'px'; // 写
el.style.width = width + 10 + 'px'; // 写(浏览器只标记待处理,不会立即 Layout)
// 所有写操作完成后,浏览器在渲染阶段统一做一次 Layout,搞定
如果你在循环里对多个元素做读一个写一个的操作,Layout Thrashing 的性能损耗会被成倍放大:
javascript
// 循环里的 Layout Thrashing:每一轮循环都触发一次强制 Layout
const items = document.querySelectorAll('.item');
items.forEach(item => {
const h = item.offsetHeight; // 读 → 强制 Layout
item.style.height = h * 2 + 'px'; // 写 → 使 Layout 失效
});
// 100 个元素 = 100 次强制 Layout,页面直接卡死
javascript
// 先批量读,再批量写
const items = document.querySelectorAll('.item');
const heights = Array.from(items).map(item => item.offsetHeight); // 批量读(只触发 1 次 Layout)
items.forEach((item, i) => {
item.style.height = heights[i] * 2 + 'px'; // 批量写(只标记,不触发 Layout)
});
// 渲染阶段统一做 1 次 Layout,性能差了 100 倍
这就是为什么理解渲染流水线对前端性能优化至关重要------你不知道 Style → Layout → Paint → Composite 这条链路,就不知道你的代码在哪里制造了不必要的计算开销。
第九步:requestIdleCallback
如果上面所有步骤都完成了,而且这一帧的 16.6ms 预算还没花完,浏览器会把剩余时间交给 requestIdleCallback:
javascript
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 告诉你还剩多少毫秒
// 通常只有 1-5ms,用来做不紧急的工作
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
doSmallTask(tasks.pop());
}
});
requestIdleCallback 适合做这些事情:
- 数据预加载
- 日志上报
- 不影响用户体验的
DOM操作 - 大任务的分片执行
但绝对不要在 requestIdleCallback 里做 DOM 修改。 因为它在渲染之后执行,你在这里改了 DOM,修改要等到下一帧才能被画出来。而且如果你的修改触发了 Layout,这个 Layout 的计算成本会侵占下一帧的预算。
javascript
// 在 requestIdleCallback 里改 DOM
requestIdleCallback(() => {
el.style.display = 'block'; // 修改要等下一帧才能看到
el.textContent = 'loaded'; // 用户会感知到延迟
});
// 用 requestIdleCallback 做计算,用 requestAnimationFrame 做渲染
requestIdleCallback(() => {
const result = heavyCalculation(); // 在空闲时间做计算
requestAnimationFrame(() => {
el.textContent = result; // 在下一帧渲染前更新 DOM
});
});
一道真正有深度的面试题
来看一道面试里几乎没人能答全的题目:
javascript
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
requestAnimationFrame(() => {
console.log('4');
Promise.resolve().then(() => console.log('5'));
});
Promise.resolve().then(() => {
console.log('6');
requestAnimationFrame(() => console.log('7'));
});
console.log('8');
按照我们完整 Event Loop 模型来推演👇:

最终输出 :1 → 8 → 6 → 4 → 5 → 7 → 2 → 3
(注意两个点:console.log('7') 的 rAF 回调是在微任务阶段注册的,会推迟到下一帧 。2 和 4 的相对顺序取决于浏览器是否在这一轮触发渲染。在大多数情况下 rAF 会先于下一个 setTimeout 执行,但这不是规范保证的。)
这些知识在真实工作中怎么用?
聊了这么多执行顺序,落到工程里就是这些规则👇:

这三条规则的背后逻辑,全部来自今天讲的这个 Event Loop 时序模型的精粹。
好了,今天就分享到这里吧!
喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。
主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

微信扫码关注 👆
不定期更新,不刷屏,聊点真正有用的干货。