欢迎光临

深入理解浏览器事件循环:宏任务、微任务与渲染时机实战解析

在前端面试和日常开发中,”事件循环(Event Loop)”是被问到频率最高的概念之一。然而,很多开发者只是背诵了”宏任务先执行,微任务后执行”这样的口诀,却并不真正理解它的工作原理。当你遇到动画卡顿、定时器不准时、Promise 链路嵌套地狱、或者

1
requestAnimationFrame

1
setTimeout

行为不一致的问题时,真正的事件循环知识才是解决问题的关键。本文将带你从浏览器多进程架构出发,深入剖析事件循环的每一个阶段,配合大量可运行代码示例,让你彻底掌握这一核心概念。

一、浏览器进程架构与渲染进程

在理解事件循环之前,必须先明白浏览器是如何组织各个模块的。现代浏览器(以 Chrome 为代表)采用多进程架构,主要包括 Browser 进程、GPU 进程、网络进程和多个渲染进程。每个标签页通常对应一个独立的渲染进程,而事件循环就运行在渲染进程的主线程上。

1.1 渲染进程包含哪些线程

渲染进程内部并不是单一线程,而是包含多个协作线程:

  • 主线程(Main Thread):执行 JavaScript、解析 HTML/CSS、计算样式、布局(Layout)、绘制(Paint)。事件循环就跑在这里。
  • 合成线程(Compositor Thread):接收绘制指令,将图层合成为最终图像,可以独立处理滚动、变换等不触发布局的操作。
  • Worker 线程:包括 Web Worker、Service Worker,它们有自己独立的事件循环。
  • 网络线程与定时器线程
    1
    setTimeout

    的计时由独立线程管理,时间到了才把回调推入主线程的任务队列。

这种架构决定了 JavaScript 是单线程的(主线程),但浏览器本身是多线程的。理解这一点能解释很多”反直觉”的现象,比如为什么

1
setTimeout(fn, 0)

不会立即执行。

二、事件循环的核心模型

HTML 规范定义了标准的事件循环处理模型。每个事件循环都有一个或多个任务队列(Task Queue),一个微任务队列(Microtask Queue),以及一个执行栈(Call Stack)。事件循环的每一轮(称为一个 tick)大致遵循如下流程:

  1. 从任务队列中取出一个最旧的可执行任务(宏任务),推入执行栈执行。
  2. 执行完毕后,清空整个微任务队列(执行所有微任务,如果在执行过程中又产生了新的微任务,也会一并执行)。
  3. 检查是否需要渲染(Render Steps):如果浏览器认为需要更新视图,则执行
    1
    requestAnimationFrame

    回调、Resize、Scroll、样式计算、布局、绘制。

  4. 回到步骤 1,重复上述循环。

这个流程可以用伪代码表示:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 事件循环简化模型
while (true) {
  // 1. 取出一个宏任务执行
  const task = taskQueue.shift();
  if (task) task();

  // 2. 清空所有微任务
  while (microtaskQueue.length) {
    const microtask = microtaskQueue.shift();
    microtask();
  }

  // 3. 渲染阶段(约 16.67ms 一次,对应 60fps)
  if (needsRender) {
    runAnimationFrameCallbacks();
    layout();
    paint();
  }
}

注意第三步:渲染不是每个 tick 都会发生。浏览器会根据屏幕刷新率(通常 60Hz)和页面是否有更新来决定是否渲染。这意味着微任务总是在下一次渲染之前执行完毕,而宏任务可能在两次渲染之间执行多次。

三、宏任务与微任务的来源

3.1 常见的宏任务

宏任务(Macro Task,也叫 Task)由宿主环境(浏览器)调度,常见的来源包括:

  • 1
    setTimeout

    /

    1
    setInterval

    的回调

  • I/O 操作完成后的回调(如文件读取)
  • UI 事件(点击、输入、滚动等)的回调
  • 1
    postMessage

    接收到的消息

  • 网络请求完成的回调(如
    1
    XMLHttpRequest

    1
    onload

  • 1
    requestIdleCallback

    (这是一个特殊的低优先级任务)

3.2 常见的微任务

微任务(Microtask)由 JavaScript 引擎自身调度,一旦当前宏任务执行完毕,所有排队的微任务都会在渲染前被清空。常见的微任务来源:

  • 1
    Promise.then / catch / finally

    的回调

  • 1
    queueMicrotask(fn)

    显式注册的微任务

  • 1
    MutationObserver

    的回调

  • 1
    IntersectionObserver

    的回调(注意:部分实现将其归为宏任务)

  • 1
    async/await

    1
    await

    后的代码(本质是 Promise 的 then)

3.3 对比一览

维度 宏任务 微任务
调度者 宿主环境(浏览器) JavaScript 引擎
执行时机 每轮 tick 执行一个 每轮 tick 清空全部
优先级 较低 较高(在渲染前执行)
典型代表 setTimeout Promise.then
能否阻塞渲染 单个任务可阻塞,但下一次渲染前会切换 会阻塞,微任务不清空不渲染

四、经典执行顺序实战

光看理论不够,我们用一段代码来验证执行顺序。建议你在浏览器控制台运行这段代码,观察输出结果:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log('1. 同步代码开始');

setTimeout(() => {
  console.log('4. 宏任务 setTimeout');
}, 0);

Promise.resolve().then(() => {
  console.log('2. 微任务 Promise.then');
});

Promise.resolve().then(() => {
  console.log('3. 微任务 Promise.then 第二个');
});

console.log('1.1 同步代码结束');

// 输出顺序:
// 1. 同步代码开始
// 1.1 同步代码结束
// 2. 微任务 Promise.then
// 3. 微任务 Promise.then 第二个
// 4. 宏任务 setTimeout

解释:同步代码先执行完毕,然后清空微任务队列(两个 Promise.then 按注册顺序执行),最后才执行宏任务 setTimeout。这就是”微任务优先于宏任务”的真正含义——它指的是在同一轮 tick 中,微任务在下一个宏任务之前执行。

4.1 async/await 的本质

1
async/await

是 Promise 的语法糖,

1
await

之后的代码等价于

1
.then()

的回调,因此也是微任务。看下面这个例子:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end'); // 微任务
}

async function async2() {
  console.log('async2');
}

console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise');
  resolve();
}).then(() => {
  console.log('promise.then'); // 微任务
});
console.log('script end');

// 输出顺序:
// script start
// async1 start
// async2
// promise
// script end
// async1 end
// promise.then
// setTimeout

关键点:执行到

1
await async2()

时,

1
async2()

本身是同步调用的(打印 “async2″),但

1
await

会让出主线程,

1
async1 end

被推入微任务队列。因此同步代码继续执行,最后才依次清空微任务(async1 end、promise.then),再执行宏任务 setTimeout。

五、requestAnimationFrame:渲染前的钩子

1
requestAnimationFrame

(简称 rAF)是一个特殊的 API,它的回调在浏览器准备渲染下一帧之前执行,紧跟在微任务清空之后、布局和绘制之前。这使得 rAF 成为执行动画的最佳选择——它能保证回调与屏幕刷新同步,避免丢帧。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 错误示范:用 setTimeout 做动画
function animateBad() {
  const el = document.getElementById('box');
  let left = 0;
  function step() {
    left += 2;
    el.style.transform = `translateX(${left}px)`;
    if (left < 300) setTimeout(step, 0);
  }
  step();
}

// 正确示范:用 requestAnimationFrame 做动画
function animateGood() {
  const el = document.getElementById('box');
  let left = 0;
  function step() {
    left += 2;
    el.style.transform = `translateX(${left}px)`;
    if (left < 300) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

1
setTimeout

做动画的问题在于:它无法与屏幕刷新对齐,可能在同一帧内多次触发样式变更(浪费计算),也可能跳过某些帧(卡顿)。rAF 则保证一帧只回调一次,且在浏览器即将绘制时执行,性能最优。

5.1 rAF 与微任务的顺序

一个容易混淆的点:rAF 回调既不是宏任务也不是微任务,它属于渲染阶段的独立步骤,位于微任务清空之后。验证:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
requestAnimationFrame(() => {
  console.log('rAF callback');
});

Promise.resolve().then(() => {
  console.log('microtask');
});

setTimeout(() => {
  console.log('macrotask');
}, 0);

// 输出顺序:
// microtask
// rAF callback
// macrotask

微任务最先执行,然后是 rAF(如果这一 tick 触发了渲染),最后是下一个宏任务。但要注意:如果当前 tick 没有渲染需求(比如标签页在后台),rAF 回调会被延迟到下一次实际渲染时才执行。

六、实战:为什么 setTimeout 不准

很多开发者发现

1
setTimeout(fn, 1000)

实际上经常超过 1 秒才执行。原因在于:setTimeout 的第二个参数只是”最小延迟”,而非”精确延迟”。事件循环必须等当前宏任务及所有微任务执行完毕,才会去检查任务队列。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 模拟一个耗时同步任务
function heavyWork() {
  const start = Date.now();
  while (Date.now() - start < 500) {
    // 阻塞主线程 500ms
  }
}

console.time('timer');
setTimeout(() => {
  console.timeEnd('timer'); // 实际耗时远超 100ms
}, 100);

heavyWork(); // 阻塞了主线程

// 输出示例:timer: 500.123ms

这个例子清楚地展示了:只要主线程被占用,定时器就无法及时触发。这也是为什么长时间同步计算会”冻结”页面——所有宏任务、微任务、渲染都被阻塞。

6.1 解决方案:任务分片

对于耗时计算,应该把任务切分成小块,用

1
setTimeout(fn, 0)

或更好的

1
requestIdleCallback

/

1
scheduler.postTask()

让出主线程,给浏览器喘息的机会:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 将大数据处理分片执行
function processChunked(data, chunkSize, processFn) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);
    while (index < end) {
      processFn(data[index]);
      index++;
    }

    if (index < data.length) {
      // 让出主线程,给浏览器处理交互和渲染的机会
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

// 使用示例:处理 10 万条数据不卡顿
const bigArray = Array.from({ length: 100000 }, (_, i) => i);
processChunked(bigArray, 1000, (item) => {
  // 对每条数据执行操作
});

七、Promise 与微任务的陷阱

7.1 微任务递归会饿死宏任务

由于微任务队列是在每轮 tick 中”全部清空”的,如果你在微任务里不断创建新的微任务,会导致宏任务永远得不到执行,页面会卡死:


1
2
3
4
5
6
7
8
// 危险!这段代码会让页面冻结
function infiniteMicrotask() {
  Promise.resolve().then(() => {
    console.log('微任务不断生成...');
    infiniteMicrotask(); // 递归创建微任务
  });
}
// infiniteMicrotask(); // 不要在生产环境运行!

这个例子说明:微任务优先级高并不意味着它总是好的。无限递归的微任务会饿死所有宏任务(包括 UI 事件、渲染),页面完全无响应。

7.2 Promise 链的错误处理顺序

多个 Promise 链的错误处理也依赖微任务顺序。看下面的对比:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 场景 A:catch 在 then 之后
Promise.resolve()
  .then(() => { throw new Error('出错了'); })
  .then(() => console.log('我不会执行'))
  .catch((err) => console.log('捕获:', err.message));

// 场景 B:先 catch 再 then
Promise.resolve()
  .then(() => { throw new Error('出错了'); })
  .catch((err) => console.log('捕获:', err.message))
  .then(() => console.log('我会执行,因为 catch 已经处理了错误'));

// 场景 A 输出:捕获:出错了
// 场景 B 输出:捕获:出错了  然后输出:我会执行...

理解这一点对于编写健壮的异步错误处理逻辑至关重要。

1
catch

本质上是

1
then(undefined, onRejected)

的语法糖,它返回一个新的已 fulfilled 的 Promise。

八、Web Worker:真正的多线程

当主线程确实需要执行密集计算时,Web Worker 是正解。Worker 运行在独立线程,有自己独立的事件循环,不会阻塞主线程。主线程与 Worker 通过

1
postMessage

通信,消息传递是异步的(走宏任务队列)。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.js(主线程)
const worker = new Worker('worker.js');

worker.postMessage({ command: 'calculate', data: largeArray });

worker.onmessage = function (e) {
  console.log('Worker 返回结果:', e.data.result);
};

// worker.js(Worker 线程)
self.onmessage = function (e) {
  const { command, data } = e.data;
  if (command === 'calculate') {
    // 执行耗时计算,不阻塞主线程
    const result = data.reduce((sum, n) => sum + n * n, 0);
    self.postMessage({ result });
  }
};

需要注意的是:Worker 不能直接操作 DOM(没有

1
document

对象),只能通过消息传递数据。对于 UI 密集型和计算密集型分离的场景,Worker 是最佳实践。SharedArrayBuffer + Atomics 还能实现真正的共享内存并发,但需要考虑线程安全和 COOP/COEP 安全头。

九、调度新 API:scheduler.postTask

Chrome 94+ 引入了

1
scheduler.postTask()

API,提供了比 setTimeout 更精细的优先级控制。它支持

1
user-blocking

(最高)、

1
user-visible

(默认)、

1
background

(最低)三个优先级:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 高优先级任务(如用户输入响应)
scheduler.postTask(() => {
  updateUIBasedOnUserInput();
}, { priority: 'user-blocking' });

// 后台任务(如数据预取、日志上报)
scheduler.postTask(() => {
  prefetchNextPageData();
  reportAnalytics();
}, { priority: 'background' });

// 配合 AbortController 取消任务
const controller = new AbortController();
scheduler.postTask(
  () => console.log('可能被取消的任务'),
  { signal: controller.signal }
);
// 某些条件下取消
controller.abort();

这个 API 让浏览器能更智能地调度任务,特别适合需要精确控制优先级的复杂应用。目前兼容性还在完善中,建议结合

1
requestIdleCallback

作为兜底方案。

十、调试技巧与常见问题排查

10.1 用 Performance 面板分析

Chrome DevTools 的 Performance 面板可以可视化每个任务的执行时机。录制一段操作后,你会看到:

  • 黄色块:脚本执行(Scripting),对应宏任务和微任务
  • 紫色块:渲染(Rendering),包括样式计算、布局
  • 绿色块:绘制(Painting),将像素绘制到屏幕

如果黄色块过长(超过 50ms),说明有长任务(Long Task),需要优化。可以利用

1
PerformanceObserver

监控长任务:


1
2
3
4
5
6
7
8
9
10
11
12
// 监控长任务并上报
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(`长任务耗时:${entry.duration.toFixed(2)}ms`);
    // 可以上报到监控系统
    navigator.sendBeacon('/api/long-task', JSON.stringify({
      duration: entry.duration,
      startTime: entry.startTime,
    }));
  }
});
observer.observe({ entryTypes: ['longtask'] });

10.2 常见误区清单

误区 真相
setTimeout(fn, 0) 立即执行 至少延迟 4ms(嵌套调用时),且要等当前任务和微任务完成
微任务一定比宏任务快 在同一 tick 内是这样,但跨 tick 不一定
await 会阻塞后续代码 不会,它只是把后续代码包装成微任务
rAF 是微任务 不是,它是渲染阶段的独立步骤
Promise 是异步的 构造函数内的代码是同步的,只有 then 回调才是异步微任务

总结

事件循环是前端开发者的”内功心法”,理解它能让你写出更流畅的动画、更高效的异步代码、更少卡顿的页面。回顾本文的几个核心要点:

  • 每个 tick:执行一个宏任务 → 清空所有微任务 → 渲染(如果需要)。
  • 微任务优先级高于宏任务,但无限递归的微任务会饿死页面。
  • rAF 是做动画的最佳选择,与屏幕刷新对齐。
  • 长任务(超过 50ms)会阻塞渲染和交互,必须分片或迁移到 Web Worker。
  • 新 API
    1
    scheduler.postTask

    提供了更精细的调度控制。

掌握这些原理后,建议你在实际项目中多用 Performance 面板观察任务执行情况,把理论知识转化为工程能力。当你能准确预测一段异步代码的执行顺序、能解释动画卡顿的根本原因、能用分片优化长任务时,说明你已经真正理解了事件循环。

浏览器事件循环与JavaScript异步编程

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » 深入理解浏览器事件循环:宏任务、微任务与渲染时机实战解析
分享到: 更多 (0)