
浏览器中的 JavaScript 事件循环可以先记成一句话:执行一个任务,清空本轮产生的微任务,浏览器获得渲染机会,然后进入下一轮。Promise.then 和 queueMicrotask 属于微任务;定时器回调、用户事件等通常作为任务进入后续轮次。
本文合并本站原有的三篇 Event Loop 内容,并修正“微任务永远比所有宏任务优先”这类容易误导的简化说法。
目录
- 事件循环解决什么问题
- 一轮浏览器事件循环
- 最常见的输出顺序
- async/await 放在哪里
- 微任务会阻塞渲染吗
- 长任务为什么让页面卡顿
- 浏览器与 Node.js 不完全相同
- 调试事件循环问题
- 常见判断
- 参考资料
- 继续阅读
事件循环解决什么问题
JavaScript 代码在一个执行线程上按“运行到完成”处理:一个函数开始执行后,不会在任意一行突然被另一个 JavaScript 回调打断。但浏览器还要处理网络、定时器、用户输入和渲染,因此运行环境会把完成后的回调排入相应队列,由事件循环选择合适时机执行。
核心组成包括:
- 调用栈:当前正在执行的函数。
- 宿主环境 API:定时器、网络、DOM 事件等浏览器能力。
- 任务队列:定时器回调、事件回调等等待执行的任务。
- 微任务队列:Promise reaction、
queueMicrotask、MutationObserver 等。 - 渲染机会:样式计算、布局和绘制并不是每条 JavaScript 后都立即发生。
“JavaScript 单线程”不等于浏览器只能做一件事;网络和部分底层工作可以在宿主环境中进行,最终回调仍要等待 JavaScript 主线程。
一轮浏览器事件循环
可以用下面的简化流程理解:
- 从某个任务队列选择并执行一个任务。
- 当前调用栈清空后,执行微任务检查点。
- 持续执行微任务,直到微任务队列为空;新加入的微任务也会在本轮继续执行。
- 浏览器可能更新渲染。
- 进入下一轮并处理新的任务。
这里的“任务”在旧文章和日常讨论中常被叫作“宏任务”,但 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 运行环境。面试题或线上问题必须先确认运行环境和版本。
调试事件循环问题
- 给同步代码、Promise、定时器分别打带序号的日志。
- 使用浏览器 Performance 面板查找 Long Task。
- 检查是否在一次事件处理中同步解析大量数据或渲染大列表。
- 检查递归 Promise/
queueMicrotask是否导致队列饥饿。 - 不要用增加
setTimeout(..., 0)的方式掩盖状态竞争;先明确任务依赖。
常见判断
| 表述 | 是否准确 |
|---|---|
setTimeout(fn, 0) 会立即执行 |
否,只是达到最短延迟后排队 |
| Promise 回调是同步代码 | 否,reaction 作为微任务执行 |
| 微任务总在下一轮所有任务之前清空 | 在同一事件循环与检查点的简化语境下成立 |
| Promise 能把 CPU 计算移到后台线程 | 否 |
| 一个长函数执行中会被点击回调随时打断 | 否,JavaScript job 运行到完成 |
参考资料
继续阅读
- 如果你想继续补 JavaScript 基础,可以看 JavaScript 数组与遍历:map、splice、for of。
- 如果你在 React 中处理异步副作用,可以读 Vue 3 和 React 中的副作用管理。
- 如果你关注页面渲染性能,可以继续看 重排与重绘:浏览器渲染性能基础。
继续阅读
JavaScript 数组与遍历:map、splice、for of
按是否修改原数组梳理 map、filter、reduce、find、slice、splice、sort、toSorted,以及 for、forEach、for of、for in 的选择方法。
7 分钟JavaScript % 余数运算:负数、取模与实用场景
解释 JavaScript % 返回余数而非数学取模,展示负数归一化、奇偶判断、循环索引、角度归一化和 BigInt 的正确写法。
11 分钟JavaScript 解构与对象复制:浅拷贝、深拷贝
讲清对象和数组解构、展开语法的浅拷贝边界,并比较 structuredClone、JSON 序列化与自定义深拷贝的适用场景。
订阅 FreeMac
每周精选:免费 Mac 软件评测、可信来源更新、替代方案和少折腾指南。