JavaScriptEvent LoopPromise浏览器

JavaScript Event Loop:任务、微任务与渲染顺序

通过调用栈、任务队列、微任务检查点和浏览器渲染流程,解释 Promise、queueMicrotask、setTimeout 与 async/await 的真实执行顺序和常见性能陷阱。

·更新于 ·阅读约 12 分钟·计算中...
JavaScript Event Loop:任务、微任务与渲染顺序

浏览器中的 JavaScript 事件循环可以先记成一句话:执行一个任务,清空本轮产生的微任务,浏览器获得渲染机会,然后进入下一轮。Promise.thenqueueMicrotask 属于微任务;定时器回调、用户事件等通常作为任务进入后续轮次。

本文合并本站原有的三篇 Event Loop 内容,并修正“微任务永远比所有宏任务优先”这类容易误导的简化说法。

目录

事件循环解决什么问题

JavaScript 代码在一个执行线程上按“运行到完成”处理:一个函数开始执行后,不会在任意一行突然被另一个 JavaScript 回调打断。但浏览器还要处理网络、定时器、用户输入和渲染,因此运行环境会把完成后的回调排入相应队列,由事件循环选择合适时机执行。

核心组成包括:

  • 调用栈:当前正在执行的函数。
  • 宿主环境 API:定时器、网络、DOM 事件等浏览器能力。
  • 任务队列:定时器回调、事件回调等等待执行的任务。
  • 微任务队列:Promise reaction、queueMicrotask、MutationObserver 等。
  • 渲染机会:样式计算、布局和绘制并不是每条 JavaScript 后都立即发生。

“JavaScript 单线程”不等于浏览器只能做一件事;网络和部分底层工作可以在宿主环境中进行,最终回调仍要等待 JavaScript 主线程。

一轮浏览器事件循环

可以用下面的简化流程理解:

  1. 从某个任务队列选择并执行一个任务。
  2. 当前调用栈清空后,执行微任务检查点。
  3. 持续执行微任务,直到微任务队列为空;新加入的微任务也会在本轮继续执行。
  4. 浏览器可能更新渲染。
  5. 进入下一轮并处理新的任务。

这里的“任务”在旧文章和日常讨论中常被叫作“宏任务”,但 Web 标准主要使用 task。不同任务来源还可能由浏览器分别管理,因此不要把运行环境想象成只有一个绝对先进先出的“宏任务数组”。

最常见的输出顺序

console.log("A")

setTimeout(() => console.log("B: timeout"), 0)

Promise.resolve().then(() => console.log("C: promise"))

queueMicrotask(() => console.log("D: microtask"))

console.log("E")

输出为:

A
E
C: promise
D: microtask
B: timeout

原因是同步代码先运行;Promise reaction 和 queueMicrotask 按入队顺序进入微任务队列;setTimeout 只能保证达到最短延迟后有资格排队,不能保证立即执行。

async/await 放在哪里

await 会暂停当前 async 函数,后续代码通过 Promise 机制继续,因此通常在微任务中恢复:

async function run() {
  console.log("2")
  await null
  console.log("4")
}

console.log("1")
run()
console.log("3")

输出:

1
2
3
4

await 之前仍是同步执行,只有后续部分被推迟。连续多个 await 会形成多个 Promise continuation,分析时应按照实际入队顺序判断,不要只背“await 后面最后执行”。

微任务会阻塞渲染吗

会。浏览器通常会在微任务队列清空后才获得渲染机会。如果微任务不断创建新微任务,定时器、输入响应和渲染都可能长期得不到执行机会:

function loop() {
  queueMicrotask(loop)
}

loop()

不要运行这段代码。它展示了“微任务优先”并不等于“微任务适合做大量工作”。MDN 也特别提醒,递归加入微任务可能造成微任务队列饥饿。

长任务为什么让页面卡顿

事件循环具有运行到完成的特性。下面的同步计算没有结束前,浏览器无法处理点击和渲染:

for (let index = 0; index < 1_000_000_000; index += 1) {
  // 大量同步计算
}

优化方式取决于任务类型:

  • 把可拆分工作分批调度,让出主线程。
  • 视觉更新使用 requestAnimationFrame 与浏览器绘制节奏协调。
  • 非紧急后台工作可评估 requestIdleCallback,但要检查兼容性并提供回退。
  • 真正密集的 CPU 计算考虑 Web Worker。
  • 减少一次渲染中不必要的 JavaScript,而不是只把代码包进 Promise。

把重计算放入 Promise.then 仍在主线程执行,并不会自动变成后台任务。

浏览器与 Node.js 不完全相同

Promise 微任务的概念相近,但 Node.js 有自己的事件循环阶段和 process.nextTick 队列。浏览器中的 requestAnimationFrame、渲染机会和 DOM 事件也不存在于普通 Node.js 运行环境。面试题或线上问题必须先确认运行环境和版本。

调试事件循环问题

  1. 给同步代码、Promise、定时器分别打带序号的日志。
  2. 使用浏览器 Performance 面板查找 Long Task。
  3. 检查是否在一次事件处理中同步解析大量数据或渲染大列表。
  4. 检查递归 Promise/queueMicrotask 是否导致队列饥饿。
  5. 不要用增加 setTimeout(..., 0) 的方式掩盖状态竞争;先明确任务依赖。

常见判断

表述 是否准确
setTimeout(fn, 0) 会立即执行 否,只是达到最短延迟后排队
Promise 回调是同步代码 否,reaction 作为微任务执行
微任务总在下一轮所有任务之前清空 在同一事件循环与检查点的简化语境下成立
Promise 能把 CPU 计算移到后台线程
一个长函数执行中会被点击回调随时打断 否,JavaScript job 运行到完成

参考资料

继续阅读

订阅 FreeMac

每周精选:免费 Mac 软件评测、可信来源更新、替代方案和少折腾指南。