javascript从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。

结合我们上节课所讲的内容是不是觉得很这个描述很怪异?

TOP1:

JavaScript Event Loop 事件循环 - 图1
上节课程我们讲到在单线程方式下,一段代码调用另一段代码时,只能采用同步调用,必须等待这段代码执行完返回结果后,调用方才能继续往下执行。

有了多线程的支持,可以采用异步调用,调用方和被调方可以属于两个不同的线程,调用方启动被调方线程后,不等对方返回结果就继续执行后续代码。同时我们通过某些机制(回调/事件/消息)让被调方有了结果时通知调用方

JavaScript单线程非阻塞又是一个什么设计? 这其实是JavaScript有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务。 我们今天的任务就是要把这个事件循环的并发模型跟大家讲清楚。

TOP2:

首先我们要先解释一个问题为什么JavaScript要设计成单线程?
最主要的原因是JavaScript最初的用途是: 与浏览器交互,在于浏览器交互过程中我们需要进行各种各样的DOM操作。试想一下 如果JavaScript是多线程的,那么当两个线程同时对DOM进行一项操作,例如一个向其添加事件,而另一个删除了这个DOM,此时该如何处理呢?因此,为了保证不会 发生类似于这个例子中的情景,javascript选择只用一个主线程来执行代码,这样就保证了程序执行的一致性。

当然,现如今人们也意识到,单线程在保证了执行顺序的同时也限制了javascript的效率,因此开发出了web worker技术。这项技术号称让javascript成为一门多线程语言。

image.png

然而,使用web worker技术开的多线程有着诸多限制,例如:所有新线程都受主线程的完全控制,不能独立执行。这意味着这些“线程” 实际上应属于主线程的子线程。另外,这些子线程并没有执行I/O操作的权限,只能为主线程分担一些诸如计算等任务。所以严格来讲这些线程并没有完整的功能,也因此这项技术并非改变了javascript语言的单线程本质。

这个我们在后面有专门的章节讲到。

回归到主题JavaScript 除了单线程的另一个特点是”非阻塞”,那么JavaScript 引擎到底是如何实现单线程且非阻塞这看似矛盾的设计呢?接下来我们就来介绍今天的主角 Event Loop 事件循环。

TOP3:

浏览器环境下js引擎的事件循环机制

当 JavaScript 引擎去执行代码的时候会将不同的变量存于内存中的不同位置:堆(heap)和栈(stack)中来加以区分。其中,堆里存放着一些对象。而栈中则存放着一些基础类型变量以及对象的指针。
image.png

当 V8 去执行这段 JavaScript 代码时,会先将全局执行上下文压入到执行栈。并将其中的同步代码按照执行顺序加入执行栈中,然后从头开始执行。如果当前执行的是一个方法,那么JavaScript 会向执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。

image.png
执行环境,又叫执行上下文。
这个执行环境中包含这个方法的私有作用域、参数、以及这个作用域中定义的变量、作用域的 this 对象,上层作用域指向的一个信息集合。(介绍全局执行环境的略微不同)

当 fn 函数执行环境中的代码 执行完毕并返回结果后,js 会退出这个执行环境并把这个执行环境销毁,回到全局执环境这个过程反复进行,直到执行栈中的代码全部执行完毕。

以上的过程说的都是同步代码的执行。那么当一个异步代码(如发送ajax请求数据)执行后会如何呢?之前讲过JavaScript 的另一大特点是非阻塞,实现这一点的关键在于事件队列(Task Queue)。

TOP4:

事件队列

JavaScript 引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为事件循环的原因。
image.png
图中的stack表示我们所说的执行栈,web apis则是代表一些异步事件,而callback queue即事件队列。

TOP5:

微任务与宏任务

以上的事件循环过程是一个宏观的表述,实际上因为异步任务之间并不相同,因此他们的执行优先级也有区别。不同的异步任务被分为两类:微任务(micro task)和宏任务(macro task)。

我们通常把宿主发起的任务称为宏观任务,把 JavaScript 引擎发起的任务称为微观任务。
以下事件属于宏任务:

  • setInterval()
  • setTimeout()

以下事件属于微任务

  • new Promise().then(function(){})
  • new MutationObserver()

注意: promise是立即执行的,它创建的时候就会执行 只有promise调用then的时候,then里面的函数才会被推入微任务中

前面我们介绍过,在一个事件循环中,异步事件返回结果后会被放到一个任务队列中。然而,根据这个异步事件的类型,这个事件实际上会被对应的宏任务队列或者微任务队列中去。并且在当前执行栈为空的时候,主线程会 查看微任务队列是否有事件存在。如果不存在,那么再去宏任务队列中取出一个事件并把对应的回到加入当前执行栈;如果存在,则会依次执行队列中事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的一个事件,把对应的回调加入当前执行栈…如此反复,进入循环。

image.png
这样就能解释下面这段代码的结果:

  1. setTimeout(function () {
  2. console.log(1);
  3. });
  4. new Promise(function(resolve,reject){
  5. console.log(2)
  6. resolve(3)
  7. }).then(function(val){
  8. console.log(val);
  9. })
  10. /*
  11. 2
  12. 3
  13. 1
  14. */

注意:每一次 loop 的时候只会并且先执行”最前面”的宏任务, 然后执行当前 loop 下所有的微任务, 所有微任务完毕之后, 进入下一次 loop, 执行接下来的宏任务, 重复上述过程。

TOP6:

简述闭环流程

当执行栈为空时,执行以下步骤

  1. 执行微任务队列
    1. 选择微任务队列中最早的任务(任务x)
    2. 如果任务 x 为空(意味着微任务队列为空),跳转到步骤(g)
    3. 将 “当前正在运行的任务 “ 设置为“任务 x “
    4. 运行 “任务 x “
    5. 将 “当前正在运行的任务 “ 设置为空,删除 “任务x “
    6. 选择微任务队列中下一个最早的任务,跳转到步骤(b)
    7. 完成微任务队列;
  2. 选择宏任务队列中最早的任务(任务 A)
  3. 将”当前正在运行的任务” 设置为“任务 A”
  4. 运行”任务A”(表示运行回调函数)同步代码
  5. 跳到第 1 步。
  6. 将”当前正在运行的任务”设置为空,删除“任务 A” 结束本次Loop 循环
  7. 跳到第 2 步。
  1. setTimeout(function() {
  2. console.log(1);
  3. });
  4. new Promise(function(resolve, reject) {
  5. console.log(2)
  6. resolve(3)
  7. }).then(function(val) {
  8. console.log(val);
  9. setTimeout(function() {
  10. console.log(4);
  11. });
  12. })

要记住的事情:

  1. 当任务(在宏任务队列中)运行时,可能会注册新事件。因此可能会创建新任务。以下是两个新创建的任务:
    • promiseA.then() 的回调是一个任务
      • promiseA 被解决/拒绝:任务将在当前轮事件循环中被推入微任务队列。
      • promiseA 未决:该任务将在未来一轮的事件循环中被推入微任务队列(可能是下一轮)
    • setTimeout(callback,n)的回调是一个任务,会被推入macrotask队列,即使n为0;
  2. 微任务队列中的任务将在本轮运行,而宏任务队列中的任务必须等待下一轮事件循环。
  3. 我们都知道”click”,”scroll”,”ajax”,”setTimeout”…的回调是任务,但是我们也应该记住脚本标签中的JavaScript代码整体也是一个任务(宏任务)。

TOP7:

为什么要设计微任务?

微任务解决了宏任务执行时机不可控的问题,宏任务需要先被放到消息队列中,如果某些宏任务的执行时间过久,那么就会影响到消息队列后面的宏任务的执行,而且这个影响是不可控的,因为你无法知道前面的宏任务需要多久才能执行完成。

引入了微任务后,微任务会在本次事件循环同步任务执行结束时执行,利用微任务,你就能比较精准地控制你的回调函数的执行时机。

TOP8:

案例 Vue 异步更新队列

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环 “tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替 。