彻底理解 JS Event Loop(浏览器环境)

2,742 阅读6分钟

最近罗列了一些软件开发基础知识点,计划逐一的、彻底的理解每一个知识点,并为每个知识点写一篇详细的,图文并茂的文章。这篇是关于浏览器环境下 JS 的 Event Loop 机制(如有错误,欢迎指出)。

浏览器线程

我们常说 JS 是单线程语言,但是别忘了常见的浏览器内核可都是多线程的,多个线程间会进行不断通讯,通常会有如下几个线程:

  • GUI 渲染进程
  • JS 引擎线程
  • 定时器线程
  • 事件触发线程
  • 异步 HTTP 请求线程

Microtask 与 Macrotask

在大多数解释 JS Event Loop 的文章中,鲜有谈及 Miscrotask 和 Macrotask 这两个概念,但这两个概念却是非常的重要,我在翻阅 Zone.js Primer 时,里面就经常会提及这两个概念,当时也是看的云里雾里的,这也是我写这篇文章的原因之一。

setTimeout(function () {
    console.log('timeout1');
}, 0);

console.log('start');

Promise.resolve().then(function () {
    console.log('promise1');
    Promise.resolve().then(function () {
        console.log('promise2');
    });
    setTimeout(function () {
        Promise.resolve().then(function () {
            console.log('promise3');
        });
        console.log('timeout2')
    }, 0);
});

console.log('done');

以上代码最后会输出什么呢?如果你能很快的回答出来,你大概就已经掌握了 Event Loop 的实际运用了,如果回答不出,那可能还得接着往下看。

问题:是先执行 then( ) 中的回调函数呢,还是 setTimeout( ) 中的回调函数呢?

答案:先执行前者。因为 Promise.prototype.then( ) 是 Microtask ,而 setTimeout( ) 是 Macrotask 。至于为什么先执行 Miscrotask ?继续往后看~

在 JS 线程中程序的每一个调用都被看成是一个任务(task) ,所有的任务被分成许多类型且存放在对应类型的队列中,为了方便理解,我把这些任务队列分成三类:

  • Micro-task queue: 存放 microtask 的回调函数。

  • Macro-task queue: 存放 macrotask 的回调函数 。

  • Other-task queue: 这是一个我个人抽象出来队列,实际并不存在,假设该队列用来存放除了 microtask 和 macrotask 外的所有任务。

Microtask 和 Macrotask 的区别就是执行顺序上的区别。简单的说,JS 线程会先处理 other-task queue 上的任务,处理完了之后,再去处理 micro-task queue 上的任务,最后才处理 macro-task queue 上的任务。至于 JS 线程具体的执行细节,后面会详细的进行描述。

以下是常见的 Microtask 和 Macrotask:

  • Microtask :Promise.prototype.then( )、MutationObserver.prototype.observe( ) 等 。

  • Macrotask :setTimeout( )、setImmediate( )、XMLHttpRequest.prototype.onload( ) 等。

JS 线程 Event Loop 的实现

Event Loop 模型图

如上,根据个人的理解,我画了一个浏览器环境下 JS 实现 Event Loop 大致模型图,具体含义如下:

1 获取执行的任务,执行步骤 1.1

1.1 判断 other-task queue 中是否有任务,如果有,获取最早的任务然后执行步骤 2 ,否则执行步骤 1.2 。

1.2 判断 micro-task queue 中是否有任务,如果有,获取最早的任务然后执行步骤 2 ,否则执行步骤 1.3 。

1.3 判断 macro-task queue 中是否有任务,如果有,获取最早的任务然后任何执行步骤 2 ,否则执行步骤 3 。

2 将取到的任务放到 call stack 并执行,执行完之后再执行步骤 1 (值得注意的是,在执行的过程中,是会不断的更新所有的 task queue ,因为 call stack 中正在执行的任务内部也可能存在普通任务、microtask 和 macrotask ,执行任务的过程可以理解为一个递归过程,如果无限递归,call stack 上待执行的任务就会不断累积而溢出,这也就是常见的 Maximum call stack size exceeded 错误)。

3 线程会处理其他工作,例如:不断同步「事件触发线程」的状态,一旦有事件触发,即查看触发事件「target」有没有对应事件的监听器任务,如果有,则选中该任务并执行步骤 2 。需要注意的是,并不是只有执行了步骤 1.3 后才会执行当前步骤,JS 线程肯定还会在的某个时候去同步其他线程的状态的。

接下来,如果仔细想,可能会产生一个疑问:JS 进程是如何更新 micro-task queue 和 macro-task queue 这两个队列的呢 ?

根据我的理解,micro-task queue 和 other-task queue 都是“同步”更新的,而 macro-task queue 是“异步”更新。以下是 macro-task queue 更新的具体流程(以 setTimeout 为例):

  1. JS 线程判断某个 macrotask 是一个定时器,将这个定时器同步给定时器线程。
  2. 定时器线程启动从 JS 线程收到的定时器。
  3. JS 线程在某个时候(可能是执行上述步骤 1 的时候)会通过定时器和 http 请求等一些线程来更新 macro-task queue ,即如果以上的定时结束了,JS 线程就可以将对应定时器的回调函数存放到 macro-task queue 中。

如何理解 JS 中的异步

目前普遍对异步的解释可能是:执行调用,如果立即得到结果就是同步调用,否则为异步调用。

在 JS 环境中,我个人其实是不同意这个解释的。

首先,根据以上的解释,setTimeout( )、Promise.prototype.then( ) 、http 请求和各类浏览器事件,这些都被认为是异步的。但我却不这么认为,我认为浏览器事件不是异步的。以下代码便是理由:

// html: <button id="btn">click</button>

// js
var btn = document.getElementById('btn');

setTimeout(function () {
    console.log('timeout')
}, 0);

Promise.resolve().then(function () {
    console.log('promise');
});

btn.addEventListener('click', function () {
    console.log('click');
});

btn.click();

console.log('done');

如果浏览器事件是异步的,不管后续会打印出什么,第一个打印的必然是 done ,而实际的打印结果为:click done promise timeout

也就是说,JS 认为浏览器事件并非异步。

由此,我个人对异步的解释是:在满足调用所需的外在条件的情况下,执行调用,立即获得结果的就为同步调用,否则为异步调用。

根据这个理解,当我们发起的一个 http 请求时,假设服务器以光速返回请求结果,XMLHttpRequest 对象的 onload 方法会立即执行吗?,显然不会,所以 http 请求为异步调用。这也是为什么我在以上分析 Event Loop 中的任务队列时并没有将 event-task queue 拎出来的原因。因此,对于异步调用的判断可以是这样:如果某个调用属于 microtask 或是 macrotask 中的其中一个,那么这个调用就是异步调用。

题外话

有人可能会注意到,这篇文章经常出现「我认为」和「我理解」,这并非是我对自己不自信,而是我想表达一个看法:在翻阅别人的技术文章的时候,务必保持独立思考的能力,就算文章的作者是业界有名的大牛,也不能没缘由的「深信不疑」,对对应的技术点务必在自个脑中里建立一个可以自圆其说的模型。至于我为什么会表达这个看法,是因为我找翻阅大量的过程中,发现大多数关于 JS Event Loop 的文章或多或少都有一些粗糙或是错误,如果我只看其中的某一篇,我很大的概率会有建立一个错误的 Event Loop 模型。当然,就我当前的理解,还是可能会有些许错误。Anyway ,还是那句话:保持独立思考,与各位共勉。

Done.👊

参考链接