深入浅出:JavaScript 事件循环究竟是什么?

419 阅读6分钟

在前端面试中,JavaScript 的事件循环(Event Loop)是一个高频考点,但很多面试者却对它一知半解。作为一名前端技术负责人,我经常遇到这样的情况:面试者能够使用 Promiseasync/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();

调用栈的执行过程

  1. bar() 被调用,压入调用栈。
  2. bar() 内部调用 foo()foo() 被压入调用栈。
  3. foo() 执行完毕,弹出调用栈。
  4. bar() 继续执行,弹出调用栈。

2.2 宏任务队列(Macrotask Queue)

宏任务队列(也称为任务队列)用于处理一些异步操作,例如:

  • setTimeout 和 setInterval 的回调
  • DOM 事件回调(如点击、滚动等)
  • I/O 操作(如文件读写、网络请求)
  • requestAnimationFrame(在浏览器中)
  • script 标签中的同步代码(整个脚本本身也是一个宏任务)

特点:

  • 每次事件循环只会从宏任务队列中取出一个任务执行。
  • 执行完一个宏任务后,会检查微任务队列并清空所有微任务。

2.3 微任务队列(Microtask Queue)

微任务队列用于处理更高优先级的异步操作,例如:

  • Promise 的 thencatchfinally 回调
  • MutationObserver 回调
  • queueMicrotask 添加的任务

特点:

  • 微任务队列的优先级高于宏任务队列。
  • 在当前宏任务执行完毕后,事件循环会立即清空整个微任务队列中的所有任务。
  • 如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会被添加到当前微任务队列中,并在本次循环中被执行。

3. 事件循环的执行顺序

事件循环的具体流程如下:

  1. 执行当前调用栈中的同步代码(这是一个宏任务)。
  2. 如果微任务队列中有任务,依次执行所有微任务,直到微任务队列为空。
  3. 执行一个宏任务(从宏任务队列中取出一个任务执行)。
  4. 重复上述步骤。

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

解释:

  1. 同步代码 console.log('Start')  进入调用栈中并执行。
  2. JavaScript的解释器会分配一个计时线程,计时结束后,会将回调函数放入宏任务队列当中。
  3. 微任务进入微任务队列当中。
  4. 同步代码 console.log('End')  进入调用栈中并执行。
  5. 接着,事件循环检查微任务队列,按照先进先出的原则依次执行微任务队列当中的任务。
  6. 最后,事件循环从宏任务队列中取出 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的规范,同一类任务只能进入同一个队列,不同类型的任务进入到不同队列。所以任务之间没有优先级的说法,任务队列存在优先级。


如果你对事件循环还有疑问,欢迎在评论区留言讨论!