JavaScript Event Loop

JavaScript 是单线程语言,异步能力依赖 Event Loop 调度宏任务与微任务。理解这套机制,是读懂 setTimeoutPromiseasync/await 等 API 执行顺序的基础。

本文先建立 Event Loop 的基本概念,再以浏览器环境为主线讲解运行机制与示例,最后补充 Node.js 中的差异。

一、什么是 Event Loop

Event Loop(事件循环) 是 JavaScript 运行时用来协调 同步代码异步回调调用栈 的调度机制。

可以把它理解为一个持续运转的循环,不断重复以下判断:

  1. 调用栈里还有代码在执行吗?有,就继续跑同步代码
  2. 调用栈空了,任务队列里有待执行的回调吗?有,就取出一个放入调用栈执行
  3. 都没有,就等待新的任务入队

以上是简化模型,便于建立直觉。浏览器中会把「任务队列」进一步拆成 宏任务微任务 两类,调度规则见下一节。

JavaScript 引擎只负责执行同步代码;setTimeout、网络 I/O 等异步工作由宿主环境(浏览器或 Node.js)完成,并把回调放进任务队列。Event Loop 的作用,就是把引擎和宿主环境衔接起来——让异步回调在合适的时机回到主线程执行。

二、浏览器中的 Event Loop

以下以前端(浏览器)环境为主讲解。Node.js 的差异见第三节。

单线程模型

JavaScript 最初被设计为浏览器脚本语言,需要直接操作 DOM。若允许多线程同时读写同一节点,浏览器将无法确定以哪个线程的结果为准。

因此,主线程同一时刻只执行一段同步代码。耗时或异步工作交给浏览器完成,回调先入队等待;当前同步代码跑完、调用栈清空后,Event Loop 再按规则取出回调继续执行。

运行机制

浏览器中,每轮事件循环大致遵循以下步骤:

  1. 宏任务队列 取出一个任务执行(首次加载时,<script> 整体就是一个宏任务)
  2. 执行过程中,同步代码压入 调用栈 依次运行;遇到异步 API,将回调注册到对应队列
  3. 当前宏任务的同步代码执行完毕后,清空 微任务队列(可能连续执行多轮,直到队列为空)
  4. 必要时进行 UI 渲染
  5. 回到第 1 步,处理下一个宏任务
┌───────────────────────────┐
│ 取宏任务(macrotask) │
└─────────────┬─────────────┘

┌───────────────────────────┐
│ 执行同步代码(调用栈) │
└─────────────┬─────────────┘

┌───────────────────────────┐
│ 清空微任务队列 │
│ (microtask) │
└─────────────┬─────────────┘

┌───────────────────────────┐
│ UI 渲染(可选) │
└─────────────┬─────────────┘

下一轮循环

核心规则:每轮先跑完当前宏任务的同步代码,再清空全部微任务,然后才进入下一个宏任务。

调用栈与任务队列

调用栈(Call Stack) 记录当前正在执行的函数:进入函数时入栈,返回时出栈。同一时刻只有一个调用栈,保证同步代码按顺序执行。

任务队列 存放等待执行的回调。宏任务队列和微任务队列都是 FIFO(先进先出)。不是调用栈去”读取”队列,而是 Event Loop 在调用栈清空后,从队列中取出任务放入调用栈执行。

宏任务与微任务

类型 发起方 每轮处理 浏览器常见来源
宏任务 宿主环境 取一个 scriptsetTimeoutsetInterval、I/O、MessageChannelpostMessage
微任务 JS 引擎 清空全部 Promise.then/catch/finallyMutationObserverqueueMicrotask

几条容易混淆的结论:

  • setTimeout / setInterval回调是宏任务;延迟时间只保证”最早何时进入队列”,不保证精确执行时刻
  • Promise 构造函数中的代码同步执行.then / .catch / .finally 的回调才是微任务
  • 微任务执行过程中产生的新微任务,会在当前轮继续清空,不会等到下一轮
  • requestAnimationFrame 回调在下一帧渲染前执行,通常排在当前微任务清空之后;不要把它和微任务混为一谈

执行顺序示例

同步代码先于宏任务回调

console.log('script start')

setTimeout(function () {
console.log('setTimeout')
})

console.log('script end')

输出:script startscript endsetTimeout

整段 <script> 作为第一个宏任务:setTimeout 只负责把回调注册到宏任务队列,同步代码全部跑完后,才取出该回调执行。

微任务先于下一个宏任务

console.log('script start')

new Promise(function (resolve) {
console.log('promise1') // 同步
resolve()
console.log('promise1 end') // 同步
}).then(function () {
console.log('promise2') // 微任务
})

setTimeout(function () {
console.log('setTimeout') // 宏任务
})

console.log('script end')

输出:script startpromise1promise1 endscript endpromise2setTimeout

当前宏任务的同步代码结束后,先清空微任务(promise2),再进入下一个宏任务(setTimeout)。

微任务链在当前轮清空

console.log('start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve()
.then(() => {
console.log('promise1')
return Promise.resolve()
})
.then(() => console.log('promise2'))

console.log('end')

输出:startendpromise1promise2timeout

promise1 执行时注册的 promise2 仍是微任务,会在当前轮微任务清空阶段继续执行,不会等到 setTimeout

async/await

async/await 是 Promise 的语法糖。理解它在 Event Loop 中的行为,需要把它还原成「同步代码 + Promise 微任务」。

await 的执行时机

常见误解:await 会阻塞线程,等右侧表达式完全结束后再继续。

实际上:

  1. await 右侧表达式会先同步求值
  2. 若结果是 Promise,则 await 之后的代码相当于注册到该 Promise 的 then 中,即微任务
  3. async 函数在第一个 await让出执行权,外层同步代码继续运行

因此,await 不会阻塞主线程,只是把后续代码推迟到微任务阶段。

综合示例

async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end') // 微任务
}

async function async2() {
console.log('async2') // 同步(await 右侧先执行)
}

console.log('script start')

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

async1()

new Promise(resolve => {
console.log('promise1')
resolve()
}).then(() => {
console.log('promise2')
})

console.log('script end')

输出:script startasync1 startasync2promise1script endasync1 endpromise2setTimeout

执行过程:

  1. 同步:script start → 进入 async1async1 start → 调用 async2async2
  2. await async2() 返回已 resolve 的 Promise,将 async1 end 入微任务队列,async1 让出
  3. 同步:promise1script end
  4. 清空微任务:先 async1 end(await 续体),再 promise2
  5. 宏任务:setTimeout

与 Promise 的对应关系

可以把 async/await 理解为自动展开的 Promise 链:

// async/await 写法
async function foo() {
const result = await bar()
console.log(result)
}

// 等价的 Promise 写法
function foo() {
return bar().then(result => {
console.log(result)
})
}

await 之后的代码 = .then 回调 = 微任务,按 FIFO 与同轮其他微任务依次执行。

三、Node.js 中的 Event Loop

浏览器与 Node.js 都遵循「宏任务 → 微任务」的大框架,但 Node.js 额外引入了 process.nextTicksetImmediate,优先级规则与浏览器不同。

与浏览器的差异

特性 浏览器 Node.js
宏任务 scriptsetTimeoutsetInterval、I/O 等 上述 + setImmediate、I/O 回调
微任务 Promise.thenqueueMicrotask 同上
nextTick 队列 process.nextTick(不属于微任务规范,但优先级高于 Promise 微任务)

Node.js 中每轮循环的简化顺序:

  1. 执行一个宏任务(timers / I/O / check 等阶段)
  2. 清空 process.nextTick 队列
  3. 清空 Promise 等微任务队列
  4. 进入下一轮

process.nextTick 不属于微任务规范,但行为上在每轮宏任务之后、Promise 微任务之前执行,优先级更高。

执行顺序示例

console.log('1')

setTimeout(function () {
console.log('2')
process.nextTick(function () {
console.log('3')
})
new Promise(function (resolve) {
console.log('4')
resolve()
}).then(function () {
console.log('5')
})
})

process.nextTick(function () {
console.log('6')
})

new Promise(function (resolve) {
console.log('7')
resolve()
}).then(function () {
console.log('8')
})

setTimeout(function () {
console.log('9')
process.nextTick(function () {
console.log('10')
})
new Promise(function (resolve) {
console.log('11')
resolve()
}).then(function () {
console.log('12')
})
})

输出:176824359111012

阶段 动作 输出
同步 主脚本 17
nextTick 清空 nextTick 队列 6
微任务 清空 Promise 队列 8
宏任务 1 第一个 setTimeout 回调 24(Promise 构造函数同步)
nextTick 该宏任务产生的 nextTick 3
微任务 该宏任务产生的 Promise 5
宏任务 2 第二个 setTimeout 回调 911
nextTick 10
微任务 12

process.nextTick 使用注意

process.nextTick 回调若递归注册自身,会饿死 I/O 和宏任务——因为 nextTick 队列在当前轮永远优先清空。

function spin() {
process.nextTick(spin) // 危险:阻塞事件循环
}

适合场景:在异步操作完成前、当前调用栈退出后立刻执行的小任务(如错误处理、状态通知)。不应替代 setImmediate 做大量或递归调度。

setImmediate 与 setTimeout(0)

两者都是宏任务,但入队时机不同。在 I/O 回调内setImmediate 通常先于 setTimeout(fn, 0) 执行:

const fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
// 输出:immediate → timeout

主脚本顶层(无 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 优先
setImmediatesetTimeout(0) 区别? I/O 回调内 setImmediate 通常更早;顶层同时注册时顺序不确定
浏览器能用 process.nextTick 吗? 不能,用 queueMicrotask 代替
微信打赏