技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

一次弄懂 Event Loop(彻底解决此类面试问题)

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/72-一次弄懂Event Loop(彻底解决此类面试问题)
本文目录10 个章节
  1. 一、为什么要弄懂 Event Loop
  2. 二、堆、栈、队列
  3. 三、Event Loop 中的 Task 和 Microtask
  4. 四、浏览器中的基础题
  5. 五、async/await 的执行顺序
  6. 六、Node.js 的 Event Loop
  7. 七、综合练习:Promise、timer 和 nextTick
  8. 八、面试题的通用分析步骤
  9. 九、总结
  10. 参考资料

一次弄懂 Event Loop(彻底解决此类面试问题)

Event Loop(事件循环)是浏览器或 Node.js 宿主协调 JavaScript 代码、异步操作、事件和定时器的一套机制。它让一个 JavaScript agent 可以在等待 I/O 时继续处理其他工作,但它并不意味着 JavaScript 回调会并行执行,也不意味着浏览器和 Node.js 使用完全相同的循环。

本文保留原文“堆、栈、队列 → 浏览器 → Node.js → 面试题”的结构,并修正旧文章中把计算机数据结构 heap 当成 JavaScript 内存堆、把所有异步回调放入一个任务队列以及把 Node 版本行为写死的问题。原文配图来自 Juejin/ByteDance CDN,已下载到同目录 images 文件夹;旧图中的浏览器和 Node 流程仅作历史示意。

一、为什么要弄懂 Event Loop

  • 了解同步代码、Promise、定时器和 I/O 的先后关系;
  • 解释页面为什么会被长任务阻塞;
  • 区分浏览器和 Node.js 的运行时;
  • 面对事件循环面试题时先明确环境和规范,而不是死记一份输出。

二、堆、栈、队列

1. 数据结构中的堆

计算机科学中的 heap 是一种通常用数组表示的完全二叉树结构,可以实现最小堆或最大堆。它常用于优先队列和调度算法,与“JavaScript heap memory”只是同名,不是同一种概念。

计算机数据结构中的堆

2. 数据结构中的栈

栈按照后进先出(LIFO)工作,只能在栈顶附近进行压入和弹出。JavaScript 的调用栈借用了这一抽象来记录当前执行上下文:

function first() {
  second()
}

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

first()

调用 second 时,它的执行上下文位于栈顶;返回后才回到 first

调用栈的 LIFO 结构

3. 数据结构中的队列

队列通常按照先进先出(FIFO)处理元素。浏览器 HTML Standard 实际上允许多个 task queue,microtask queue 也不是 task queue;因此“一个宏任务队列 + 一个微任务队列”的说法只是便于入门的模型。

任务队列的 FIFO 结构

4. JavaScript 的 heap、stack 和 queue

ECMAScript 规范描述值、对象、执行上下文和 job,并不规定 primitive 一定在栈、对象一定在堆。可以这样理解:

  • 调用栈记录当前执行上下文;
  • 对象具有身份,多个变量可能共享同一个对象;
  • 引擎使用 heap 等内存区域管理对象,但具体布局属于实现;
  • 事件循环从宿主提供的任务和 job 中选择后续工作;
  • 闭包可能使词法绑定在创建它的函数返回后继续存活。

原文的 JavaScript 执行模型示意图

三、Event Loop 中的 Task 和 Microtask

1. Task(宏任务的常用叫法)

浏览器中常见 task 来源包括:

  • 经典脚本、模块脚本的执行;
  • setTimeoutsetInterval 的计时器回调;
  • 用户输入、消息和部分网络事件。

setImmediate 是 Node.js API,不是浏览器通用 API;Object.observe 已废弃并从现代 JavaScript 中移除,不能继续列为当前微任务来源。

2. Microtask

Promise reaction、queueMicrotask 和浏览器 MutationObserver 回调通常在 microtask checkpoint 中执行。Promise 对象本身不是微任务,注册在 Promise 上的回调才会被调度。

在浏览器中,一次简化的循环可以表示为:

  1. 选择并执行一个 task,运行至完成;
  2. 执行 microtask checkpoint,清空当前微任务队列;
  3. 在合适时机更新渲染;
  4. 继续选择下一个 task。

微任务中产生的新微任务会加入当前队列并继续执行,因此无界微任务链可能阻塞渲染和用户输入。

四、浏览器中的基础题

console.log('script start')

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

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

console.log('script end')

输出顺序:

script start
script end
promise1
promise2
setTimeout

解释:整个经典脚本通常是一个 task。同步日志先执行;脚本结束后进入 microtask checkpoint,先执行第一个 Promise reaction;第一个 reaction 完成后,第二个 then 的 reaction 才加入队列并继续执行;微任务清空后才轮到 timer task。

浏览器 Event Loop 的步骤

Task 与 Microtask 的关系

五、async/await 的执行顺序

console.log('script start')

async function async1() {
  await async2()
  console.log('async1 end')
}

async function async2() {
  console.log('async2 end')
}

async1()

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

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

console.log('script end')

在当前主流浏览器和 Node.js 中,通常输出:

script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout

async2() 的函数体会同步运行到返回 Promise 的位置,因此 async2 end 先打印;await 之后的 async1 end 会异步恢复。调用 async1() 后,后续的 Promise 链也会排队,具体 reaction 的先后由它们注册的时间和规范算法决定。

关于 Chrome 73 和旧版 V8

原文重点介绍了 Chrome 73 前后的 await 优化:旧版 V8 为 await 生成更多中间 Promise 和 microtask,V8 后来依据 TC39 的规范调整减少了不必要的 tick。这个历史现象可以保留,但不能把“三个 tick”或 Chrome 73 的实现细节当作现在所有浏览器的语言规则。今天应以 ECMAScript 的异步函数和 Promise job 语义为准,并在目标浏览器或 Node.js 版本中运行最小示例验证。

async/await 历史实现差异

六、Node.js 的 Event Loop

Node.js 的 Event Loop 基于 libuv。经典的阶段图如下:

┌─────────────┐
│   timers    │  setTimeout / setInterval
└──────┬──────┘
       ↓
┌─────────────┐
│ pending     │  部分延后的 I/O 回调
│ callbacks   │
└──────┬──────┘
       ↓
┌─────────────┐
│ idle,prepare│  内部使用
└──────┬──────┘
       ↓
┌─────────────┐
│    poll     │  I/O 事件与等待
└──────┬──────┘
       ↓
┌─────────────┐
│    check    │  setImmediate
└──────┬──────┘
       ↓
┌─────────────┐
│ close       │  close callbacks
│ callbacks   │
└─────────────┘

Node.js 20 使用的 libuv 1.45.0 改变了 timers 与 poll 的相对调度:timers 在每轮中主要位于 poll 之后,旧版本在 poll 前后都有差异。阶段图是总体模型,不是保证每个平台和版本都按同一条直线执行。

Node.js Event Loop 阶段

setImmediatesetTimeout

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

setImmediate(() => {
  console.log('immediate')
})

从 Node.js 主模块直接安排时,顺序可能受启动时机和系统调度影响;不要写死输出。放在 I/O 回调中时通常是 setImmediate 先执行:

const fs = require('node:fs')

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0)
  setImmediate(() => console.log('immediate'))
})

原因是 I/O 回调位于 poll 相关流程,setImmediate 会进入 check 阶段,而零延迟 timer 仍需等待 timers 阶段。0 也只是最小阈值,不是立即执行。

setImmediate 与 setTimeout

process.nextTick()

process.nextTick() 有自己的 nextTick 队列,不属于上面的六个 libuv 阶段。当前操作完成、调用栈展开后,Node 会先处理它;递归的 nextTick 可能饿死 I/O。当前 Node 文档已将它标为 Legacy;多数跨环境的微任务场景优先使用 queueMicrotask(),只有需要 Node 特有调度顺序时才使用 process.nextTick()

let value

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

process.nextTick(() => {
  console.log('nextTick:', value)
})

value = 1

这里 nextTick: 1 会先于 timer 和 immediate。若只需要跨浏览器和 Node 的 Promise 级微任务,使用 queueMicrotask 更通用;process.nextTick 只在需要 Node 特定顺序时使用。

Node 版本还可能影响 Promise microtask 在不同回调之间的观察位置。Node 11 以后对每次回调后的 microtask 处理更接近浏览器,但不能把 Node 10 的历史输出当作今天的通用规则。

原文配图:Node timers 与 microtask

原文配图:Node 版本差异

Node nextTick 队列

七、综合练习:Promise、timer 和 nextTick

下面按 Node.js CommonJS 主模块运行;浏览器中没有 process.nextTick,Node.js ESM 顶层评估也可能改变初始 nextTick 与 Promise reaction 的相对顺序。

console.log('1')

setTimeout(() => {
  console.log('2')
  process.nextTick(() => console.log('3'))
  new Promise(resolve => {
    console.log('4')
    resolve()
  }).then(() => console.log('5'))
}, 0)

process.nextTick(() => console.log('6'))

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

setTimeout(() => {
  console.log('9')
  process.nextTick(() => console.log('10'))
  new Promise(resolve => {
    console.log('11')
    resolve()
  }).then(() => console.log('12'))
}, 0)

当前 Node.js 中通常输出:

1
7
6
8
2
4
3
5
9
11
10
12

分析方法:

  • 17 属于当前脚本的同步代码;
  • 脚本结束后先处理 nextTick 6,再处理 Promise reaction 8
  • 第一个 timer 中先同步输出 24,然后 nextTick 3 先于 Promise reaction 5
  • 第二个 timer 同理输出 9111012

如果将 process.nextTick 放到浏览器代码中,代码会直接报错;如果要比较浏览器输出,应换成 queueMicrotask 或 Promise,并明确这是另一套宿主调度。

综合题的执行流程

八、面试题的通用分析步骤

  1. 先写出当前 task 中的同步输出;
  2. 记录每个定时器、I/O、事件和 Promise reaction 的注册位置;
  3. 区分浏览器 microtask、Node nextTick 和 Promise job;
  4. 一个 task 或 callback 执行期间不能被同一 agent 的其他 JavaScript 回调抢占;
  5. 当前 task 完成后清空 microtask,微任务中新加入的微任务也要继续处理;
  6. 检查 timer 的最小阈值和 I/O 上下文,不把 0ms 当成立即执行;
  7. 最后再讨论渲染、poll、check 和不同 Node 版本的实现差异;
  8. 对复杂题目在目标运行时执行最小脚本,避免只依赖旧文章的图片或结论。

九、总结

  • Event Loop 是宿主环境协调任务和异步完成事件的机制,不是 JavaScript 语言本身唯一的一张流程图;
  • 同一个 JavaScript agent 的回调通常串行、运行至完成,但浏览器和 Node 可以使用其他线程、进程或 worker 并行处理工作;
  • 浏览器通常在 task 后执行 microtask checkpoint,并可能在合适时机更新渲染;
  • Node.js 还有 libuv 阶段、setImmediate 和独立的 process.nextTick 队列;
  • setTimeout 的参数表示最小阈值,setInterval 不是精确时钟;
  • async/await 会产生异步恢复,不应按旧版 V8 的固定 tick 数量解释;
  • 确认运行环境、版本和代码上下文,比背“宏任务优先/微任务优先”更重要。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS