短话长说系列之 —— 浏览器的事件循环机制

111 阅读4分钟

在上一篇文章中,我们说到了JavaScript的单线程(如果还不清楚JavaScript为什么是单线程的,这里有传送门),单线程模型除了简化了复杂性和提高了易用性以外,也存在一些弊端。它的一个主要问题就是容易出现阻塞,即耗时操作(如密集型计算,I/O操作等)会阻塞后续代码的执行。为了解决这个问题,JavaScript引入了异步编程模式,其中包括回调函数,Promise,async/await等。而今天的主角事件循环就是用于处理异步任务的机制。

事件循环机制

事件循环机制主要是负责调度和执行异步任务。它是JavaScript运行时环境中的一个核心机制。也就是说,在不同的宿主环境下可能会有不同的实现。比如在浏览器中的事件循环与在Node.js中的事件循环实现方式就不一样,Node.js的事件循环是基于libuv(这是一个跨平台的异步I/O库)来实现的。不过今天咱们这里主要是介绍浏览器的事件循环,故不再赘述。

浏览器中的事件循环机制主要由以下几大部分组成:

一,调用栈(Call Stack)

它也被称为执行栈,故名思义,调用栈是一个栈结构,遵循LIFO(后进先出)原则。它主要是用于追踪当前运行的代码的所有执行上下文,当一个函数被执行时,它的执行上下文被创建并推入到调用栈的顶部。当函数执行完毕,它的执行上下文会从栈顶被移除。

二,任务队列(Task Queue)

任务队列也被称为宏任务队列,它是一个FIFO(先进先出)的队列,用于存储待执行的异步任务的回调。常见的宏任务包括setTimeoutsetIntervalI/O操作用户交互(如点击事件)等。当一个异步操作完成时,它的回调函数将会被添加到任务队列等待执行

三,微任务队列(Microtask Queue)

微任务队列是另一种任务队列,用于处理比宏任务更高优先级的任务。常见的微任务包括Promise.then()MutationObserver,以及(nodej中的)process.nextTick等。它有一个重要特性,在每次事件循环迭代结束时,如果微任务队列中存在任务,它会优先于宏任务被执行

四,事件循环(Event Loop)本身

事件循环的核心就是一个循环过程,它不断检查调用栈是否为空,如果调用栈为空,它会按照以下规则处理队列中的任务:

  • 处理微任务队列:事件循环首先检查微任务队列,如果队列不为空,它会依次执行所有微任务。在任务执行期间产生的新微任务也会被添加到队列并在这个阶段执行。
  • 处理宏任务队列:一旦微任务队列为空,事件循环就会转到宏任务队列。它会取出队列中的第一个宏任务,将其相关的回调放入执行栈中执行。
  • 循环重复:完成宏任务后,事件循环会再次检查微任务队列,然后处理下一个宏任务,这个循环不断重复,每次事件循环只执行一个宏任务。

为了大家更好的理解这个过程,请看下面的这个示例:

console.log('A');

setTimeout(() => {
    console.log('B');
    new Promise((resolve, reject) => {
        console.log('C');
        resolve();
    }).then(() => {
        console.log('D');
    }).then(() => {
        console.log('E');
    }).finally(() => {
        console.log('F');
    });
}, 0);

new Promise((resolve, reject) => {
    console.log('G');
    resolve();
}).then(() => {
    console.log('H');
}).then(() => {
    console.log('I');
}).finally(() => {
    console.log('J');
});

setTimeout(() => {
    console.log('K');
}, 0);

console.log('L');

大家可以先考虑它的输出结果是什么?然后最好去控制台运行一下看看自己的答案是否正确。

  1. 这里它首先会执行所有同步代码,打印 'A',然后是 'G',最后是 'L'
  2. 接下来是微任务,当第一个promise resolve了,它的.then()回调被添加到微任务队列,这就会依次打印出'H''I''J'
  3. 然后事件循环移动到宏任务,第一个setTimeout执行,打印B,它的回调中创建的promise resolve了,该promise的.then()回调也会被添加到微任务队列,所以它会依次打印出'C''D''E''F'
  4. 最后执行第二个setTimeout的回调,打印出K

所以最终打印的顺序为: A G L H I J B C D E F K

补充

关于经常会被问requestAnimationFrame是宏任务还是微任务,它其实既不是宏任务也不是微任务。只能说它和宏任务的性质一样,但执行时机不同,它通常在每次重绘之前执行。requestAnimationFrame是专门为更平滑的动画效果设计的API,它的执行机制和时机是为了与浏览器的渲染过程同步,而不是基于传统的事件循环队列。

请看下面这段代码:

setTimeout(()=>{
    console.log('A')
})
requestAnimationFrame(()=>{
    console.log('B')
})
setTimeout(()=>{
    console.log('C')
})
Promise.resolve('D').then(res=>{
    console.log(res)
})

当你去运行这段代码的时候,你会发现它的输出结果是不固定的,有时候是 D B A C 有时候则是 D A C B

如有错误请帮忙指出,感激不尽~