前端基础进阶(十四):深入核心,详解事件循环机制
Category(分类): JavaScript Status: 已整理
JavaScript 的学习零散而庞杂,很多时候我们学到了一些东西,却没办法感受到进步。事件循环(Event Loop)是把同步代码、异步回调、Promise、浏览器渲染和 Node.js I/O 串起来的一条重要线索。
本文保留原文的面试题、浏览器/Node.js 对照和手写队列示例,但把“宏任务严格 FIFO”“每个宏任务后必然渲染”“Node.js 与浏览器完全相同”等过于绝对的说法改成当前规范和实现都更准确的表述。
一、先明确讨论范围
学习事件循环之前,最好已经了解:
- 执行上下文和函数调用栈
- 队列与迭代器
- Promise 和 Promise reaction
- 浏览器 task、microtask 的基本概念
JavaScript 语言规范本身定义 Promise reaction 等 job,并通过宿主钩子把 job 交给浏览器或 Node.js。HTML Standard 的事件循环和 Node.js 的 libuv 事件循环不是同一套规范,下面会分别说明。
“JavaScript 单线程”通常指一个 JavaScript agent 的脚本执行不会被多个脚本线程同时交错执行;浏览器可以有多个 agent、Worker 和内部线程。Web Worker 有自己的事件循环,不能直接操作页面 DOM。
二、浏览器中的 task 与 microtask
1. 一个适合学习的近似流程
一次浏览器事件循环可以近似理解为:
- 选择一个可运行的 task 并执行到结束。
- 执行 microtask checkpoint,直到微任务队列清空;微任务中新增的微任务也会继续执行。
- 在满足条件且存在渲染机会时,用户代理运行更新渲染相关步骤,例如
requestAnimationFrame回调;是否绘制、何时合成并不是脚本可以强制保证的。 - 再选择后续 task。
这只是教学近似。HTML Standard 定义了多个 task source 和 task queue,同一 task source 通常保持顺序,但不同来源之间不是一个由开发者可见的全局 FIFO 队列。
2. 常见任务来源
常见 task 包括:
- 整段 classic script 或 module script 的启动执行
setTimeout、setInterval等计时器回调- 用户交互事件回调
- 网络、文件或消息完成后的回调
postMessage等消息任务
常见 microtask 包括:
- Promise reaction(
then、catch、finally) queueMicrotask注册的回调- MutationObserver 的通知回调
fetch 的网络完成不会让回调直接插进当前代码,而是通过 Promise reaction 进入微任务流程。requestAnimationFrame 是渲染更新阶段的回调,不是普通 timer,也不是“下一轮事件循环”的同义词。
3. 基础示例
console.log('global 1')
setTimeout(() => {
console.log('timeout 1')
}, 0)
const promise = new Promise((resolve) => {
console.log('promise 1')
resolve()
})
promise.then(() => {
console.log('then 1')
})
console.log('global 2')
在常见浏览器环境中,输出是:
global 1
promise 1
global 2
then 1
timeout 1
Promise 构造器的 executor 是同步调用的,then 的 reaction 会在当前 script task 结束后运行,计时器回调属于后续 task。
4. 微任务会清空到稳定
console.log('start')
queueMicrotask(() => {
console.log('microtask 1')
queueMicrotask(() => {
console.log('microtask 2')
})
})
setTimeout(() => {
console.log('timer')
}, 0)
console.log('end')
输出为:
start
end
microtask 1
microtask 2
timer
如果微任务不断产生新的微任务,就会延迟计时器、用户交互和渲染,这种现象通常称为 microtask starvation:
function loop() {
queueMicrotask(loop)
}
// 不要执行:它会让后续 task 很难获得机会
// loop()
三、浏览器事件循环练习题
console.log('start')
setTimeout(() => {
console.log('timeout 1')
Promise.resolve().then(() => {
console.log('promise in timeout')
})
}, 0)
Promise.resolve().then(() => {
console.log('promise 1')
setTimeout(() => {
console.log('timeout 2')
}, 0)
})
console.log('end')
常见输出为:
start
end
promise 1
timeout 1
promise in timeout
timeout 2
分析方法是:整段脚本先执行,脚本中的同步输出先完成;脚本结束后处理 promise 1;随后选择一个计时器 task。计时器 task 内排入的 Promise reaction 会在该 task 结束后的 microtask checkpoint 中执行,所以 promise in timeout 早于后面等待中的 timeout 2。
不要把这个例子推广为“所有 task source 永远这样排列”。在同一浏览器、同一可见性和相近负载下,计时器的相对顺序通常容易观察;规范层面仍然不保证所有来源共享一个固定的全局队列。
四、渲染与 Event Loop 的关系
原文把“执行所有微任务后执行 UI 线程渲染”写成了固定步骤,这适合做入门图示,但不应当作浏览器的强保证:
- 浏览器只有在存在渲染机会、页面状态允许且确实需要更新时才可能更新渲染。
requestAnimationFrame回调属于更新渲染相关流程,回调时间戳表示该帧的时间基准,不是固定的 16.67ms。- 隐藏页面、后台标签页、低刷新率、CPU 负载和省电策略都会影响调度。
- 样式计算、布局、绘制、光栅化和合成是实现层面的简化管线;读取布局信息可能触发同步布局,但不能把所有浏览器都描述成同一条固定流水线。
动画应使用 requestAnimationFrame:
const box = document.querySelector('.box')
let start
function frame(timestamp) {
start ??= timestamp
const elapsed = timestamp - start
box.style.transform = `translateX(${elapsed / 2}px)`
requestAnimationFrame(frame)
}
requestAnimationFrame(frame)
不要写成“每个宏任务之后一定绘制一帧”,也不要用 setTimeout(..., 16) 代替帧调度。
五、Node.js 中的事件循环
Node.js 使用 libuv,并把事件循环划分为多个阶段。常见的阶段包括:
- timers:处理到期的
setTimeout/setInterval回调(当前 Node 版本的具体时机应以官方文档为准)。 - pending callbacks:处理某些系统操作推迟的回调。
- idle、prepare:Node/libuv 内部阶段。
- poll:处理 I/O 回调,并在合适时等待新的 I/O。
- check:处理
setImmediate回调。 - close callbacks:处理诸如 socket
close事件的回调。
Node 的每个版本、操作系统和代码所在阶段都会影响计时器与 setImmediate 的相对顺序。浏览器没有 Node 的这些阶段,也没有 process.nextTick。
1. process.nextTick 与 Promise
process.nextTick() 使用 Node 特有的 next tick queue。它会在当前 JavaScript 操作完成后尽快处理,并且可能先于普通 Promise microtask;递归使用会阻塞 I/O 和其他任务:
console.log('script')
process.nextTick(() => {
console.log('nextTick')
})
Promise.resolve().then(() => {
console.log('promise')
})
setImmediate(() => {
console.log('immediate')
})
通常会看到:
script
nextTick
promise
immediate
这是 Node 运行时的常见顺序,不是浏览器的通用规则。Node 官方建议不要递归滥用 process.nextTick,如果需要把工作排到 check 阶段,可以考虑 setImmediate。
2. setTimeout 与 setImmediate
下面的顶层示例不要写死唯一输出:
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
在主模块顶层,两者谁先执行可能受到启动时机和系统调度影响。放在 I/O 回调中通常更容易观察到 setImmediate 先于新注册的 setTimeout(0):
const fs = require('node:fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
Node.js 20 及之后的 libuv 计时器阶段细节发生过调整,因此文章中的旧版流程图和 Node 10 输出只能作为历史背景,不能当成跨版本契约。
六、Node.js 的异步、非阻塞 I/O
“异步 I/O”和“非阻塞 I/O”不是完全相同的词:
- 阻塞/非阻塞主要描述调用与操作系统内核交互时是否等待。
- 异步 API描述应用如何在操作完成后收到通知。
- Node.js 让 JavaScript 回调在事件循环线程中执行,I/O 可能由操作系统异步机制或 libuv 线程池完成。
Node.js 的文件系统异步 API 可能使用线程池;网络 I/O 会结合操作系统事件通知机制。不能笼统地说“所有异步 I/O 都由一个线程池完成”,也不能说“Node 只有一个线程”。JavaScript 回调通常在主 Node 线程执行,Worker threads 和 libuv 工作线程是另外的执行资源。
const fs = require('node:fs')
fs.readFile('./test.txt', 'utf8', (error, data) => {
if (error) {
console.error(error)
return
}
console.log(data)
})
console.log('readFile 已发起,当前脚本可以继续执行')
可以把流程概括为:Node API 注册请求 → libuv/操作系统或线程池处理 I/O → 完成事件进入事件循环可处理的队列 → JavaScript 回调在合适阶段执行。具体内部对象名称、系统 API 和线程池策略是实现细节,不能当成 ECMAScript 语义。
七、用数组模拟任务队列
下面的代码只是帮助理解“注册、排队、消费”的教学模型,并不是真正的浏览器或 Node.js 事件循环:
const tasks = []
function addTask(task) {
tasks.push(task)
}
function flush() {
while (tasks.length > 0) {
const task = tasks.shift()
task()
}
}
addTask(() => console.log('task 1'))
addTask(() => console.log('task 2'))
setTimeout(flush, 0)
如果希望按名称分发,可以使用 Map:
const handlers = new Map()
function subscribe(name, handler) {
const list = handlers.get(name) ?? []
list.push(handler)
handlers.set(name, list)
}
function publish(name, ...args) {
for (const handler of handlers.get(name) ?? []) {
handler(...args)
}
}
subscribe('demo', (value) => console.log(value))
publish('demo', 42)
这个发布-订阅示例没有处理一次性监听、错误隔离、监听器删除、递归发布和内存泄漏。生产代码可以使用平台提供的 EventTarget、Node.js EventEmitter 或成熟的状态管理方案。
八、为什么 Promise 使用 microtask
Promise executor 是同步执行的,但 then/catch/finally 注册的 reaction 不会在调用方法的瞬间同步执行。把 reaction 放入 microtask,可以同时满足:
- 不阻塞当前同步代码。
- 比等待一个普通 task 更快地处理已经完成的 Promise。
- 保持 Promise 链的异步一致性:即使 Promise 已经 fulfilled,后续 handler 也不会同步插入当前调用栈。
const promise = Promise.resolve('value')
promise.then((value) => {
console.log(value)
})
console.log('after then')
输出为:
after then
value
ECMAScript 只定义 Promise reaction job 的语义,具体 job queue 如何接入浏览器或 Node,是宿主负责的部分。
九、常见错误结论
- 错误:宏任务只有一个严格 FIFO 队列。修正:HTML 有多个 task source/queue,宿主选择任务;同一来源的顺序和跨来源的选择要区分。
- 错误:每个宏任务后一定渲染。修正:浏览器有 rendering opportunity,用户代理可以跳过或合并更新。
- 错误:
Promise本身创建了线程。修正:Promise 是状态和异步 reaction 抽象,不创建 JavaScript 线程。 - 错误:Node 和浏览器的微任务、定时器顺序完全相同。修正:Node 有 next tick queue、libuv 阶段和
setImmediate;版本变化也会影响可观察顺序。 - 错误:
setTimeout(fn, 0)立即执行。修正:它只是注册一个不早于约定延迟的后续 task。
总结
理解事件循环时,先确定宿主,再确定当前回调来自哪个任务来源:
- 同步代码先在当前执行栈执行。
- Promise reaction 和
queueMicrotask在 microtask checkpoint 中清空。 - 浏览器渲染是有条件的更新机会,不是每轮固定动作。
- Node.js 还要考虑 next tick queue、libuv 阶段、I/O 和
setImmediate。 - 任何涉及计时器、渲染或 Node 版本的输出,都应注明运行环境,必要时实际运行验证。
作者:这波能反杀 原文链接:https://www.jianshu.com/p/12b9f73c5a4f/ 来源:简书。原文著作权归作者所有;转载或公开发布时请遵守原作者的授权和署名要求。
参考资料: