JavaScript 是一种单线程语言,这意味着它一次只能执行一个任务。然而,浏览器环境(以及 Node.js 等运行时环境)提供了异步操作的能力,例如网络请求、定时器和用户交互。为了协调这些异步操作并确保 JavaScript 引擎不会阻塞主线程,浏览器引入了 事件循环 (Event Loop) 机制。
什么是事件循环 (Event Loop)?
事件循环是 JavaScript 运行时环境中的一个核心机制,它负责协调代码的执行、事件的处理以及 DOM 的渲染。 它可以被看作是一个永无止境的循环,不断地检查是否有任务需要执行,并将其推入 JavaScript 引擎的执行栈中。
事件循环的核心组件
理解事件循环需要了解几个关键组件:
-
调用栈 (Call Stack) :
- 这是一个后进先出 (LIFO) 的数据结构,用于存储正在执行的函数。
- 当 JavaScript 代码执行时,函数调用会被推入栈中,函数执行完毕后会从栈中弹出。
- JavaScript 引擎一次只能处理调用栈顶部的任务。
-
堆 (Heap) :
- 这是一个非结构化的内存区域,用于存储对象和变量。
-
Web APIs (浏览器提供的 API) :
-
这些是浏览器提供给 JavaScript 引擎的功能,用于处理异步任务,例如:
setTimeout()和setInterval()(定时器)fetch()和XMLHttpRequest(网络请求)addEventListener()(DOM 事件,如点击、键盘输入等)requestAnimationFrame()(动画帧请求)MutationObserver(DOM 变动观察)
-
当调用这些 API 时,它们会将相应的异步操作交给浏览器处理,而不是在 JavaScript 主线程中执行。
-
-
回调队列 (Callback Queues) :
- 当 Web API 完成其异步操作后,会将对应的回调函数放入一个或多个回调队列中等待执行。
- 这些队列通常分为两种主要类型:宏任务队列 (Macrotask Queue) 和 微任务队列 (Microtask Queue) 。
宏任务 (Macrotasks / Tasks)
宏任务是较大粒度的任务。当一个宏任务执行时,它会完整地运行直到结束。
常见的宏任务包括:
-
整个
script(初始执行的 JavaScript 代码) -
setTimeout()和setInterval()的回调 -
UI 渲染事件(如解析 HTML、生成 DOM)
-
I/O 操作(如网络请求完成后的回调)
-
用户交互事件(如
click,mousemove等) -
requestAnimationFrame(虽然它与渲染紧密相关,但在某些模型中被视为一种特殊的宏任务,或者在渲染阶段前被处理) 宏任务的特点: -
事件循环的每次迭代只会从宏任务队列中取出一个宏任务执行。
-
一个宏任务执行完毕后,浏览器会检查微任务队列。
微任务 (Microtasks)
微任务是更小粒度、优先级更高的任务。它们通常用于在当前宏任务结束之后、下一个宏任务开始之前,对应用程序状态进行更新。
常见的微任务包括:
Promise的回调 (.then(),.catch(),.finally())MutationObserver的回调queueMicrotask()
微任务的特点:
- 微任务队列的优先级高于宏任务队列。
- 在一个宏任务执行完毕后,事件循环会清空所有微任务队列中的任务,然后才会进入下一个宏任务的执行。
- 这意味着在同一个宏任务周期内,所有排队的微任务都会被执行。
执行顺序 (事件循环的完整机制)
事件循环的整个流程可以概括为以下步骤:
-
执行主线代码 (Initial Macrotask) :
- 当浏览器加载页面时,首先执行
<script>标签中的所有同步 JavaScript 代码。这些代码被视为第一个宏任务。 - 同步代码会立即推入调用栈并执行。
- 当浏览器加载页面时,首先执行
-
清空调用栈 (Call Stack) :
- 当所有同步代码执行完毕,调用栈变为空。
-
执行所有微任务 (Drain Microtask Queue) :
- 一旦调用栈为空,事件循环会立即检查微任务队列。
- 如果微任务队列中有任务,事件循环会按先进先出 (FIFO) 的顺序,将所有微任务逐个推入调用栈并执行,直到微任务队列为空。
- 在这个过程中,如果微任务又产生了新的微任务,新的微任务也会被添加到当前微任务队列的末尾,并在当前循环中被执行。
-
DOM 渲染 (Rendering) :
- 当微任务队列清空后,浏览器可能会进行渲染更新。
- 渲染通常发生在每个事件循环周期之后(即一个宏任务执行完毕,并且所有微任务也执行完毕之后)。
- 浏览器通常会以 60 帧每秒 (FPS) 的频率进行渲染,这意味着大约每 16.6 毫秒会尝试更新一次屏幕。
- 重要提示:在 JavaScript 执行一个宏任务期间,DOM 渲染不会发生。 只有当宏任务及其所有微任务都完成后,浏览器才有机会进行渲染。
-
执行下一个宏任务 (Next Macrotask) :
- 渲染完成后,事件循环会从宏任务队列中取出一个宏任务(如果有的话),将其推入调用栈并执行。
- 这个过程会重复步骤 2-5,形成一个持续的循环。
requestAnimationFrame的特殊性:
requestAnimationFrame(rAF) 是专门用于动画的 API。它的回调函数会在浏览器下一次重绘之前执行。 这使得它非常适合进行 DOM 操作和动画,因为它可以确保在渲染之前执行代码,从而避免不必要的重排和重绘,并与浏览器的渲染周期同步。 通常,rAF 的回调会在微任务执行之后、实际渲染之前被执行。
总结流程图 (简化版)
┌───────────────────────────┐
│ Call Stack │
└───────────────────────────┘
│
│ (同步代码执行)
▼
┌───────────────────────────┐
│ Web APIs │ (setTimeout, fetch, DOM Events, etc.)
└───────────────────────────┘
│
│ (异步操作完成,回调入队)
▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ Microtask Queue │ <───│ Promise Callbacks │
│ (Promise, MutationObserver)│ └───────────────────────────┘
└───────────────────────────┘
│
│ (微任务清空后)
▼
┌───────────────────────────┐
│ DOM Rendering │ (浏览器重绘)
└───────────────────────────┘
│
│ (渲染后)
▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ Macrotask Queue │ <───│ setTimeout Callbacks │
│ (setTimeout, UI Events, I/O)│ │ User Event Callbacks │
└───────────────────────────┘ └───────────────────────────┘
│
│ (取出一个宏任务)
▼
(回到 Call Stack)
示例
console.log('Script start'); // 1. 同步代码
setTimeout(function() {
console.log('setTimeout callback'); // 4. 宏任务
}, 0);
Promise.resolve().then(function() {
console.log('Promise callback 1'); // 3. 微任务
}).then(function() {
console.log('Promise callback 2'); // 3. 微任务 (新的微任务,在当前微任务批次中执行)
});
// 模拟一个 DOM 操作,它可能会触发渲染
const div = document.createElement('div');
div.textContent = 'Hello';
document.body.appendChild(div);
console.log('DOM operation'); // 2. 同步代码
requestAnimationFrame(function() {
console.log('requestAnimationFrame callback'); // 渲染前执行
});
console.log('Script end'); // 2. 同步代码
输出顺序分析:
-
Script start(同步代码,立即执行) -
DOM operation(同步代码,立即执行) -
Script end(同步代码,立即执行)- 此时,调用栈清空。
setTimeout的回调被放入宏任务队列。Promise的两个回调被放入微任务队列。requestAnimationFrame的回调被注册到渲染阶段。
-
Promise callback 1(微任务队列中的第一个任务,立即执行,因为调用栈已空) -
Promise callback 2(微任务队列中的第二个任务,紧接着Promise callback 1执行,因为微任务队列会一次性清空)- 此时,微任务队列清空。
-
requestAnimationFrame callback(在微任务清空后,渲染之前执行) -
(浏览器进行 DOM 渲染)
-
setTimeout callback(微任务和渲染完成后,事件循环从宏任务队列中取出下一个宏任务执行)
实际输出:
Script start
DOM operation
Script end
Promise callback 1
Promise callback 2
requestAnimationFrame callback
setTimeout callback
理解事件循环对于编写高性能、非阻塞的 JavaScript 代码至关重要,尤其是在处理复杂的 UI 交互和大量异步数据时。