JavaScript 事件循环:微任务、宏任务与渲染时机

FreeGuideOnline 最新 2026-06-15

JavaScript 事件循环:微任务、宏任务与渲染时机

JavaScript 是一门单线程语言,它同一时间只能做一件事。但借助事件循环(Event Loop),它能高效处理网络请求、用户交互和定时器等异步操作,同时保持界面的流畅响应。理解事件循环、微任务与宏任务的分工,是写出高性能、可预测代码的关键。

为什么需要事件循环

在浏览器中,渲染引擎和 JavaScript 引擎共享主线程。如果 JS 长时间占用线程,页面就会“卡死”。事件循环通过任务队列优先级调度,在“执行代码”与“更新界面”之间找到平衡,让异步结果在合适的时机被处理。它的核心思想是:

  • 同步代码直接进调用栈立即执行。
  • 异步任务先挂起,结果就绪后,对应的回调被放入任务队列排队。
  • 调用栈清空后,事件循环从任务队列中取出回调执行。

调用栈与任务队列

JS 引擎维护一个调用栈(Call Stack),记录函数的执行上下文。当调用栈为空时,事件循环就会检查任务队列,将等待中的任务推入栈中执行。这些任务分为两类:宏任务(MacroTask)微任务(MicroTask)

宏任务(MacroTask / Task)

宏任务代表一个独立的、完整的执行单元。常见的宏任务来源包括:

  • <script> 整体代码(第一个宏任务)
  • setTimeoutsetInterval 的回调
  • I/O 操作(如 fetch 完成后的回调)
  • UI 交互事件(clickscroll 等)
  • requestAnimationFrame(在渲染前执行,某些角度也可归为宏任务)
  • setImmediate(Node.js 环境)

浏览器在一个宏任务执行完成后,会检查是否需要渲染,然后再从宏任务队列中取出下一个宏任务。

微任务(MicroTask)

微任务是一些需要尽快执行、但又不能打断当前宏任务的异步任务。它们总是在当前宏任务执行完、下一个宏任务开始前被全部清空。常见的微任务来源:

  • Promise.then()Promise.catch()Promise.finally()
  • async/await(本质是 Promise 的语法糖)
  • MutationObserver
  • queueMicrotask()

事件循环的完整执行流程

浏览器的每一次事件循环迭代可以简化为以下步骤:

  1. 执行一个宏任务(从宏任务队列中取出最老的一个)
  2. 清空微任务队列:依次执行所有微任务,直到队列为空。这期间新产生的微任务也会在当前循环中被执行。
  3. 判断是否需要渲染:浏览器会根据刷新率(通常 60fps,即每 16.6ms 一次)和当前环境决定是否进行页面渲染。
  4. 若需要渲染,则执行与渲染相关的操作(如执行 requestAnimationFrame 回调、重排重绘等)。
  5. 进入下一轮事件循环,取出下一个宏任务。

全局 <script> 代码本身就是一个宏任务,所以所有写在脚本中的同步代码属于“第一个宏任务”。执行完同步代码后,若存在微任务,会立即清空微任务队列,之后才可能渲染,接着再执行 setTimeout 等下一个宏任务。

微任务与宏任务的执行顺序演示

来看一段经典混合代码,彻底理解执行顺序:

console.log('1'); // 同步

setTimeout(() => {
  console.log('2'); // 宏任务回调
}, 0);

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

console.log('4'); // 同步

输出顺序为:1432

解释:

  • 整体代码作为一个宏任务开始,顺序输出 1
  • setTimeout 将其回调放入宏任务队列,等待一次循环。
  • Promise.then 将回调放入微任务队列。
  • 输出 4,同步代码执行完毕。
  • 当前宏任务结束,开始清空微任务队列,输出 3
  • 微任务清空后,浏览器可能判断渲染(此处代码不会触发渲染),然后取出下一个宏任务,输出 2

再看一个包含多次微任务的例子:

setTimeout(() => console.log('A'), 0);

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

console.log('D');

输出顺序:DBCA。注意 B 中的微任务 C 会在同一轮微任务清空中立即执行,不会留到下一轮。

为什么微任务一定要在渲染之前全部执行

浏览器的渲染运行在宏任务之间(偶尔在同一个事件循环中),而微任务队列必须在渲染前被清空。原因之一是避免微任务导致的界面更新延迟:如果你在一次宏任务中多次变更 DOM 并产生 Promise 回调,它们将在渲染前一次性处理完,保证渲染的结果是最新的。如果微任务在渲染后才执行,用户可能会先看到旧的界面,再跳变到新界面,严重影响体验。

但这也带来一个隐患:如果微任务中又不断产生新的微任务(无限递归),事件循环将被卡在清空微任务队列的步骤,导致页面无法渲染和响应。因此,不建议在微任务中无限添加微任务。

渲染时机的细节

当浏览器认为需要更新页面时(例如 DOM 变化、CSS 动画、滚动等),会在合适的时机执行渲染步骤。典型的渲染相关钩子:

  • requestAnimationFrame(rAF):回调在渲染前执行,适合更新动画或处理样式,以保证在下一帧绘制前完成计算。
  • 渲染(包括样式计算、布局、绘制):在这之后页面才会显示新的内容。

具体顺序可以这样理解:

宏任务 → 微任务队列清空 → requestAnimationFrame → 渲染 → 下一宏任务(如果时间允许)。

因此,若希望操作与下一帧渲染同步,可以使用 rAF,而不是 setTimeout

在 Node.js 环境中的差异

Node.js 也有事件循环,但它是基于 libuv 的多阶段循环,包括 timers、pending callbacks、idle、poll、check、close callbacks 等阶段。重点区别:

  • process.nextTick() 是 Node 独有的微任务,优先级高于 Promise 微任务。在每一个 Node 事件循环阶段结束前,会先清空 nextTick 队列,再清空 Promise 微任务队列。
  • setImmediate 在 check 阶段执行,setTimeout(fn,0) 在 timers 阶段执行,执行顺序取决于事件循环启动的时间和环境,并不总是固定。
  • 浏览器的“一个宏任务 → 全部微任务 → 渲染”模型本质相似,但 Node 没有渲染步骤。

本文重点聚焦浏览器环境,Node 开发者可参考专门的事件循环剖析。

最佳实践与常见陷阱

  1. 避免长时间运行的宏任务
    将大任务拆分成多个小宏任务(例如用 setTimeout 分段),让出线程给用户交互和渲染。

  2. 谨慎使用无限微任务
    Promise 循环不断追加微任务会导致渲染阻塞和内存泄漏。务必提供退出条件。

  3. 利用 queueMicrotask 控制执行时机
    当需要保证某些代码在当前宏任务完成后、任何异步回调或渲染前执行时,可使用 queueMicrotask,但不要滥用。

  4. 区分 setTimeout(fn, 0) 与 Promise
    setTimeout 延迟最小约 4ms(嵌套时),且一定会等到下一轮事件循环;Promise 微任务在同轮末立即执行。根据优先级需求选择。

  5. 使用 requestAnimationFrame 进行视觉更新
    需要与浏览器刷新同步的动画、布局修改,优先使用 rAF,避免用 setTimeout 或微任务造成布局抖动。

总结

  • 事件循环是 JavaScript 异步调度的核心,单线程通过任务队列实现并发。
  • 宏任务(如 setTimeout、事件)每次只执行一个,然后清空所有微任务(如 Promise.then)。
  • 渲染通常发生在两次宏任务之间,并且在微任务队列清空之后。
  • 掌握执行顺序能帮你写出更可靠、高效的前端代码,避免界面卡顿和难以排查的时序问题。

只要理清“同步代码 → 微任务 → 可能渲染 → 下一个宏任务”这一主线,绝大多数异步谜题都能迎刃而解。