事件循环与渲染时机:一文吃透宏任务、微任务和 rAF

问题场景

前端性能优化里,最常被问的一句话是:"为什么我的页面滚动会卡?为什么这段代码跑完界面才更新?"

举几个每天都在发生的"玄学":

  1. setTimeout(() => xxx, 0) 想"立即"执行,结果却排在后面,甚至没生效。
  2. Promise.then 明明写在 setTimeout 之前,却总是先执行。
  3. 一个 while 死循环,把整个页面锁死,连动画都停了。
  4. 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

原因拆解:

  1. console.log('1sync') 是同步代码,当前宏任务里立刻 执行 → 先出 1
  2. 当前宏任务结束 → 进入"清空微任务队列 "阶段 → 执行 Promise.then → 出 3
  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 卡在渲染前。吃透它,异步和性能问题就解决了一大半。

相关推荐
章鱼小丸子逃跑中1 小时前
【2025最新版】如何将fnm与node.js安装在D盘?【保姆级安装及人性话理解教程】
前端·javascript·npm·node.js
Larcher1 小时前
从 SDD 到可交付:我如何用规范驱动 AI 做出一个 Chrome 翻译插件
前端·后端·架构
suaizai_2 小时前
AI Agent如何懂你:四层能力拆解
java·前端·人工智能
invicinble2 小时前
把握前端项目的核心(vibecoding)
前端
0end13 小时前
AI Agent 学习笔记(三):上下文工程(下)—— KV Cache、提示词设计与 Agent Skills
前端·aigc·ai编程
CAD老兵3 小时前
在浏览器里对比 DWG/DXF 图纸 —— @mlightcad/cad-diff-viewer
前端·javascript·github
JasonYin3 小时前
AI 定义的 H5移动端 开发规范,直接抄作业!
前端
诗章与猫3 小时前
Leaflet 渲染 100 万 marker 卡成 PPT?我用 WebGL 重写了渲染器
前端
用户921080262863 小时前
在 AI 代码生成项目里接入 Thought:别展示“玄学思维链”,只展示用户真正关心的工具调用
前端