事件循环机制详解

323 阅读9分钟

事件循环是一种在计算机程序中处理和调度事件或消息的机制,也是 JavaScript 在浏览器中的运行机制,对于前端了解异步任务逻辑非常重要,对此我一直都处在一个模糊不清的状态,然而直到昨天半夜,突然顿悟以至于我睡不着觉,今天起来梳理了一下把它记录下来。

事件循环机制

众所周知 JavaScript 是一种单线程语言,没有像 JAVA 那样复杂的多线程操作,但是在实际运用中并不是所有的任务都是在执行之前统一安排好的,大部分情况下,新的任务是在线程运行过程中产生的,要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制,js运行在浏览器渲染进程中的主线程中,可以视为主线程中的主干任务加载完成之后,创建一个循环系统,去执行不断产生的任务。

image.png

但是这样的模型只能处理主线程内部产生的任务,渲染进程中还有一个IO 线程,网络进程,浏览器进程会产生新的任务都会由他接收,渲染进程就需要让主线程着手处理这些新的任务,渲染进程就维护了一个消息队列,通过它管理由IO线程不断派发的新任务,而主线程的循环系统不断地从消息队列中取出新的任务去完成。

image.png

异步任务

了解了浏览器事件循环机制,在这里我们需要先清楚一点,并发和并行的区别。

  • 并发(Concurrency):两个或多个任务在同一时段内发生,但不一定同时发生。换句话说,它们可能交替执行,但不会真正的在同一时刻运行。在单线程环境中,如JavaScript,异步操作实际上是并发地执行的。
  • 并行(Parallelism):两个或多个任务真正同时进行。

在 JavaScript 不论是在浏览器中还是在Node.js中都是单线程的,所以代码不存在并行执行。事件循环和异步操作的设计,JavaScript能够利用线程空闲,“非阻塞”地处理IO线程产生的并发任务,看起来好像这些任务在同时进行,但这实际上是并发完成的,而不是并行。然而在Node.js中 JavaScript 执行主进程的主线程中,大致的原理与浏览器中相同,然而Node.js在其内部和API中提供了很多利用多进程和多线程的方法来提高性能和并发处理能力,这些就不在我们的讨论范围内。接下来让我们结合上面的事件循环模型,来理解浏览器异步任务执行流程。

  1. 在浏览器中,网络进程完成页面请求返回HTML文档以后,与渲染进程搭建IPC将玩当内容交给渲染进程,这部分并不会通过消息队列,而是直接由主线程开始执行,逐步解析HTML文档并构建Dom树。然而在HTML文档解析的早期阶段就会同步创建全局上下文并创建windows对象。以上都在主线程中同步执行,并不会经过IO线程。
  2. 当解析器遇到同步<script>标签时它会暂停DOM构建,请求并下载脚本,这部分脚本就会形成一个新的任务进入消息队列,由循环系统取出任务并执行,这也就是为什么JS脚本会阻塞页面渲染。至于异步脚本也就是执行时机不同,实行原理还是相同的
  3. 重复上面两步解析并构建DOM,过程中遇到的其他资源便会由网络进程发起请求。遇到全局的如页面加载、加载完成等监听器便触发回调函数进入消息队列。
  4. DOM构建完成后开始构建CSSOM并进行页面渲染。
  5. 页面渲染完成

到此,一个新的页面就展示完成了。在此过程中<script>标签中的脚本、浏览器事件、网络请求资源加载完成等都会由IO线程包装为一个任务(可以视作一个待执行的方法)放入消息队列,这就是宏任务。常见的能够产生宏任务被推入消息队列的方式如下:

浏览器环境下:

  1. 定时器setTimeoutsetInterval的回调。
  2. 用户界面交互:如点击事件、键盘事件等。
  3. 网络事件:例如XMLHttpRequest的事件回调。
  4. 解析完整的HTML文档事件:例如window.onload
  5. 其他API:如requestAnimationFrame的回调、WebSocketsEventSource等。
  6. 其他异步事件:例如postMessage
  7. 历史导航:如history.pushState的事件回调。

Node.js环境:

  1. 定时器setTimeoutsetInterval的回调。
  2. I/O操作:例如文件操作的回调、网络请求的回调。
  3. 即时定时器setImmediate的回调。
  4. 其他API:如stream事件、HTTP服务器事件等。

下面来看一个代码段:

console.log("Starting...");
​
setTimeout(() => {
    console.log("This is an async message from setTimeout!");
}, 0);
​
console.log("Ending...");

输出结果是:

Starting...
Ending...
This is an async message from setTimeout!

我们做一个简单的解析:

  1. 我们暂且将当前正在执行的任务称为task0(在Node.js中如果是全局代码,会在先在线程中同步执行,并不会经过消息队列产生这个宏任务,但是事实上也可以将它视为一次宏任务,并不影响后续逻辑)当前任务执行到第一行输出Starting...
  2. setTimeout方法执行,将回调函数作为一个任务,当定时器开始计时,到达指定时间后进入消息队列。这里时间为0也就直接进入消息队列,暂且将这个任务称为task1。
  3. 此时task0继续执行,完成随后一行输出Ending...
  4. task0执行完成,循环系统从消息队列中取出下一个任务,也就轮到了task1
  5. 执行task1的内容,输出This is an async message from setTimeout!

目前为止代码产生的都是会通过消息队列异步执行的宏任务,现在我们再来看另一个案例:

console.log('1: Script start');
​
setTimeout(() => {
    console.log('6: setTimeout callback');
}, 0);
​
new Promise(resolve => {
    console.log('2: Promise start');
    resolve()
}).then(() => {
    console.log('4: Promise resolve callback 1');
    Promise.resolve().then(() => {
        console.log('5: Promise resolve callback 2');
    });
})
console.log('3: Script end');

输出如下:

1: Script start
2: Promise start
3: Script end
4: Promise resolve callback 1
5: Promise resolve callback 2
6: setTimeout callback

对Promise不熟悉的可能会有点懵,在对他进行解析之前我们先来了解一下微任务。

首先来看一下常见的会生成为任务的方式:

  1. Promise的回调Promisethencatchfinally方法都会产生微任务。
  2. MutationObserver:在浏览器中,MutationObserver的回调是微任务。
  3. process.nextTick:这是Node.js特有的,它的回调也是一个微任务。
  4. queueMicrotask:这是一个直接将微任务添加到微任务队列的方法。它是浏览器和Node.js都提供的标准API。

以上方法在执行过程中会生成微任务,那么微任务是什么,他跟宏任务有什么区别吗?

微任务(Microtask)是事件循环中由循环系统维护的一种任务队列,在宏任务运行过程中产生的微任务都会进入这个微任务队列,当至此宏任务执行完成,取下一个宏任务之前会依次执行微任务队列中的任务,而如果微任务队列中又产生了新的微任务,仍然会推入微任务队列,直到微任务队列清空,继续拿取下一个宏任务执行。

image.png

现在我们可以来分析一下上面的例子的执行过程了。

  1. 我们暂且将当前正在执行的任务称为macroTask0,当前任务执行到第一行输出1: Script start
  2. setTimeout方法执行,跟刚才一样,这里定时器时间也为0也就产生一个宏任务macroTask1
  3. 终于,代码执行到了Promise这一行,调用Promise构造函数,Promise中的回调函数仍然是同步执行的,输出2: Promise start,并调用resolve()方法使Promise顺利返回一个实例
  4. 代码继续执行到Promise实例的.then方法,这里我们要分清,是.then方法传入的回调函数会被推入微任务队列中,但是.then会直接执行,并返回一个新的Promise。至此也就产生了一个新的微任务,我们称之为microTask1,被推入微任务队列中
  5. 此时task0继续执行,完成随后一行输出3: Script end
  6. 至此task0执行完成即将推出此次循环,但是循环系统发现微任务队列中存在微任务。那么就开始依次执行微任务,第一个就是microTask1
  7. 开始执行microTask1,输出4: Promise resolve callback 1
  8. microTask1继续执行,又发现了一个新的Promise,这里直接调用了resolve()兑现了这个Promise,执行.then方法,将其回调函数作为一个新的微任务推入微任务队列,称之为microTask2
  9. microTask1执行完成,继续查看问任务队列,发现仍然存在微任务,继续执行microTask2
  10. 开始执行microTask2,输出5: Promise resolve callback 2
  11. microTask2执行完成,至此微任务队列便清空了,循环系统得以退出此次循环,从消息队列中获取下一个宏任务。也就是终于开始执行早早进入消息队列的macroTask1,输出最后一行6: setTimeout callback

结语

到这里相信你对JavaScript的事件循环机制已经有了一个全面的认识,相信面对更多异步执行的代码产生的时序问题会有更加清晰地思路,但是在这我也不敢保证自己的总结全部是正确的,建议大家结合自己的认知以及实践沉淀出自己的理解。最后再打个广告,关注公众号程序猿青空,不定期分享各种文章、学习资源、学习课程,能在未来(因为现在还没啥东西)享受更多福利。