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 事件循环模型,常用的简化顺序如下:
- 取一个可运行的 task,例如初始脚本、计时器回调或用户事件回调;
- 执行该 task,直到 JavaScript 调用栈清空;
- 执行 microtask checkpoint,持续清空微任务队列;
- 浏览器在合适的时机更新渲染,可能执行
requestAnimationFrame回调; - 继续选择下一个 task。
这是便于开发者理解的模型,不代表所有任务来源共享一个简单 FIFO 队列,也不代表每次循环都必须绘制。渲染由浏览器根据刷新率、页面是否可见、是否有渲染更新等条件决定。
3.1 常见 task 来源
浏览器中常见的 task 来源包括:
<script>中启动的一段脚本;- 用户交互事件回调;
setTimeout、setInterval到期后的回调;MessageChannel、postMessage等消息事件;- 网络或其他宿主 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 1、microtask 2、timer。如果无限添加微任务,就会阻塞后续 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)
同步的 a、b、c 会先完成,随后才处理计时器 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
原因:
- 初始脚本 task 开始,注册定时器;
Promise构造器的 executor 是同步执行的,打印2;then回调被安排为微任务;- 继续打印
4,初始脚本 task 完成; - 清空微任务队列,打印
3; - 之后才有机会执行定时器 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 的相对阶段细节,因此涉及 setTimeout 和 setImmediate 的边界示例应注明 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
setImmediate 与 setTimeout(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 阶段,因此常见输出是 immediate 再 timeout;在主模块直接安排二者时,顺序可能受运行时和系统调度影响,不能写死。
8.2 浏览器和 Node.js 的共同点与差异
共同点:
- 同一个 JavaScript 执行 agent 中,当前同步代码运行到完成;
- 异步 API 完成后,回调不会插入正在执行的同步函数;
- Promise reaction 不会在调用
then的同一同步位置直接执行。
差异:
- 浏览器由 HTML 标准定义 task、microtask、渲染和用户交互协作;
- Node.js 由 Node API、V8、libuv 和操作系统协作,存在 timers、poll、check 等阶段;
- 浏览器常用
queueMicrotask、MutationObserver、requestAnimationFrame;Node.js 还提供process.nextTick、setImmediate; - “宏任务/微任务”是便于沟通的分类,不是可以忽略宿主差异的统一规范术语。
九、事件循环调试建议
- 先标记同步代码、task、microtask 和渲染相关回调,不要直接背“宏任务大于微任务”。
- 明确运行环境:浏览器经典脚本、ES module、Node.js 主模块、Node.js I/O 回调的顺序可能不同。
- 不要把
setTimeout(fn, 0)当成立即执行或精确 0ms 执行。 - 不要在微任务或
process.nextTick中无限递归;必要时把工作拆成多个 task 或让出主线程。 - 使用浏览器 DevTools Performance、Node.js trace events 或小型可重复脚本验证时序,不要只依据截图。
- 网络请求的完成顺序、用户事件到达顺序和多个任务源的选择还会受到真实环境影响,业务代码应设计为不依赖偶然顺序。
十、历史图示与来源
原文事件循环图已下载到本地:

图片来源:原始图示。图中“宏任务大于微任务”的排列表达仅是入门简化,本文以浏览器/Node.js 宿主差异说明为准。
十一、总结
- 调用栈记录当前执行上下文,任务队列记录等待调度的工作,二者不是同一个概念;
- 同一个 task/job 运行到完成,微任务通常在 task 后被清空;
- 浏览器可能在合适时机更新渲染,但不是每次循环都必然绘制;
- Promise reaction、
queueMicrotask和MutationObserver属于常见微任务来源; <script>、事件、计时器等通常作为浏览器 task 开始执行;- Node.js 事件循环有 libuv 阶段,
process.nextTick、Promise 和setImmediate不能直接套浏览器图; - 事件循环并没有让同步代码自动变成并行,长任务和无限微任务仍会阻塞当前 agent。
参考资料
- MDN:JavaScript execution model
- MDN:Event loop
- MDN:Using microtasks in JavaScript with queueMicrotask()
- WHATWG HTML:Event loops
- Node.js:The Node.js event loop
- 原文曾提供 CodePen 演示,但当前站点访问受限;本文代码已整理为可独立阅读的示例。