在前端面试中,JavaScript 的事件循环(Event Loop)是一个高频考点,但很多面试者却对它一知半解。作为一名前端技术负责人,我经常遇到这样的情况:面试者能够使用 Promise 和 async/await,但却无法解释清楚它们的执行顺序;或者知道 setTimeout 是异步的,却说不清楚它为什么会有延迟。今天,我们就来彻底讲清楚 JavaScript 的事件循环,从基础概念到实际应用,层层递进,深入浅出。
1. 为什么需要事件循环?
JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。如果所有任务都是同步执行的,那么当一个任务耗时较长时(比如网络请求),整个程序就会被阻塞,用户体验会非常糟糕。
为了解决这个问题,JavaScript 引入了异步编程,而事件循环就是实现异步编程的核心机制。它允许 JavaScript 在执行同步任务的同时,处理异步任务(如定时器、网络请求等),从而避免阻塞。
2. 事件循环的核心概念
要理解事件循环,我们需要先了解以下几个核心概念:
2.1 调用栈(Call Stack)
调用栈是一个后进先出(LIFO)的数据结构,用于存储函数的调用信息。每当一个函数被调用时,它会被压入调用栈;当函数执行完毕时,它会从调用栈中弹出。
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
bar();
调用栈的执行过程:
bar()被调用,压入调用栈。bar()内部调用foo(),foo()被压入调用栈。foo()执行完毕,弹出调用栈。bar()继续执行,弹出调用栈。
2.2 宏任务队列(Macrotask Queue)
宏任务队列(也称为任务队列)用于处理一些异步操作,例如:
setTimeout和setInterval的回调- DOM 事件回调(如点击、滚动等)
I/O操作(如文件读写、网络请求)requestAnimationFrame(在浏览器中)script标签中的同步代码(整个脚本本身也是一个宏任务)
特点:
- 每次事件循环只会从宏任务队列中取出一个任务执行。
- 执行完一个宏任务后,会检查微任务队列并清空所有微任务。
2.3 微任务队列(Microtask Queue)
微任务队列用于处理更高优先级的异步操作,例如:
Promise的then、catch、finally回调MutationObserver回调queueMicrotask添加的任务
特点:
- 微任务队列的优先级高于宏任务队列。
- 在当前宏任务执行完毕后,事件循环会立即清空整个微任务队列中的所有任务。
- 如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会被添加到当前微任务队列中,并在本次循环中被执行。
3. 事件循环的执行顺序
事件循环的具体流程如下:
- 执行当前调用栈中的同步代码(这是一个宏任务)。
- 如果微任务队列中有任务,依次执行所有微任务,直到微任务队列为空。
- 执行一个宏任务(从宏任务队列中取出一个任务执行)。
- 重复上述步骤。
4. 代码示例分析
让我们通过一个具体的代码示例,来理解事件循环的执行顺序:
4.1 示例1
console.log('Start'); // 同步代码,直接执行
setTimeout(() => {
console.log('Timeout'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('Promise'); // 微任务
});
console.log('End'); // 同步代码,直接执行
最终输出:
Start
End
Promise
Timeout
解释:
- 同步代码
console.log('Start')进入调用栈中并执行。 - JavaScript的解释器会分配一个计时线程,计时结束后,会将回调函数放入宏任务队列当中。
- 微任务进入微任务队列当中。
- 同步代码
console.log('End')进入调用栈中并执行。 - 接着,事件循环检查微任务队列,按照先进先出的原则依次执行微任务队列当中的任务。
- 最后,事件循环从宏任务队列中取出
setTimeout的回调,执行console.log('Timeout')。
4.2 示例2
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1');
}).then(() => {
console.log('Promise 2');
});
输出:
Promise 1
Promise 2
Timeout
解释:
- 微任务队列中的任务会一次性全部执行完毕,然后再执行宏任务。
4.3 示例3
console.log('Start'); // 同步代码
setTimeout(() => {
console.log('Timeout 1'); // 宏任务 1
Promise.resolve().then(() => {
console.log('Promise 3'); // 微任务 3
});
}, 0);
Promise.resolve().then(() => {
console.log('Promise 1'); // 微任务 1
setTimeout(() => {
console.log('Timeout 2'); // 宏任务 2
}, 0);
});
Promise.resolve().then(() => {
console.log('Promise 2'); // 微任务 2
});
setTimeout(() => {
console.log('Timeout 3'); // 宏任务 3
}, 0);
console.log('End'); // 同步代码
输出:
Start
End
Promise 1
Promise 2
Timeout 1
Promise 3
Timeout 3
Timeout 2
解释:
- 代码从上往下,同步代码进入调用栈中并执行,输出Start,End。
- 宏任务进入宏任务队列,微任务进入微任务队列。
- 此时宏任务队列当中有两个任务,微任务队列当中也有两个任务。
- 按照队列优先级,事件循环优先检查微任务队列,按照先进先出的原则依次执行,输出Promise 1,产生的宏任务等到计时结束后会放入宏任务队列
- 输出Promise 2
- 微任务队列被清空,接下来执行宏任务队列当中的任务。
- 同步代码,输出Timeout 1,此时产生了一个新的微任务,进入微任务队列当中,紧接着会清空微任务队列
- 输出Promise 3
- 执行宏任务,依然是先进先出
- 输出Timeout3
- 输出Timeout2
第一次循环输出了Start、End、Promise 1、Promise 2
第二次循环输出了Timeout 1、Promise3
第三次循环输出了Timeout 3
第四次循环输出了TImeout 2
不难发现,事件循环的本质就是每一轮宏任务结束之后,开启新一轮宏任务的过程。
5. 总结
JavaScript 的事件循环是异步编程的核心机制,理解它对于掌握 JavaScript 的运行原理至关重要。通过本文的讲解,希望你能够清晰地理解以下内容:
1.调用栈:用于执行同步任务。
2.宏任务队列:处理低优先级的异步任务,每次事件循环执行一个宏任务。
3.微任务队列:处理高优先级的异步任务,在当前宏任务执行完毕后立即清空所有微任务。
4.事件循环:交替执行宏任务和微任务,确保异步代码的有序执行。
注意:微任务优先级高于宏任务这种说法是不严谨的。准确来讲,是微任务队列优先级高于宏任务队列。任务在队列中只能按先进先出的顺序执行,而根据W3C的规范,同一类任务只能进入同一个队列,不同类型的任务进入到不同队列。所以任务之间没有优先级的说法,任务队列存在优先级。
如果你对事件循环还有疑问,欢迎在评论区留言讨论!