JavaScript Event Loop
JavaScript 是单线程语言,异步能力依赖 Event Loop 调度宏任务与微任务。理解这套机制,是读懂 setTimeout、Promise、async/await 等 API 执行顺序的基础。
本文先建立 Event Loop 的基本概念,再以浏览器环境为主线讲解运行机制与示例,最后补充 Node.js 中的差异。
一、什么是 Event Loop
Event Loop(事件循环) 是 JavaScript 运行时用来协调 同步代码、异步回调 和 调用栈 的调度机制。
可以把它理解为一个持续运转的循环,不断重复以下判断:
- 调用栈里还有代码在执行吗?有,就继续跑同步代码
- 调用栈空了,任务队列里有待执行的回调吗?有,就取出一个放入调用栈执行
- 都没有,就等待新的任务入队
以上是简化模型,便于建立直觉。浏览器中会把「任务队列」进一步拆成 宏任务 与 微任务 两类,调度规则见下一节。
JavaScript 引擎只负责执行同步代码;setTimeout、网络 I/O 等异步工作由宿主环境(浏览器或 Node.js)完成,并把回调放进任务队列。Event Loop 的作用,就是把引擎和宿主环境衔接起来——让异步回调在合适的时机回到主线程执行。
二、浏览器中的 Event Loop
以下以前端(浏览器)环境为主讲解。Node.js 的差异见第三节。
单线程模型
JavaScript 最初被设计为浏览器脚本语言,需要直接操作 DOM。若允许多线程同时读写同一节点,浏览器将无法确定以哪个线程的结果为准。
因此,主线程同一时刻只执行一段同步代码。耗时或异步工作交给浏览器完成,回调先入队等待;当前同步代码跑完、调用栈清空后,Event Loop 再按规则取出回调继续执行。
运行机制
浏览器中,每轮事件循环大致遵循以下步骤:
- 从 宏任务队列 取出一个任务执行(首次加载时,
<script>整体就是一个宏任务) - 执行过程中,同步代码压入 调用栈 依次运行;遇到异步 API,将回调注册到对应队列
- 当前宏任务的同步代码执行完毕后,清空 微任务队列(可能连续执行多轮,直到队列为空)
- 必要时进行 UI 渲染
- 回到第 1 步,处理下一个宏任务
┌───────────────────────────┐ |
核心规则:每轮先跑完当前宏任务的同步代码,再清空全部微任务,然后才进入下一个宏任务。
调用栈与任务队列
调用栈(Call Stack) 记录当前正在执行的函数:进入函数时入栈,返回时出栈。同一时刻只有一个调用栈,保证同步代码按顺序执行。
任务队列 存放等待执行的回调。宏任务队列和微任务队列都是 FIFO(先进先出)。不是调用栈去”读取”队列,而是 Event Loop 在调用栈清空后,从队列中取出任务放入调用栈执行。
宏任务与微任务
| 类型 | 发起方 | 每轮处理 | 浏览器常见来源 |
|---|---|---|---|
| 宏任务 | 宿主环境 | 取一个 | script、setTimeout、setInterval、I/O、MessageChannel、postMessage |
| 微任务 | JS 引擎 | 清空全部 | Promise.then/catch/finally、MutationObserver、queueMicrotask |
几条容易混淆的结论:
setTimeout/setInterval的回调是宏任务;延迟时间只保证”最早何时进入队列”,不保证精确执行时刻Promise构造函数中的代码同步执行;.then/.catch/.finally的回调才是微任务- 微任务执行过程中产生的新微任务,会在当前轮继续清空,不会等到下一轮
requestAnimationFrame回调在下一帧渲染前执行,通常排在当前微任务清空之后;不要把它和微任务混为一谈
执行顺序示例
同步代码先于宏任务回调
console.log('script start') |
输出:script start → script end → setTimeout
整段 <script> 作为第一个宏任务:setTimeout 只负责把回调注册到宏任务队列,同步代码全部跑完后,才取出该回调执行。
微任务先于下一个宏任务
console.log('script start') |
输出:script start → promise1 → promise1 end → script end → promise2 → setTimeout
当前宏任务的同步代码结束后,先清空微任务(promise2),再进入下一个宏任务(setTimeout)。
微任务链在当前轮清空
console.log('start') |
输出:start → end → promise1 → promise2 → timeout
promise1 执行时注册的 promise2 仍是微任务,会在当前轮微任务清空阶段继续执行,不会等到 setTimeout。
async/await
async/await 是 Promise 的语法糖。理解它在 Event Loop 中的行为,需要把它还原成「同步代码 + Promise 微任务」。
await 的执行时机
常见误解:await 会阻塞线程,等右侧表达式完全结束后再继续。
实际上:
await右侧表达式会先同步求值- 若结果是 Promise,则
await之后的代码相当于注册到该 Promise 的then中,即微任务 async函数在第一个await处让出执行权,外层同步代码继续运行
因此,await 不会阻塞主线程,只是把后续代码推迟到微任务阶段。
综合示例
async function async1() { |
输出:script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
执行过程:
- 同步:
script start→ 进入async1→async1 start→ 调用async2→async2 await async2()返回已 resolve 的 Promise,将async1 end入微任务队列,async1让出- 同步:
promise1→script end - 清空微任务:先
async1 end(await 续体),再promise2 - 宏任务:
setTimeout
与 Promise 的对应关系
可以把 async/await 理解为自动展开的 Promise 链:
// async/await 写法 |
await 之后的代码 = .then 回调 = 微任务,按 FIFO 与同轮其他微任务依次执行。
三、Node.js 中的 Event Loop
浏览器与 Node.js 都遵循「宏任务 → 微任务」的大框架,但 Node.js 额外引入了 process.nextTick 和 setImmediate,优先级规则与浏览器不同。
与浏览器的差异
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 宏任务 | script、setTimeout、setInterval、I/O 等 |
上述 + setImmediate、I/O 回调 |
| 微任务 | Promise.then、queueMicrotask 等 |
同上 |
| nextTick 队列 | 无 | process.nextTick(不属于微任务规范,但优先级高于 Promise 微任务) |
Node.js 中每轮循环的简化顺序:
- 执行一个宏任务(timers / I/O / check 等阶段)
- 清空
process.nextTick队列 - 清空 Promise 等微任务队列
- 进入下一轮
process.nextTick不属于微任务规范,但行为上在每轮宏任务之后、Promise 微任务之前执行,优先级更高。
执行顺序示例
console.log('1') |
输出:1 → 7 → 6 → 8 → 2 → 4 → 3 → 5 → 9 → 11 → 10 → 12
| 阶段 | 动作 | 输出 |
|---|---|---|
| 同步 | 主脚本 | 1、7 |
| nextTick | 清空 nextTick 队列 | 6 |
| 微任务 | 清空 Promise 队列 | 8 |
| 宏任务 1 | 第一个 setTimeout 回调 |
2、4(Promise 构造函数同步) |
| nextTick | 该宏任务产生的 nextTick | 3 |
| 微任务 | 该宏任务产生的 Promise | 5 |
| 宏任务 2 | 第二个 setTimeout 回调 |
9、11 |
| nextTick | 10 |
|
| 微任务 | 12 |
process.nextTick 使用注意
process.nextTick 回调若递归注册自身,会饿死 I/O 和宏任务——因为 nextTick 队列在当前轮永远优先清空。
function spin() { |
适合场景:在异步操作完成前、当前调用栈退出后立刻执行的小任务(如错误处理、状态通知)。不应替代 setImmediate 做大量或递归调度。
setImmediate 与 setTimeout(0)
两者都是宏任务,但入队时机不同。在 I/O 回调内,setImmediate 通常先于 setTimeout(fn, 0) 执行:
const fs = require('fs') |
在主脚本顶层(无 I/O 回调)同时注册时,先后顺序取决于当前事件循环状态,不能假定固定顺序。
Node.js 事件循环按阶段推进(timers → pending callbacks → idle/prepare → poll → check → close callbacks),setImmediate 在 check 阶段执行,setTimeout 在 timers 阶段执行。日常开发记住「I/O 回调里 immediate 更早」即可,细阶段可在需要时查阅 Node.js 官方文档。
四、速查
浏览器
| 问题 | 答案 |
|---|---|
| Promise 构造函数里的代码何时执行? | 立即、同步 |
.then 何时执行? |
当前宏任务同步代码结束后 |
| 微任务和宏任务谁先? | 当前宏任务同步代码结束后,先清空微任务,再取下一个宏任务 |
| 微任务里再产生微任务怎么办? | 仍在当前轮继续清空 |
setTimeout(fn, 0) 会立刻执行吗? |
不会,回调仍是宏任务,排在当前微任务之后 |
requestAnimationFrame 是微任务吗? |
不是,在下一帧渲染前执行,通常晚于当前微任务 |
async/await
| 问题 | 答案 |
|---|---|
await 会阻塞主线程吗? |
不会,只让出 async 函数内的后续代码 |
await 右侧何时执行? |
同步求值,立即执行 |
await 后面的代码何时执行? |
作为微任务,在当前宏任务同步代码结束后 |
async 函数返回值是什么? |
始终是 Promise |
Node.js
| 问题 | 答案 |
|---|---|
process.nextTick 在哪生效? |
仅 Node.js |
| 与 Promise 微任务谁优先? | nextTick 优先 |
setImmediate 与 setTimeout(0) 区别? |
I/O 回调内 setImmediate 通常更早;顶层同时注册时顺序不确定 |
浏览器能用 process.nextTick 吗? |
不能,用 queueMicrotask 代替 |
- 作者: Yin-Hongwei
- 链接: http://Yin-Hongwei.github.io/2018/08/17/JavaScript%20Event%20Loop/
- 版权声明: CC BY-NC-SA 4.0