【前端基础】事件循环 详解
文章目录
- 一、JavaScript 的单线程特性
- 二、事件循环的核心组成
- 三、事件循环的运作流程
- 四、宏任务 (Macrotask) vs 微任务 (Microtask)
- 五、代码示例解析
- 六、浏览器环境中的渲染时机
- 七、Node.js 环境中的事件循环
- 八、为什么理解事件循环很重要?
事件循环是 JavaScript 实现异步非阻塞 I/O 的核心机制,也是理解 JavaScript 代码执行顺序,特别是涉及
setTimeout
,
Promise
,
async/await
等异步操作时至关重要的概念。

一、JavaScript 的单线程特性
首先,我们需要明确 JavaScript 是一门单线程语言。这意味着在任意特定时刻,JavaScript 引擎只能执行一个任务。所有任务都需要排队,一个接一个地处理。
优点:
- 避免了多线程环境下复杂的并发控制问题(如竞态条件、死锁等)。
- 实现简单。
缺点:
- 如果一个任务执行时间过长(例如,复杂的计算或长时间的网络请求),后续的所有任务都必须等待,导致程序阻塞,用户界面卡顿甚至无响应。这就是所谓的“阻塞 (blocking)”。
为了解决单线程带来的阻塞问题,同时又能处理耗时的操作(如 I/O 操作、定时器、用户交互等),JavaScript 引入了异步编程模型和事件循环机制。
二、事件循环的核心组成
事件循环模型主要由以下几个关键部分组成,尤其是在浏览器环境中:
-
调用栈 (Call Stack / Execution Stack):
- 一个后进先出 (LIFO) 的数据结构,用于存储所有正在执行的函数调用的上下文。
- 当一个函数被调用时,它的帧 (frame) 会被推入调用栈。
- 当函数执行完毕返回时,它的帧会从调用栈中弹出。
- 所有同步代码都在调用栈中执行。
-
堆 (Heap):
- 一块内存区域,用于存储对象、数组等复杂数据类型。与事件循环的直接关系不大,但与 JavaScript 的内存管理相关。
-
宿主环境提供的 API (Web APIs / Node.js APIs):
- 这些 API 由 JavaScript 的宿主环境(浏览器或 Node.js)提供,它们不是 JavaScript 引擎的一部分。
- 例如:
- 浏览器环境:
DOM API
(如事件监听),setTimeout()
,setInterval()
,XMLHttpRequest
,Workspace()
,requestAnimationFrame()
等。 - Node.js 环境:
fs
(文件系统),http
(网络),child_process
,timers
模块等。
- 浏览器环境:
- 这些 API 允许我们发起异步操作。当调用这些 API 时,操作会在宿主环境的后台线程中处理,而不会阻塞 JavaScript 主线程。
-
任务队列 (Task Queue / Callback Queue):
事件循环中有两种主要的任务队列:- 宏任务队列 (Macrotask Queue / Task Queue):
- 用于存放宏任务 (Macrotask) 的回调函数。
- 常见的宏任务来源:
setTimeout()
,setInterval()
的回调setImmediate()
(Node.js 环境)- I/O 操作的回调 (如文件读写、网络请求完成)
- UI 交互事件的回调 (如点击、键盘输入、滚动等)
- 脚本 (
<script>
) 本身的执行 (可以看作是一个初始的宏任务) - UI 渲染 (浏览器环境下,通常在处理完微任务后,在两次宏任务之间或特定时机进行)
- 微任务队列 (Microtask Queue):
- 用于存放微任务 (Microtask) 的回调函数。
- 微任务通常具有更高的优先级,会在当前宏任务执行完毕后、下一个宏任务开始前立即执行。
- 常见的微任务来源:
Promise.then()
,Promise.catch()
,Promise.finally()
的回调async/await
中await
关键字之后的代码 (实际上是Promise
的封装)MutationObserver
的回调queueMicrotask()
APIprocess.nextTick()
(Node.js 环境,优先级甚至高于其他微任务)
- 宏任务队列 (Macrotask Queue / Task Queue):
三、事件循环的运作流程
事件循环是一个持续不断的过程,它的基本工作流程如下:
-
执行同步代码:
- 首先,JavaScript 引擎会执行全局的同步代码(通常是
<script>
标签中的代码,这可以看作是第一个宏任务)。 - 所有同步函数调用都会被压入调用栈并依次执行。
- 首先,JavaScript 引擎会执行全局的同步代码(通常是
-
遇到异步 API 调用:
- 当遇到异步 API 调用(如
setTimeout
,Workspace
或Promise
的创建),JavaScript 引擎会将这些操作交给相应的宿主环境 API 处理。 - 宿主环境 API 会在后台处理这些异步任务。
- 当异步任务完成(例如定时器到期、数据获取成功/失败、Promise 状态改变),宿主环境会将相应的回调函数放入对应的任务队列中(宏任务队列或微任务队列)。
- 当遇到异步 API 调用(如
-
事件循环的监控:
- 事件循环会持续不断地检查调用栈是否为空。
-
处理微任务:
- 一旦调用栈为空(即当前宏任务中的所有同步代码执行完毕),事件循环会立即检查微任务队列。
- 如果微任务队列不为空,事件循环会按顺序执行队列中所有的微任务,直到微任务队列变空为止。
- 重要:如果在执行微任务的过程中,又产生了新的微任务,这些新的微任务也会被添加到微任务队列的末尾,并在当前轮次的微任务处理中被执行完毕。微任务队列必须在下一个宏任务开始前被完全清空。
-
处理宏任务:
- 当调用栈为空且微任务队列也为空之后,事件循环会检查宏任务队列。
- 如果宏任务队列不为空,事件循环会取出一个最早进入队列的宏任务,将其回调函数压入调用栈执行。
- 这个宏任务的执行过程与步骤 1 类似,它可能包含同步代码和新的异步 API 调用。
-
重复循环:
- 当这个新的宏任务执行完毕(调用栈再次变空),事件循环会再次执行步骤 4(检查并清空微任务队列),然后执行步骤 5(处理下一个宏任务),如此往复,形成一个持续的循环。
简单概括一轮事件循环 (Tick):
一个宏任务 -> 执行该宏任务中的所有同步代码 -> 清空所有微任务 -> (可能会进行UI渲染) -> 下一个宏任务…
四、宏任务 (Macrotask) vs 微任务 (Microtask)
理解宏任务和微任务的区别是掌握事件循环的关键:
- 执行时机:
- 微任务在当前宏任务执行结束后、下一个宏任务开始前立即执行。
- 宏任务则需要等待前面的宏任务以及所有微任务都执行完毕后,才会按队列顺序执行。
- 优先级:微任务的优先级高于宏任务。
- 队列特性:
- 通常一次事件循环只会执行一个宏任务(从宏任务队列中取一个)。
- 但会执行所有当前可用的微任务,直到微任务队列为空。
一个形象的比喻:
- 宏任务队列:像是银行的排队叫号系统,一次处理一个客户(宏任务)。
- 微任务队列:像是银行的 VIP 通道或紧急业务窗口。当一个普通客户(宏任务)办理完业务后,银行会立即处理所有 VIP 通道和紧急业务窗口的客户(微任务),直到这些客户都处理完毕,才会叫下一个普通号。
五、代码示例解析
让我们通过一个经典的例子来理解事件循环:
console.log('Script start'); // 1. 同步代码setTimeout(function() { // 注册宏任务 M1console.log('setTimeout callback'); // M1.1
}, 0);Promise.resolve().then(function() { // 注册微任务 m1console.log('Promise.then callback 1'); // m1.1
}).then(function() { // 注册微任务 m2 (由 m1 返回的 Promise 产生)console.log('Promise.then callback 2'); // m2.1
});console.log('Script end'); // 2. 同步代码
执行顺序分析:
-
全局同步代码执行 (第一个宏任务的一部分):
console.log('Script start');
输出:Script start
- 遇到
setTimeout
,其回调函数被注册为一个宏任务 (M1),放入宏任务队列。 Promise.resolve()
创建一个已解决的 Promise,其第一个.then()
的回调函数被注册为一个微任务 (m1),放入微任务队列。console.log('Script end');
输出:Script end
- 调用栈变空。
-
处理微任务队列:
- 事件循环检查微任务队列,发现不为空。
- 执行微任务 m1:
console.log('Promise.then callback 1');
输出:Promise.then callback 1
- m1 的
.then()
返回一个新的 Promise,其回调函数(打印 “Promise.then callback 2”)被注册为新的微任务 (m2),放入微任务队列末尾。 - 微任务队列现在是
[m2]
。事件循环继续处理微任务。 - 执行微任务 m2:
console.log('Promise.then callback 2');
输出:Promise.then callback 2
- 微任务队列变空。
-
处理宏任务队列 (下一轮事件循环):
- 事件循环检查宏任务队列,发现 M1。
- 取出 M1,将其回调函数压入调用栈执行。
- 执行 M1 的回调:
console.log('setTimeout callback');
输出:setTimeout callback
- 调用栈变空。
- 再次检查微任务队列(此时为空)。
最终输出顺序:
Script start
Script end
Promise.then callback 1
Promise.then callback 2
setTimeout callback
六、浏览器环境中的渲染时机
在浏览器中,页面的渲染更新通常也作为事件循环的一部分。浏览器一般会尝试在每秒60帧(约16.7ms一次)的频率更新页面。
- 渲染操作通常被认为是宏任务(或者在宏任务之间进行)。
- 重要的是,所有微任务会在下一次渲染之前执行完毕。这意味着,如果微任务执行时间过长,或者不断有新的微任务被添加到队列中,可能会阻塞或延迟页面的渲染,导致用户感觉卡顿。
requestAnimationFrame()
是一个特殊的 API,它的回调会在浏览器下一次重绘之前执行,非常适合用来执行动画更新。它本身可以被看作是与渲染紧密相关的另一种类型的任务。
七、Node.js 环境中的事件循环
Node.js 的事件循环机制与浏览器类似,但也有其自身的特点和更复杂的阶段划分。Node.js 事件循环的主要阶段包括:
- timers (定时器):执行
setTimeout()
和setInterval()
的回调。 - pending callbacks (待定回调):执行延迟到下一个循环迭代的 I/O 回调。
- idle, prepare (仅内部使用)
- poll (轮询):检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器回调和
setImmediate()
)。Node.js 在这里可能会阻塞,等待新的连接、数据等。 - check (检查):执行
setImmediate()
的回调。 - close callbacks (关闭回调):例如
socket.on('close', ...)
。
在 Node.js 中,process.nextTick()
的回调并不完全属于上述阶段,它们会在当前操作完成后、事件循环继续到下一个阶段之前立即执行,优先级非常高,甚至高于其他微任务。Promise 的回调也是微任务,在每个阶段完成后、进入下一个阶段前,以及 process.nextTick()
队列清空后执行。
总的来说,Node.js 的事件循环更为复杂,但“宏任务-微任务”的基本处理逻辑(即每个宏任务阶段完成后清空微任务队列)是相似的。
八、为什么理解事件循环很重要?
- 编写高性能代码:避免长时间运行的同步代码阻塞主线程,合理利用异步操作提升应用响应性。
- 调试异步问题:理解代码的实际执行顺序,帮助定位和解决异步相关的 bug。
- 掌握异步模式:更好地使用
Promise
,async/await
等现代异步编程工具。 - 避免常见陷阱:例如,过度依赖
setTimeout(fn, 0)
的执行时机,或者不理解微任务可能导致的渲染延迟。
事件循环是 JavaScript 异步编程的基石,深刻理解它能够让你更自如地驾驭 JavaScript 的异步特性,编写出更健壮、更高效的应用程序。