JavaScript 事件循环
介绍
JavaScript是一种单线程语言,这意味着它一次只能执行一个操作。但在网页开发中,我们经常需要处理多个任务,比如用户交互、数据获取和计时器等。那么JavaScript如何在单线程环境下处理这些并发操作呢?答案就是事件循环(Event Loop)。
事件循环是JavaScript运行时环境(浏览器或Node.js)提供的一种机制,帮助处理异步操作,使JavaScript能够非阻塞地执行代码。理解事件循环对于编写高效、无阻塞的JavaScript代码至关重要。
JavaScript 的执行环境
在深入事件循环之前,我们需要了解JavaScript的执行环境包含以下几个关键部分:
- 调用栈(Call Stack):追踪当前正在执行的函数
- 任务队列(Task Queue):存放待执行的宏任务(macrotasks)
- 微任务队列(Microtask Queue):存放待执行的微任务(microtasks)
- 事件循环(Event Loop):协调这些组件,决定何时执行哪些代码
调用栈
调用栈是JavaScript引擎跟踪函数执行的机制。当我们调用一个函数时,它会被添加(推入)到栈顶;当函数执行完成时,它会从 栈中移除(弹出)。
让我们看一个简单的例子:
function multiply(a, b) {
return a * b;
}
function calculate() {
const result = multiply(5, 3);
console.log(result);
}
calculate();
执行过程如下:
calculate()
被推入调用栈calculate
内部调用multiply(5, 3)
,所以multiply
被推入栈顶multiply
计算完成返回结果,从栈中弹出calculate
继续执行,调用console.log()
console.log()
执行完成后弹出栈calculate
执行完成,从栈中弹出
宏任务(Macrotasks)
宏任务代表了大部分的异步操作,包括:
setTimeout
setInterval
setImmediate
(Node.js环境)- I/O操作
- UI渲染
- 事件回调
这些任务会被添加到任务队列中,等待事件循环将它们移到调用栈中执行。
console.log('开始');
setTimeout(() => {
console.log('定时器回调');
}, 0);
console.log('结束');
输出结果:
开始
结束
定时器回调
虽然定时器设置为0毫秒,但它仍然作为宏任务被添加到任务队列,要等到当前执行栈清空后才会执行。
微任务(Microtasks)
微任务是另一种类型的异步任务,它们比宏任务具有更高的优先级。每当一个宏任务执行完毕,JavaScript引擎会清空整个微任务队列,然后再执行下一个宏任务。
常见的微任务包括:
- Promise回调 (
.then()
,.catch()
,.finally()
) queueMicrotask()
MutationObserver
process.nextTick
(Node.js环境)
让我们看一个包含宏任务和微任务的例子:
console.log('1. 脚本开始');
setTimeout(() => {
console.log('2. 宏任务 - 定时器回调');
}, 0);
Promise.resolve()
.then(() => {
console.log('3. 微任务 - Promise回调');
});
console.log('4. 脚本结束');
输出结果:
1. 脚本开始
4. 脚本结束
3. 微任务 - Promise回调
2. 宏任务 - 定时器回调
备注
注意Promise回调在setTimeout回调之前执行,这是因为微任务队列会在每个宏任务执行完成后立即清空。
事件循环详解
现在让我们更深入地了解事件循环的工作流程:
- 执行全局JavaScript代码(第一个宏任务)
- 检查调用栈是否为空
- 如果调用栈为空,检查微任务队列
- 执行所有微任务直到微任务队列为空
- 执行下一个宏任务(从任务队列中取出)
- 回到步骤2,重复循环
复杂示例
让我们通过一个更复杂的例子来理解事件循环:
console.log('1. 脚本开始');
setTimeout(() => {
console.log('2. 第一个宏任务(setTimeout)');
Promise.resolve().then(() => {
console.log('3. 第一个宏任务中的微任务');
});
}, 0);
Promise.resolve().then(() => {
console.log('4. 第一个微任务');
setTimeout(() => {
console.log('5. 微任务中的宏任务');
}, 0);
});
Promise.resolve().then(() => {
console.log('6. 第二个微任务');
});
console.log('7. 脚本结束');
输出结果:
1. 脚本开始
7. 脚本结束
4. 第一个微任务
6. 第二个微任务
2. 第一个宏任务(setTimeout)
3. 第一个宏任务中的微任务
5. 微任务中的宏任务
让我们分析这个执行过程:
- 同步执行脚本,输出"1. 脚本开始"
- 遇到
setTimeout
,将其回调函数加入宏任务队列 - 遇到第一个
Promise.then
,将其回调函数加入微任务队列 - 遇到第二个
Promise.then
,将其回调函数加入微任务队列 - 输出"7. 脚本结束",同步脚本执行完毕(第一个宏任务完成)
- 检查微任务队列,执行第一个微任务,输出"4. 第一个微任务",并将新的
setTimeout
加入宏任务队列 - 继续执行第二个微任务,输出"6. 第二个微任务"
- 微任务队列清空,取出宏任务队列中的第一个任务(第一个setTimeout回调)
- 执行该宏任务,输出"2. 第一个宏任务(setTimeout)",并将新的Promise回调加入微任务队列
- 该宏任务执行完毕,检查微任务队列,执行微任务,输出"3. 第一个宏任务中的微任务"
- 微任务队列清空,取出宏任务队列中的下一个任务(第二个setTimeout回调)
- 执行该宏任务,输出"5. 微任务中的宏任务"