深入JS腹地,体会Event Loop的精妙设计--JS基础篇(十一)

441 阅读9分钟

写在前面

如何打好JavaScript基础?深入剖析V8的代码执行机制--JS基础篇(一) - 掘金 (juejin.cn)

前面我分享了有关于JS引擎如何处理代码执行的一些内容。通过调用栈,JS高效地管理着全局执行上下文与函数执行上下文,这保证了JS在遇到异步任务时仍然有良好的响应性。

如果还不了解调用栈等知识的友友,请移步👆文章,因为这些基础知识对于我们理解JS的事件循环机制(Event loop)很有帮助。

正文

一、Event Loop的设计初衷

事件循环(Event Loop)机制是用于解决JS单线程环境中对异步操作的管理问题。因为JS是单线程的,执行任务时只能逐个执行。

在现代编程环境中,我们经常会进行一些耗时的操作,如:网络I/O操作(Ajax请求、Fetch API调用)和定时器(setTimeout和setInterval)等等。这些操作相比于简单的算术运算、变量赋值和结果打印繁重得多。

如果在JS的单线程环境下,任务的执行只是简单的顺序执行,那么可能有些简单任务或者说与耗时任务不相关的任务会被 耽误 很长时间。

我们举个🌰(例子)

在一条路上有一个关卡,要求出示证件才允许车辆通行。一天,小明骑着小毛驴经过的时候,发现前面有许多大型车辆排队,由于小明是一个老实人,就乖乖排队。可是过了半个小时,整个队伍一动不动,小明也忍不了了。于是小明凭借着车辆的优势去到了队伍前面,询问工作人员后得知,一辆大车手续不齐全,得补全手续才能过去。小明想着自己带齐了证件,就想着先过去,但是工作人员坚决不让其先过去,说得按先来后到!最后,小明等了好几辆手续不全的车通过才轮到他。

这就比较悲哀了,小明手续齐全能很快通过,相当于一个不耗时的任务;手续不全的其他车都不能立马通过,属于耗时任务。严格按照排序来通过,结果就是效率十分低下。

JS官方为了更好的处理这些不耗时和耗时任务(异步)的调度,就设计了一个事件循环(Event Loop)执行机制。

事件循环机制中的一个核心是将任务分成了两种:同步任务异步任务

二、Event Loop的两种任务

1 同步任务(Synchronous tasks)

这一类任务会直接在主线程上执行,JS会按照代码的编写顺序将所有同步任务放入同步队列。

  • 执行顺序:遵循编写顺序,逐行执行。

  • 执行环境:所有同步任务都是在JS的主线程上执行,主线程是JS引擎执行代码的唯一执行上下文(可以理解为代码的执行环境)。

  • 阻塞行为:注意,如果同步任务中包含耗时操作(非异步,如复杂的循环),整个主线程会被阻塞,直到这个同步的耗时操作完成,在这期间,其他同步任务都会在同步任务队列中等待。

2 异步任务(Asynchronous tasks)

这一类任务通常是耗时任务,当JS引擎执行代码时遇到异步代码,它会启动异步任务(交由浏览器或Node.js环境执行),然后继续执行后续代码,而不会等待异步任务完成。

当异步任务完成时,其回调函数(在JS主线程执行)会被放入异步队列,等待事件循环机制处理,当执行栈(call stack)清空后,由事件循环机制调度到JS主线程上执行。

  • 执行顺序: 具体根据事件队列里的任务类型判断,稍后会重点讲。

  • 执行环境: 异步任务启动后交由浏览器或者Node.js执行,异步任务完成后,其回调函数由JS主线程执行。

  • 阻塞行为:异步任务不阻塞主线程(异步任务设计初衷),除了启动异步任务的代码(例如:setTimeout),异步任务不会在主线程执行。

来看个例子:

image.png

结果如下:

  • ⬇️开始执行
    ⬇️同步任务 0
    ⬇️同步任务 1
    ⬇️同步任务 2
    ⬇️触发异步任务
    ⬇️同步任务继续执行
    ⬇️同步任务 3
    ⬇️同步任务 4
    ⬇️同步任务 5
    ⬇️所有同步任务执行完毕
    ⬇️异步任务执行完毕

异步任务启动后JS引擎继续执行同步代码。一般来说,只要没有在同步代码中引入新的异步任务,异步任务的回调函数会在所有同步任务执行完毕之后执行。这是JavaScript异步模型的基本行为之一。

注意:并非所有的耗时任务都是异步任务,任务类型的判断取决于是否阻塞主线程,同步任务会阻塞主线程,异步任务不会阻塞主线程。

三、Event Loop的主角--异步任务

如果说同步任务是事件循环机制中的核心,那么异步任务就是核心的核心,理解了事件循环机制中异步任务的处理过程,我们才算是理解JS的异步编程。

在上面的例子中:

启动异步任务后,JS引擎就继续将后面所有同步任务都执行完毕。那么在这个过程中,会不会出现这种情况呢?

在JS引擎执行剩余的同步代码过程中,交给浏览器线程或者Node.js环境的异步任务就已经执行完毕。当然有可能!

那么你有没有这样一个疑问呢?异步任务结束后,其回调函数又是一个同步任务,那么这个回调函数会何时执行呢?

看完例子你就知道了:

image.png 这里有一个异步任务setTimeout中内嵌了同步任务和另一个异步任务。执行结果如下:

image.png

上面的异步任务setTimeout等待时间为0,意味着在最后一个同步任务执行之前就完成了。但是从结果我们可以得出结论, 就算异步任务在同步任务之前完成,该异步任务的回调函数也不会先执行 ,而是会等待外部所有同步任务执行完毕。

解释原理之前,我们还要学习三个抽象概念:

🚀异步任务执行基础

1 调用栈(Call Stack)

调用栈用于维护函数间的调用关系,一个函数或者一个全局环境都可以作为一个元素压入调用栈。执行时,JS引擎从栈顶开始处理各个元素。(注:所有代码都在函数作用域或者全局作用域内)

2 宏任务队列(Macrotask Queue)

存放宏任务(异步任务)的回调函数的队列,宏任务是异步操作的类型之一。

包括:

  • setTimeoutsetIntervalsetImmediateDOM操作I/O操作等......

3 微任务队列(Microtask Queue)

存放微任务(异步任务)的回调函数的队列,微任务是异步操作的类型之一。

包括:

  • Promise的.then(), .catch(), .finally()等方法中的回调
  • MutationObserver的回调
  • process.nextTick(在Node.js中)

注:由于调用栈维护的是可执行的单元,即使异步任务内没有回调函数,其内部实现的代码也会被底层JS环境转换为可执行的单元。

♻️事件循环机制

了解了上述基本概念后,我们来看一张流程图:

image.png

看不懂?没关系,听我细细道来:

  • 首先,JS引擎执行代码,判断同步还是异步,遇到同步任务直接在JS主线程上执行同步任务,具体过程为放入调用栈执行。

  • 如果遇到异步任务就将其交给浏览器或者Node.js环境的线程执行,在异步任务结束后返回一个回调函数

  • 将回调函数划分为宏任务微任务放入对应任务队列。

  • 检查微任务队列,异步任务中默认优先执行微任务,当微任务队列不为空,且调用栈为空(无同步任务),则将微任务队列的回调函数放入调用栈执行。

  • 检查宏任务队列,当微任务队列为空(无微任务队列),将微任务的回调函数放入调用栈执行。

  • 过程中,微任务遇到同步任务会暂停执行下一个微任务,宏任务遇到微任务会暂停执行下一个宏任务。

同步任务与异步任务的处理可以同步进行,因为同步任务占用JS主线程,异步任务占用浏览器或者Node.js环境线程,但是对于异步任务的回调函数,其必须在所有同步任务后执行。

没看懂?那强烈建议多看几遍!!!

以上就是Event Loop机制的详细解释。

接下来我们分析之前的例子,如图:

image.png

下面我统一使用任务x代表数字x。

先执行同步任务1。

遇到异步任务setTimeout,启动异步任务,继续执行最后一行任务2。

外部同步任务执行完毕后,进入异步任务的回调函数。遇到任务3,执行。

遇到另一个异步任务,启动异步任务。继续执行任务4。

执行完成之后,执行异步任务的回调函数,任务5执行完毕。

🔍补充知识:

回调函数:

回调函数(Callback Function)是一种将函数作为参数传递给另一个函数的概念,这样传递的函数就可以在外部完成某些操作后再被调用,回调函数主要用于异步编程汇总,允许开发者指定在某个事件发生后要执行的代码。

总结

本期讲了:

  • Event Loop的设计初衷
  • Event Loop的两种任务
    • 同步任务
    • 异步任务
  • Event Loop的主角--异步任务
    • 异步任务执行基础
      • 调用栈
      • 宏任务队列
      • 微任务队列
    • 事件循环机制(重点❗️)
  • 回调函数概念

感谢看到这里的你,希望你有所收获,下一期Promise对象。如果你觉得本篇文章对你有帮助,还请给个小赞,这将是我持续创作的动力!