Event Loop 可视化解析讲解

索取的太多,最后必然失去一切 --《拿破仑传》

前言

原先,我们有一篇文章,简单描述了 JS (Event Loop)事件循环 和 (Call Stack) 调用堆栈。从宏观角度,分析浏览器中事件循环的运行机制。

理论终归是理论,我们在知晓原理之后,是为了更好的解决实际问题。 纸上得来终觉浅,绝知此事要躬行。 所以,今天借助一个可视化工具来详细介绍一下,JS事件循环的各种运行细节。

一图胜前言

简明扼要

  1. 事件循环(Event Loop)用于处理宏任务微任务。并将它们推入到调用栈中,并且满足同一时间只能执行一种任务(单线程特性)。同时还能控制页面何时被渲染
  2. 调用栈(Call Stack)用于追踪函数调用。是LIFO(后进先出)的栈结构。每个栈帧代表一次函数调用
  3. 宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。当一个任务被执行,其他任务是被挂起的(按顺序排队)
  4. 微任务队列是ES6新增的专门用于处理Promise调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。

文章概要

  1. 四大金刚
  2. 代码解析

1. 四大金刚

1. 事件循环 (Event Loop)

事件循环是一个不停的从 宏任务队列/微任务队列中取出对应任务的循环函数。在一定条件下,你可以将其类比成一个永不停歇的永动机。 它从宏/微任务队列取出任务并将其推送调用栈中被执行。

事件循环包含了四个重要的步骤:

  1. 执行Script:以同步的方式执行script里面的代码,直到调用栈为空才停下来。
    其实,在该阶段,JS还会进行一些预编译等操作。(例如,变量提升等)。该过程涉及到关于V8等知识范畴,不在此篇文章内讨论范围。如果感兴趣,可以参考V8如何处理JS
  2. 执行一个宏任务:从宏任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。
  3. 执行所有微任务:从微任务队列中挑选最老的任务并将其推入到调用栈中运行,直到调用栈为空。但是,但是,但是(转折来了),继续从微任务队列中挑选最老的任务并执行。直到微任务队列为空
  4. UI渲染:渲染UI,然后,跳到第二步,继续从宏任务队列中挑选任务执行。(这步只适用浏览器环境,不适用Node环境)

用伪代码来描述一下Event Loop的运行机制

// 在第一步执行Script时,开启“永动机”
while (EventLoop.waitForTask()) {
  // 第二步:从宏任务队列中挑选最老任务
  const taskQueue = EventLoop.selectTaskQueue();
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask();
  }
  // 第三步:从微任务队列中挑选最老任务
  const microtaskQueue = EventLoop.microTaskQueue;
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask();
  }
  // 第四步:UI渲染
  rerender();
}
复制代码

事件循环(Event Loop)用于处理宏任务微任务。并将它们推入到调用栈中,并且满足同一时间只能执行一种任务(单线程特性)。同时还能控制页面何时被渲染。

2. 调用栈 (Call Stack)

调用栈(Call Stack)是JS的基本组成部分。它是一个记录保存结构(record-keeping structure)允许我们能够执行函数调用的操作。在调用栈中,每一个函数调用被一种叫做栈帧(frame)的数据结构所替代。该结构能够帮助JS引擎(V8)保持函数之间的调用顺序和关系。并且能够在某个函数结束后,利用存储在栈帧中的信息,执行剩余的代码。使得JS应用拥有记忆。

当JS代码第一次被执行时,此时的调用栈是空的。只有在第一个函数被调用时候,才会向调用栈的栈顶推入(push)该函数对应的栈帧。当函数执行完成(执行到return语句),对应的栈帧会从调用栈中抛出pop)。

调用栈(Call Stack)用于追踪函数调用。是LIFO(后进先出)的栈结构。每个栈帧代表一次函数调用。

3. 宏任务队列 (Task Queue)

也可以称为回调队列(Callback queue)。

调用栈是用于跟踪正在被执行函数的机制,而宏任务队列是用于跟踪将要被执行函数的机制。

宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。你可以将这些任务类比成一个函数对象。

事件循环不知疲倦的运行着,并且按照一定的规则(后面会讲)从宏任务队列中不停的取出任务对象。事件循环的单次迭代过程被称为tick

题外话:看到tick是不是会想到Vue.nextTick(callback)。在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

Vue.nextTick(callback) 使用原理:Vue异步执行(会被推入到宏任务队列中)dom更新的,一旦观察到数据变化,Vue就会开启一个队列,然后把在同一个事件循环 (event loop) 当中观察到数据变化的 watcher 推送进这个队列。

如果这个watcher被触发多次,只会被推送到队列一次。这种缓冲行为可以有效的去掉重复数据造成的不必要的计算和DOm操作。而在下一个事件循环时,Vue会清空队列,并进行必要的DOM更新。

当你设置 vm.someData = 'new value',DOM 并不会马上更新,而是在异步队列被清除,也就是下一个事件循环开始时执行更新时才会进行必要的DOM更新。如果此时你想要根据更新的 DOM 状态去做某些事情,就会出现问题。为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。

为了处理宏任务,事件循环会调用与该宏任务一一对应的函数。当宏任务被执行时,它霸占整个调用栈,并且是排他性的。也就是说,此时被执行的宏任务(函数)的优先级最高。 而事件循环只能等到该任务执行完,并且,并且,并且调用栈为空时,才会从宏任务队列中挑选最老的任务继续上述步骤。

最老任务:这里有两层含义
1:如果每个宏任务的约定被执行的时间都相同的话(参考setTimeout中的第二个参数),那么最老的任务就是按照入队顺序来定,越早入队,越早被执行
2: 如果时间不一致,那就按约定执行时间来挑选,时间越小,越早被执行

在任务(函数)执行期间,如果触发了新的宏任务,它也会将新任务提交到宏任务队列中,按照队列排队顺序,将任务进行合理安置。有很多方式能够触发新的宏任务,最简单的方式就是在代码中触发了setTimeout(newTaskFn,0)。当然,《在JS (Event Loop)事件循环 和 (Call Stack) 调用堆栈》 一文中我们也介绍过能够触发宏任务的函数被称为Web APIS。 这里,我就直接拿来主义了。

Web APIs

  1. setTimout/setInteral
  2. AJAX请求
  3. 操作DOM
  4. 访问local storage、
  5. 使用worker

宏任务队列是一个FIFO(先进先出)的队列结构。结构中存储的宏任务会被事件循环探查到。并且,这些任务是同步阻塞的。当一个任务被执行,其他任务是被挂起的(按顺序排队)。

4. 微任务队列 (Microtask Queue)

微任务队列也是一个FIFO(先进先出)的队列结构。并且,结构中存储的微任务也会被时间循环探查到。微任务队列和宏任务队列很像。作为ES6的一部分,它被添加到JS的执行模型中,以处理Promise回调

微任务和宏任务也很像。它也是一个同步阻塞代码,运行时也会霸占调用栈。像宏任务一样,在运行期间,也会触发新的微任务,并且将新任务提交到微任务队列中,按照队列排队顺序,将任务进行合理安置。

布莱希特说:世界上没有两片相同的叶子。虽然,宏任务和微任务在很多地方相似,但是在存储处理过程上还是有差异的。

  • 宏任务存储在宏任务队列中,微任务存储在微任务队列中(听君一席话,如听一席话 )
  • 宏任务是在循环中被执行,并且UI渲染穿插在宏任务中。
    微任务是在一个宏任务完成之后,在UI渲染之前被触发。

微任务队列是ES6新增的专门用于处理Promise调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。


2. 代码详解

假设,我们是V8引擎,在接收到一段JS代码后,按照既定的套路,来输出用户想要的结果。

Call Stack运行机制


function seventh() { }

function sixth() { seventh() }

function fifth() { sixth() }

function fourth() { fifth() }

function third() { fourth() }

function second() { third() }

function first() { second() }

first();
复制代码

作为一个合格的JS引擎,你需要了解要执行这段代码需要用到何种工具(调用栈、任务队列等)。

简单的分析一波:从代码角度看,这里都是一些函数的定义和调用。没有任何副作用(产生异步)。所以,处理它们,仅用常规的调用栈就足够了。上文说到,调用栈中存储的是与函数一一对应的栈帧。它能够记住函数之间的调用关系。所以在一个函数返回后,还能通过栈帧中存储的信息恢复之前的函数信息。

Call Stack运行机制

Task Queue 运行机制

预订运行时间相同

setTimeout(function a() {}, 0);

setTimeout(function b() {}, 0);

setTimeout(function c() {}, 0);

function d() {}

d();
复制代码

在执行该段代码时,JS引擎会从上到下对代码进行分析。首先映入眼帘的是三个setTimeout,依次执行setTimeout代码。将setTimeout中回调函数按照调用顺序依次入队。(a=>b=>c)

随后,执行同步代码d(),由于是同步代码,它会被推入到调用栈内,执行对应的代码逻辑。

在调用栈为空后(d()执行完),事件循环会从宏任务队列中提取满足要求的任务。由于,三个宏任务的预订运行时间都相等,会按照他们入队的顺序依次被推入调用栈内。

预订运行时间相同

预订运行时间不同

这段代码和上面例子中有一点不同,在执行同步代码的逻辑是一样的。

从上到下执行代码,对应的setTimeout的回调函数依次入队。(a=>b=>c)。随后,执行同步函数d()

但是,但是,但是(转折又来了),在执行完d()后,时间循环从宏任务队列中提取满足条件的回调函数时。 由于三个回调函数的预订执行时间不一致,此时不会按照入队顺序提取。而是根据预订运行时间的大小来进行函数的提取。也就是我们上面提到的。时间越小,越靠前

setTimeout(function a() {}, 1000);

setTimeout(function b() {}, 500);

setTimeout(function c() {}, 0);

function d() {}

d();
复制代码

预订运行时间不同

Microtask Queue运行机制

这里有一些前置知识点,需要简单说一下。 在Promise中有一个概念叫做 非重入

非重入:Promise进入落定(解决/拒绝)状态时,与该状态相关的处理程序不会立即执行,处理程序后的同步代码会在其之前先执行

在一个解决promise上调用 then()会把 onResolved 处理程序推进消息队列

// 创建解决的promise
let p = Promise.resolve(); 
// 添加解决处理程序
p.then(() => console.log('新增处理程序')); 
// 同步输出
console.log('同步代码'); 

// 实际的输出:
// 同步代码
// 新增处理程序
复制代码
fetch('https://www.google.com')
  .then(function a() {});

Promise.resolve()
  .then(function b() {});

Promise.reject()
  .catch(function c() {});
复制代码

该段代码是刻意为之的。三个语句都会产生promise。根据前面所知,promise产生微任务。

继续分析代码,从上而下,fetch()进行一个异步接口请求,在接口没完成时(成功/失败),此时的promise的状态是pending状态。是不会触发对应的回调函数。 所以,fetch()会优先触发,但是不会进入微任务队列或者调用栈。

Promise.resolve()/Promise.reject()却不同,它们返回的Promise直接会进入落定(解决/拒绝)状态。所以,在遍历到它们的时候,会按照代码顺序,将其产生的微任务依次入队。

当同步代码执行完后,时间循环就会从宏任务队列/微任务队列中检索需要处理的任务。而此时宏任务队列为空。所以,就会从微任务队列中提取任务,并将其推入(push)到调用栈内执行。

等当前微任务队列中的任务被全部处理完后,此时fetch()的异步接口也会发生变化,会触发对应promise的then方法,此时就会产生新的微任务,该微任务会被入队。继续上述的步骤。

Task vs Microtask

setTimeout(function a() {}, 0);

Promise.resolve().then(function b() {});
复制代码

继续分析代码。

从上到下,执行代码。在执行过程中setTimeout首先被执行,与之对应的回调函数,被请入 宏任务队列。(相信,这步已经轻车熟路了哇)

继续,执行Promise.resolve(),此步直接返回了一个进入落定(解决)状态promise。那对于的微任务被区别对待被请入了 微任务队列。

到这里,貌似就有点犯难了。前文将过,宏任务队列和微任务队列其实很像,但是在事件循环是一个喜新厌旧的主。微任务队列是新人,它的优先级比宏任务队列高。 毕竟,年轻就是资本

其实,这里有一个很重要的点:

在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行

到这里可能会有疑惑,这里的代码,也不是在函数内部啊。其实哇,在script被解析执行时候,在全局范围内会存在一个anonymous函数。如果大家在debugger的时候,在控制台-Call Stack中最底部,就是一个代表当前script的匿名函数。

好了,收。拐的有点远,说了这么多,其实就是为说一句。在同一函数作用域下,微任务比宏任务先执行

再多说一嘴,其实哇,在JS中。宏任务和微任务是 1对N的关系。

V8 会为每个宏任务维护一个微任务队列

同时,微任务被执行的时机,是在V8要销毁全局代码的环境对象,此时会调用环境对象的析构函数 (这是C++的一个概念),此时,V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。

Promise.all() vs Promise.race()

接下来的代码解析,会简单的一带而过,其中涉及到知识点,其实都是针对promise的运行机制。后期,打算会有一篇专门针对promise的文章。

const GOOGLE = 'https://www.google.com';
const NEWS = 'https://www.news.google.com';

Promise.all([
  fetch(GOOGLE).then(function b() {}),
  fetch(GOOGLE).then(function c() {}),
]).then(function after() {});

复制代码

我们直接说结论了。Promise.all中产生的微任务,是内部所有的任务进入落定(解决/拒绝)状态,才会执行后续的处理。

上述代码的运行结果就是 b=>c=> after 或者 c=>b=>after 具体是哪一个,需要看b/c哪一个先进入落定状态。

const GOOGLE = 'https://www.google.com';
const NEWS = 'https://www.news.google.com';

Promise.race([
   fetch(NEWS).then(function b() {}),
   fetch(GOOGLE).then(function c() {}),
]).then(function after() {});
复制代码

Promise.race()却是,内部哪一个任务先落定,就会执行后续的代码(then)。在then的回调处理后,才会继续处理race中未被处理的任务。

上述代码的运行结果是 c=>after=>b 这里的b/c的运行顺序也是看哪一个先落定。

Promise 中产生Error

Promise.resolve()
  .then(function a() {
    Promise.resolve().then(function d() {})
    Promise.resolve().then(function e() {})
    throw new Error('Error')
    Promise.resolve().then(function f() {})
  })
  .catch(function b() {})
  .then(function c() {})
复制代码

这里简单说一下,在Promise中throw一个错误,会将后续的代码截断。这里的后续代码指的是,throw后面的代码。也就是Promise.resolve().then(function f() {})不会被执行。

在promise中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令。

Promise.reject(Error('foo')); 
console.log('bar'); 

// bar 
// Uncaught (in promise) Error: foo
复制代码

Promise.prototype.catch()调用它就相当于调用 Promise.prototype.then(null, onRejected),并且返回了一个新的promise实例。

Promise 中产生Error

Promise 链式操作

Promise.resolve()
  .then(function a() {
    Promise.resolve().then(function c() {});
  })
  .then(function b() {
    Promise.resolve().then(function d() {});
  });
复制代码

在微任务执行期间,如果又产生新的微任务,此时新产生的微任务会入队到微任务队列中。在新的微任务被执行完后,才会执行后续的操作。

此时,就会存在一些不知名的bug。

function foo() {
  return Promise.resolve().then(foo)
}
foo()
复制代码

当执行 foo 函数时,foo 函数中调用了 Promise.resolve(),触发一个微任务。V8 会将该微任务添加进微任务队列中,退出当前 foo 函数的执行。

V8 在准备退出当前的宏任务之前检查微任务队列,微任务队列中有一个微任务,先执行微任务。由于这个微任务就是调用 foo 函数本身,执行微任务的过程中,需要继续调用 foo 函数,在执行 foo 函数的过程中,又会触发了同样的微任务。

这个循环就会一直持续下去,当前的宏任务无法退出,消息队列中其他的宏任务是无法被执行的,比如通过鼠标键盘所产生的事件,事件会一直保存在消息队列中,页面无法响应这些事件,页面卡死

Promise 链式操作

后记

分享是一种态度,这篇文章,主要是根据一个可视化工具进行代码分析的。 如果,自己想要实践一下,可以自行验证。

参考资料:

  1. Vue.nextTick
  2. JS高级程序设计(4)
  3. Google V8

看都看到这里了,那就劳烦,动动小手手,一键三连哇

猜你喜欢

转载自juejin.im/post/7080512254445256718
今日推荐