Event Loop 这篇你看完就不用再看别的了!

关于 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 编程 · 职场认知 · 开发者成长

微信扫码关注 👆

不定期更新,不刷屏,聊点真正有用的干货。

相关推荐
codigger33 分钟前
线上数据库连接池打满:从应急止损到架构根治的完整方案
数据库·sql·程序员
狂师36 分钟前
把别人的软件拆开看,推荐5个主流逆向工具
安全·程序员·开源
挖掘狂人41 分钟前
线上数据库连接池打满:从应急止损到架构根治的完整方案
数据库·sql·程序员
恋猫de小郭42 分钟前
Flutter 下 AOT 一个你怎么都想不出来的 Bug ,JIT 正常的计算,在 AOT 居然算错了
android·前端·flutter
晓得迷路了1 小时前
栗子前端技术周刊第 153 期 - Vitest 5.1、Remix v3.2、Node.js 27 Alpha...
前端·javascript·vite
系统集成-鹰翔1 小时前
ERP单据已作废为什么商品仍不能删除,引用检查与软删除怎么查
javascript·经验分享·后端
Moment2 小时前
Harness Engineering 为什么是 AI Coding 的工程底座 ❓❓❓
前端·后端·面试
深蓝AI2 小时前
Vercel 把浏览器做成命令行:agent-browser 十个月 4.6 万星,给每个元素发一个编号
人工智能·程序员