JavaScript 浏览器的事件循环(Event Loop)机制
JavaScript 的事件循环(Event Loop)是理解 JS 异步编程的核心。它是浏览器(或 Node.js)协调同步代码和异步代码执行的一种机制。
由于 JavaScript 是一门单线程语言(意味着它同一时间只能做一件事),如果没有事件循环机制,执行耗时任务(如网络请求、定时器)就会导致页面卡死。事件循环正是 JS 实现非阻塞 I/O 的秘密武器。
下面我们从核心概念、运行机制到经典代码示例,把浏览器的事件循环彻底讲透。
一、 核心组件
要理解事件循环,首先需要认识浏览器环境中的几个核心组件:
- Call Stack(调用栈):
- JS 代码真正执行的地方。
- 遵循后进先出(LIFO)原则。
- 所有的同步代码都会先被推入调用栈执行。
- Web APIs(浏览器 API):
- 浏览器提供的工作线程(C++ 实现),独立于 JS 主线程。
- 例如:
DOM 操作、setTimeout/setInterval、AJAX/Fetch 网络请求。 - 当调用这些异步方法时,调用栈会把任务交给 Web APIs 处理,JS 主线程继续往下走。
- Task Queue(任务队列):
- 当 Web APIs 处理完异步任务后(比如定时器时间到了,或者网络请求拿到数据了),会把对应的回调函数推入任务队列。
- 遵循先进先出(FIFO)原则。
- 任务队列分为两种:宏任务队列和微任务队列。
二、 宏任务与微任务(核心考点)
异步任务被分为了两类,它们的优先级不同:
1. 宏任务(Macrotask / Task)
可以理解为每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
<script>整体代码(整体的主体同步代码)setTimeout/setInterval- UI 渲染(UI Rendering)
- I/O 操作、事件回调(如
click事件)
2. 微任务(Microtask)
可以理解为在当前宏任务执行结束后,立即要执行的任务。微任务的优先级高于下一个宏任务。
Promise.then() / .catch() / .finally()MutationObserver(监听 DOM 变化)queueMicrotask()- 注意:
async/await本质上是 Promise 的语法糖,await后面的代码会被包装成微任务。
三、 事件循环的运转流程(The Loop)
事件循环是一个不断检查和执行的死循环,具体步骤如下:
- 执行同步代码:从宏任务队列中取出第一个宏任务(通常是
<script>整体代码),放入调用栈中执行。 - 遇到异步任务:
- 如果是宏任务,交给 Web APIs 处理,完成后把回调放入宏任务队列。
- 如果是微任务,把回调放入微任务队列。
- 清空微任务队列:当前宏任务执行完毕(调用栈为空)时,事件循环会立即检查微任务队列。并把里面的微任务全部依次执行完。(如果在执行微任务的过程中又产生了新的微任务,也会在这一步一起执行完)。
- UI 渲染:如果需要,浏览器会在这时进行视图的更新渲染。
- 开启下一轮:去宏任务队列中取出一个宏任务,放入调用栈执行。
- 重复步骤 2 ~ 5。
💡 核心口诀:执行一个宏任务 -> 清空所有微任务 -> 渲染 -> 执行下一个宏任务。
四、 经典代码实战
基础示例
我们来看一道经典的面试题:
javascript
console.log('1. 同步代码开始');
setTimeout(() => {
console.log('2. 宏任务: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3. 微任务: Promise.then');
});
console.log('4. 同步代码结束');
执行过程分析:
- 打印
1. 同步代码开始。 - 遇到
setTimeout,交给 Web APIs,0ms 后将其回调放入宏任务队列。 - 遇到
Promise.resolve().then,将其回调放入微任务队列。 - 打印
4. 同步代码结束。 - 当前宏任务(整个 script)执行完毕,调用栈空了。
- 检查微任务队列,发现有 Promise 回调,推入调用栈执行,打印
3. 微任务: Promise.then。微任务队列清空。 - 检查宏任务队列,发现有 setTimeout 回调,推入调用栈执行,打印
2. 宏任务: setTimeout。
输出结果: 1 -> 4 -> 3 -> 2
进阶示例(包含 async/await 和嵌套)
javascript
console.log('script start');
async function async1() {
await async2();
// await 后面的代码等价于 Promise.then,是微任务
console.log('async1 end');
}
async function async2() {
// 这里的代码是同步执行的
console.log('async2 end');
}
async1();
setTimeout(function() {
console.log('setTimeout');
}, 0);
new Promise(resolve => {
console.log('Promise'); // Promise 构造函数里的代码是同步的
resolve();
}).then(function() {
console.log('promise1'); // 微任务
}).then(function() {
console.log('promise2'); // 微任务
});
console.log('script end');
执行过程深度剖析:
- 执行同步代码:打印
script start。 - 调用
async1(),async1内部调用async2(),打印async2 end。await后面的console.log('async1 end')被推入微任务队列(微任务A)。 - 遇到
setTimeout,回调推入宏任务队列。 new Promise构造函数立即执行,打印Promise。resolve()被调用,第一个.then被推入微任务队列(微任务B)。- 打印
script end。(至此,第一轮宏任务结束) - 清空微任务队列:
- 执行微任务A,打印
async1 end。 - 执行微任务B,打印
promise1。此时它返回了一个新的 Promise,导致第二个.then被推入微任务队列(微任务C)。 - 继续清空,执行微任务C,打印
promise2。
- 执行微任务A,打印
- 执行下一个宏任务:
- 取出
setTimeout回调,打印setTimeout。
- 取出
输出结果:
plaintext
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
五、 补充与注意事项
- Promise 构造函数是同步的:
new Promise(executor)中的executor函数是同步执行的,只有.then()/.catch()/.finally()里的回调才是微任务。 setTimeout(fn, 0)并非真正的 0 毫秒:根据 HTML5 标准,嵌套超过 5 层的定时器,最小延迟时间为 4ms。- 微任务的“霸权”:如果在执行微任务的过程中不断生成新的微任务,事件循环会一直停留在清空微任务队列的阶段,导致宏任务无法执行,UI 无法渲染,页面会卡死。
- Node.js 的事件循环:虽然概念相似,但 Node.js 的事件循环机制(基于 libuv)比浏览器复杂,分为了 Timers、Poll、Check 等多个阶段,并且 Node 独有
process.nextTick(优先级高于普通微任务)。(注意:Node 11 之后,两者在执行顺序的表现上已经基本拉平,都是执行完一个宏任务就清空微任务)。