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

显示模式

登录
ARCHIVE DOCUMENTJS

JS 的事件循环(Event Loop)机制以及实例讲解

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/42-Js 的事件循环(Event Loop)机制以及实例讲解
本文目录12 个章节
  1. 一、为什么需要事件循环
  2. 二、执行上下文、调用栈和队列
  3. 三、浏览器中的 task、microtask 和渲染
  4. 四、原文定时器示例
  5. 五、经典 Promise 面试题
  6. 六、async/await 与微任务
  7. 七、浏览器渲染和事件循环的关系
  8. 八、Node.js 中的事件循环
  9. 九、事件循环调试建议
  10. 十、历史图示与来源
  11. 十一、总结
  12. 参考资料

JS 的事件循环(Event Loop)机制以及实例讲解

Category(分类): JavaScript Status: 已整理(2026)

本文保留原文的执行栈、任务队列、宏任务/微任务和 Promise 面试题,并补充 ECMAScript、浏览器 HTML 规范和 Node.js 的差异。原文把执行栈、主线程、任务队列和渲染时机混为一谈,也把“每个事件循环都一定渲染”写得过于绝对,现已区分这些概念。

原文:Js 的事件循环(Event Loop)机制以及实例讲解

一、为什么需要事件循环

JavaScript 的同步代码在同一个 JavaScript agent 中按顺序执行,一个执行上下文不会被另一个 JavaScript 回调从中途抢占。浏览器把 DOM、用户输入、网络、定时器等宿主能力交给运行时管理;异步操作完成后,宿主再把相应的回调安排到队列中。

“JavaScript 是单线程”需要加限定:

  • 一个浏览器窗口的 JavaScript 通常由一个主线程上的 agent 执行;
  • 浏览器可以使用其他线程处理网络、解码、布局或内部任务;
  • Web Worker 拥有自己的 agent 和事件循环,可以并行执行 JavaScript;
  • Node.js 默认使用一个 JavaScript 线程,但通过 libuv、操作系统和 worker pool 处理许多 I/O,Worker Threads 也可以运行额外的 JavaScript 线程;
  • 单线程只描述一个 agent 中 JavaScript 执行的并发模型,不等于整个浏览器或 Node.js 只有一个线程。

二、执行上下文、调用栈和队列

2.1 调用栈不是任务队列

函数调用会创建执行上下文并压入调用栈;函数返回后上下文出栈:

function square(value) {
  return value * value
}

function calculate(value) {
  return square(value) + 1
}

console.log(calculate(4)) // 17

执行 calculate(4) 时,调用栈大致经历:

全局脚本 → calculate → square
全局脚本 → calculate
全局脚本
空栈

在 ECMAScript 语境中,job 是一次可以运行到完成的工作单元;在浏览器 HTML 语境中,task 是宿主调度的一类工作。Promise reaction 通常作为 microtask/job 执行,并不是 HTML task。它们开始执行时都会使用调用栈,但“调用栈”本身不是存放待执行事件的“执行栈队列”。

2.2 运行到完成

一个任务中的同步 JavaScript 会运行到调用栈清空,其他回调不能插入同步代码中间:

console.log('start')

for (let i = 0; i < 3; i += 1) {
  console.log(`sync ${i}`)
}

console.log('end')

输出一定是:

start
sync 0
sync 1
sync 2
end

如果同步任务执行时间过长,浏览器无法及时处理点击、滚动或绘制,这就是长任务(long task)导致卡顿的原因之一。事件循环不会自动把一个正在执行的同步函数切成更小的片段。

三、浏览器中的 task、microtask 和渲染

浏览器遵循 HTML 事件循环模型,常用的简化顺序如下:

  1. 取一个可运行的 task,例如初始脚本、计时器回调或用户事件回调;
  2. 执行该 task,直到 JavaScript 调用栈清空;
  3. 执行 microtask checkpoint,持续清空微任务队列;
  4. 浏览器在合适的时机更新渲染,可能执行 requestAnimationFrame 回调;
  5. 继续选择下一个 task。

这是便于开发者理解的模型,不代表所有任务来源共享一个简单 FIFO 队列,也不代表每次循环都必须绘制。渲染由浏览器根据刷新率、页面是否可见、是否有渲染更新等条件决定。

3.1 常见 task 来源

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

  • <script> 中启动的一段脚本;
  • 用户交互事件回调;
  • setTimeoutsetInterval 到期后的回调;
  • MessageChannelpostMessage 等消息事件;
  • 网络或其他宿主 API 完成后派发的事件。

setTimeout(fn, 0) 不是“立刻执行”,而是设置一个不早于指定延迟的计时器;回调要等当前 task、微任务以及调度条件允许后才可能执行。

3.2 常见 microtask 来源

常见微任务包括:

  • Promise.then/catch/finally 回调;
  • queueMicrotask 回调;
  • 浏览器的 MutationObserver 回调。

fetch() 本身是异步宿主 API,Promise 的反应回调在 Promise 结算后以微任务形式执行;不能简单说“fetch 是一个微任务”。requestAnimationFrame 是与下一次渲染更新相关的回调,也不应粗略归为普通宏任务。

示例:

console.log('script start')

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

queueMicrotask(() => {
  console.log('queueMicrotask')
})

Promise.resolve().then(() => {
  console.log('promise microtask')
})

console.log('script end')

典型浏览器输出:

script start
script end
queueMicrotask
promise microtask
timer task

同一个微任务中继续加入的微任务,会在下一个 task 之前继续执行:

queueMicrotask(() => {
  console.log('microtask 1')
  queueMicrotask(() => console.log('microtask 2'))
})

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

典型输出是 microtask 1microtask 2timer。如果无限添加微任务,就会阻塞后续 task 和渲染,造成微任务饥饿:

let count = 0

function scheduleForever() {
  queueMicrotask(() => {
    count += 1
    if (count < 1000) scheduleForever()
  })
}

scheduleForever()

实际代码应避免无界递归地加入微任务。

四、原文定时器示例

原文通过三个函数说明:同步循环会先全部执行,定时器回调要等当前脚本 task 完成后才有机会运行。下面减少循环次数,避免无意义地打印数万行:

function runTask(name, taskNumber) {
  setTimeout(() => {
    console.log(`任务队列函数${taskNumber}`)
  }, 0)

  for (let i = 0; i < 3; i += 1) {
    console.log(`${name} 的 for 循环 ${i}`)
  }

  console.log(`${name} 事件执行完`)
}

runTask('a', 1)
runTask('b', 2)
runTask('c', 3)

同步的 abc 会先完成,随后才处理计时器 task。多个计时器的实际时机仍受计时器阈值、浏览器调度、页面状态和其他任务影响,但它们不会抢到当前同步代码之前执行。

五、经典 Promise 面试题

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

new Promise(resolve => {
  console.log(2)
  resolve(3)
}).then(value => {
  console.log(value)
})

console.log(4)

典型输出是:

2
4
3
1

原因:

  1. 初始脚本 task 开始,注册定时器;
  2. Promise 构造器的 executor 是同步执行的,打印 2
  3. then 回调被安排为微任务;
  4. 继续打印 4,初始脚本 task 完成;
  5. 清空微任务队列,打印 3
  6. 之后才有机会执行定时器 task,打印 1

Promise 的 executor 是同步的,Promise 的 reaction handler 才是异步调度的:

console.log('before')

const promise = new Promise(resolve => {
  console.log('executor')
  resolve()
})

promise.then(() => console.log('then'))
console.log('after')

输出:

before
executor
after
then

六、async/await 与微任务

async 函数在遇到 await 时会把后续部分安排到 Promise reaction 中;即使等待的是已经完成的 Promise,后续代码也不会在同一同步调用栈中直接继续:

async function example() {
  console.log('async start')
  await Promise.resolve()
  console.log('async continuation')
}

console.log('script start')
example()
console.log('script end')

典型输出:

script start
async start
script end
async continuation

await 不会阻塞整个线程等待网络或定时器;它只暂停当前 async 函数,并把后续逻辑交给 Promise 调度。其他任务仍可能在等待期间运行。

七、浏览器渲染和事件循环的关系

原文把“每次事件循环都更新 render”写成确定规则,这不准确。浏览器可能在 task 和微任务之间、接近下一帧时执行渲染更新;实际时机由 HTML 事件循环、页面可见性、刷新率、布局/绘制需求和浏览器实现共同决定。

需要动画同步到渲染帧时,使用 requestAnimationFrame

let frameId

function update() {
  // 在浏览器准备绘制下一帧前更新 DOM 或样式
  frameId = requestAnimationFrame(update)
}

frameId = requestAnimationFrame(update)

// 示例:运行约 1 秒后停止动画;立即 cancel 则看不到首帧回调
setTimeout(() => cancelAnimationFrame(frameId), 1000)

requestAnimationFrame 回调仍运行在 JavaScript 主线程上;如果回调本身执行很久,依然会阻塞渲染。

八、Node.js 中的事件循环

浏览器的 HTML event loop 与 Node.js 的 libuv event loop 不是同一个规范模型。Node.js 常见阶段包括:

timers → pending callbacks → idle/prepare → poll → check → close callbacks
  • timers:处理到达阈值的 setTimeout/setInterval 回调;阈值不是精确执行时间;
  • pending callbacks:执行部分延后的系统 I/O 回调;
  • poll:获取并处理 I/O 事件,必要时等待;
  • check:执行 setImmediate 回调;
  • close callbacks:处理部分关闭事件。

Node.js 20 使用的 libuv 版本改变了 timers 与 poll 的相对阶段细节,因此涉及 setTimeoutsetImmediate 的边界示例应注明 Node.js 版本,不要把浏览器的“宏任务”图直接套到 Node.js。

8.1 process.nextTick()、Promise 和 setImmediate()

process.nextTick() 使用 Node.js 的 next tick 队列,它不是浏览器 microtask API,也不属于 libuv 阶段本身。Node.js 会在当前操作结束后优先处理 next tick 队列;Promise 微任务也有 Node.js 自己的调度边界。大量递归 nextTick 会阻塞 I/O:

console.log('start')

process.nextTick(() => console.log('nextTick'))
Promise.resolve().then(() => console.log('promise'))
setImmediate(() => console.log('setImmediate'))

console.log('end')

在常见 Node.js 版本中,典型顺序为:

start
end
nextTick
promise
setImmediate

setImmediatesetTimeout(fn, 0) 的先后取决于调用位置:

import { readFile } from 'node:fs'
import { fileURLToPath } from 'node:url'

const filename = fileURLToPath(import.meta.url)

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

在 I/O 回调中,通常先进入 check 阶段,因此常见输出是 immediatetimeout;在主模块直接安排二者时,顺序可能受运行时和系统调度影响,不能写死。

8.2 浏览器和 Node.js 的共同点与差异

共同点:

  • 同一个 JavaScript 执行 agent 中,当前同步代码运行到完成;
  • 异步 API 完成后,回调不会插入正在执行的同步函数;
  • Promise reaction 不会在调用 then 的同一同步位置直接执行。

差异:

  • 浏览器由 HTML 标准定义 task、microtask、渲染和用户交互协作;
  • Node.js 由 Node API、V8、libuv 和操作系统协作,存在 timers、poll、check 等阶段;
  • 浏览器常用 queueMicrotaskMutationObserverrequestAnimationFrame;Node.js 还提供 process.nextTicksetImmediate
  • “宏任务/微任务”是便于沟通的分类,不是可以忽略宿主差异的统一规范术语。

九、事件循环调试建议

  1. 先标记同步代码、task、microtask 和渲染相关回调,不要直接背“宏任务大于微任务”。
  2. 明确运行环境:浏览器经典脚本、ES module、Node.js 主模块、Node.js I/O 回调的顺序可能不同。
  3. 不要把 setTimeout(fn, 0) 当成立即执行或精确 0ms 执行。
  4. 不要在微任务或 process.nextTick 中无限递归;必要时把工作拆成多个 task 或让出主线程。
  5. 使用浏览器 DevTools Performance、Node.js trace events 或小型可重复脚本验证时序,不要只依据截图。
  6. 网络请求的完成顺序、用户事件到达顺序和多个任务源的选择还会受到真实环境影响,业务代码应设计为不依赖偶然顺序。

十、历史图示与来源

原文事件循环图已下载到本地:

事件循环历史图示

图片来源:原始图示。图中“宏任务大于微任务”的排列表达仅是入门简化,本文以浏览器/Node.js 宿主差异说明为准。

十一、总结

  • 调用栈记录当前执行上下文,任务队列记录等待调度的工作,二者不是同一个概念;
  • 同一个 task/job 运行到完成,微任务通常在 task 后被清空;
  • 浏览器可能在合适时机更新渲染,但不是每次循环都必然绘制;
  • Promise reaction、queueMicrotaskMutationObserver 属于常见微任务来源;
  • <script>、事件、计时器等通常作为浏览器 task 开始执行;
  • Node.js 事件循环有 libuv 阶段,process.nextTick、Promise 和 setImmediate 不能直接套浏览器图;
  • 事件循环并没有让同步代码自动变成并行,长任务和无限微任务仍会阻塞当前 agent。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS