从Event Loop探究JavaScript异步

·  阅读 1357

前言

最近一直在看React的源码,发现React并发模式下的更新都使用了异步的方式渲染,刚好自己也一直想总结一篇关于event loop的文章,来巩固一下对event loop的理解。

关于浏览器的进程

浏览器是多进程的,每个tab页面是独立的浏览器进程。

浏览器中也包含一个类似于任务管理器的工具, 在Chrome中可以通过菜单->更多工具->任务管理器打开.

1632987725332.jpg

根据图片可以看到,每一个tab页面都是独立的浏览器进程。

浏览器包含的进程:

未命名文件 (1).png

可以看到,浏览器主要包括四个进程:

  1. 浏览器进程:浏览器的主进程,负责协调和控制浏览器,只有一个。
    • 负责浏览器界面显示,与用户交互,如前进,后退等
    • 负责各个页面的管理,创建和销毁其他进程
    • 将Renderer进程得到的内存中的图片绘制到用户界面上
    • 网络资源的管理,下载等
  2. 第三方插件进程:使用第三方插件时对应创建的进程。
  3. GPU进程:最多一个,负责3D图像的绘制。
  4. 渲染进程:也就是前端接触最密切的进程,js的执行,界面的渲染等都在这个进程中执行。

渲染进程

可以看到上图,渲染进程是多线程的:

  1. GUI线程:
    • 只有一个,负责渲染页面,解析HTML、CSS构建render树,布局和绘制等
    • 页面重绘或回流时,该线程都会执行
    • GUI线程与js引擎线程是互斥的,当js引擎线程正在执行时,GUI线程则会被挂起,GUI的更新任务会被保存在一个队列中,等到js引擎线程执行完所有任务时,则会从队列中取出更新任务来执行。
  2. js引擎线程:
    • 也称js内核,负责解析执行js脚本,例如:v8引擎。
    • 在一个Tab页面中js引擎线程只有一个,也就是我们经常所说的js单线程。
    • GUI线程与js引擎线程是互斥的,当js引擎线程正在执行时,GUI线程则会被挂起,GUI的更新任务会被保存在一个队列中,等到js引擎线程执行完所有任务时,则会从队列中取出更新任务来执行。
  3. 定时器线程:
    • 也就是setTimeout和setInterval所在的线程
    • 浏览器的定时器不是由js引擎来计数的,因为js引擎是单线程,如果在定时器前有大量任务在执行,那么需要等待前面所有任务执行完毕才能开始计时,就会导致无法准确的计时。
    • 当计时完毕后,并不会执行定时器的回调事件,而是会将回调事件添加到事件循环的任务队列中,等待js引擎空闲的时候才会去任务队列中取出执行。
  4. 事件触发线程:
    • 事件触发线程归属于浏览器,而不属于 JS 引擎,JS 引擎处理的事务过多,需要浏览器开线程来进行协助,主要负责控制事件循环
    • JS 是采用事件驱动机制来响应用户操作的,事件线程是通过维护事件循环和事件队列等方式,来响应和处理事件。
    • 当处理异步代码的时候,比如声明了一个点击事件,当用户点击的时候,则会将点击事件添加到事件队列的末端,等js引擎空闲了,则会从事件队列中取出执行。
  5. HTTP请求线程:
    • 存在多个
    • 通过创建XMLHttpRequest实例,开启一个新的线程进行请求。
    • 检测到状态变更时,如果设置有回调函数,异步 HTTP 请求线程就会产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行。

js调用栈

在js中当一个函数执行时,会生成一个执行上下文,执行上下文中包含了函数的参数,变量等,javascript任何代码的运行都是在执行上下文中进行的,然后会将执行上下文压入调用栈中,又可以叫作执行上下文栈。当函数执行完毕,则又会将该函数的执行上下文从调用栈中删除。

js是一个单线程的语言,这意味着它只有一个调用栈,所以js一次只能做一件事。

举个例子:

function funA() {
  console.log('A');
}

function funB() {
  funA();
}

funB();
复制代码

执行这段代码会产生如下步骤:

未命名文件 (2).png

注:执行上下文只要在函数被调用时才会产生,每次调用都会生成新的执行上下文。

  1. 第一步调用了funB,生成一个执行上下文,压入调用栈中。
  2. 开始执行funB,进入funB遇到了funA函数,那么则会生成funA的执行上下文压入调用栈中。
  3. 开始执行funA,进入funA遇到console.log函数,那么则会生成console.log的执行上下文压入栈中。
  4. 开始执行console.log,执行完毕后,将console.log的执行上下文从调用栈中删除。
  5. funA中没有代码可执行了,执行完毕,将funA的执行上下文从调用栈中删除。
  6. funB中没有代码可执行了,执行完毕,将funB的执行上下文从调用栈中删除。

调用栈中是否有执行上下文,代表着js引擎是否正在执行,也就是是否是空闲的状态。

事件循环(Event Loop)

上面我们说到,js是在js引擎线程上执行的,一次只能做一件事。如果有一些特别耗时的操作,例如网络请求,I/O等,那么则需要等待上一个任务完成才能做下一个任务,效率十分低,为了解决这个问题,于是就有了事件循环。

事件循环是异步事件执行的一种机制。不同的线程间可以通过一个公共的区域进行通信,这个区域就是事件队列,又叫做任务队列。

在执行js代码时,有的代码是同步执行的,有的则是异步执行的。

例如:

console.log('hello'); // 同步代码,执行到这段代码,控制台会打印出:hello

setTimeout(function() {
  console.log('setTimeout');
}, 1000); // 异步代码,会在1秒后再控制台打印出: setTimeout
复制代码

那么遇到异步代码时,js是如何执行的呢?

当遇到异步代码时,会先将异步事件添加到事件队列中,当调用栈中所有事件都执行完成后,事件循环会从事件队列中取出事件,放入调用栈开始执行,这个过程是循环不断的,所以叫做事件循环。

事件循环在其中主要的两个作用:

  1. 监听调用栈,检查调用栈是否为空
  2. 当调用栈为空,从事件队列中取出事件放入调用栈中执行

未命名文件 (3).png

定时器

关于定时器,其实定时器并不是异步,只是在调用定时器,如:setTimeout时,会调用定时器线程开始倒计时,当倒计时结束,定时器线程会将回调函数放入事件队列中,当调用栈为空时,才会执行定时器回调函数。如果在倒计时结束时,事件队列或者调用栈中还有很多任务没有执行,那么则会导致定时器回调函数不会立即执行,无法保证定时器计时的准确性。

宏任务(MacroTask)和微任务(MicroTask)

在异步代码中,又分为宏任务和微任务,因此事件循环中则又会存在相应任务的任务队列:宏任务队列和微任务队列。

宏任务可以理解为每次在执行栈中所执行的代码,在一个宏任务结束后,下一个宏任务开始执行前,浏览器会对页面进行渲染。宏任务包括:

  • script(可以理解为同步代码)
  • setTimeout/setInterval
  • UI交互事件
  • UI渲染
  • I/O
  • postMassage
  • MassageChannel
  • setImmediate

微任务可以理解为,当前宏任务执行完成后,在页面渲染前所执行的任务。微任务包括:

  • Promise
  • Object.observe
  • MutaionObserver
  • process.nextTick

宏任务与微任务的执行顺序:

  1. 首先执行同步代码,遇到微任务则将任务放入微任务队列中,本次事件循环遇到的微任务都会在这次循环中被执行,遇到宏任务的话,会添加到宏任务队列中,在下一次事件循环执行。
  2. 检查微任务队列中是否有任务,有的话会将微任务依次取出放入调用栈执行,直到微任务队列清空为止。
  3. 当微任务全部执行完毕,浏览器会检查是否有页面更新,有的话则会进行页面渲染。
  4. 渲染完成后,检查宏任务队列中是否有任务,有的话则取出一个放入调用栈中执行
  5. 当取出的宏任务执行完成后,则又从步骤2开始进行循环执行。

未命名文件 (4).png

例子

下面,我们通过代码来验证上面所说的:

console.log('1===========');

// setTimeout1
setTimeout(function timeout1() {
  console.log('2===========');
  new Promise(function promiseTest(resolve) {
    console.log('3===========');
    resolve();
  }).then(function promiseThenTest() {
    console.log('4===========');
  });
}, 0);

// setTimeout2
setTimeout(function timeout2() {
  console.log('5===========');
}, 0);

new Promise(function promiseTest(resolve) {
  console.log('6===========');
  resolve();
}).then(function promiseThenTest() {
  console.log('7===========');
});

console.log('8===========');
        
复制代码

执行结果为:

1===========
6===========
8===========
7===========
2===========
3===========
4===========
5===========
复制代码

我们来分析一下:

  1. 首先执行代码遇到console.log('1===========');,这段代码为同步代码,所以执行后控制台打印出:1===========
  2. 然后接着向下执行,遇到setTimeout1setTimeout是宏任务,放入宏任务队列
  3. 接着向下执行,遇到setTimeout2,也放入宏任务队列
  4. 继续向下执行,遇到PromisePromise实例化的回调函数为同步代码,所以执行后控制台打印出:6===========then方法则会放入微任务队列中
  5. 最后遇到console.log('8===========');,这段代码为同步代码,所以执行后控制台打印出:8===========。到这,同步代码执行完毕
  6. 然后开始从微任务队列中取出任务执行,也就是会执行Promise.then方法,控制台打印出:7===========
  7. 此时已经没有微任务了,浏览器会判断是否需要渲染界面,完成后,然后进行下一次的事件循环
  8. 从宏任务队列中取出setTimeout1,执行回调函数,遇到console.log('2===========');,控制台打印:2===========,然后遇到PromisePromise实例化的回调函数为同步代码,所以执行后控制台打印出:3===========, then方法放入微任务队列中.
  9. 此时,setTimeout1的回调函数执行完毕,也就是宏任务执行完毕,则又会去微任务队列中拿出任务执行,也就是上一步中setTimeout1中的Promise.then方法,控制台打印出:4===========
  10. setTimeout1中的Promise.then方法执行完成后,微任务队列为空,浏览器并不需要渲染,则直接开启下一次事件循环。
  11. 从宏任务队列中拿出setTimeout2,执行回调函数,控制台打印:5===========

注意:在一次事件循环中,如果遇到新的宏任务,那么它会在下一次事件循环才会执行,如上面例子中的setTimeout1setTimeout2

我们再举一个例子,来验证:在一次事件循环中,如果遇到新的宏任务,那么它会在下一次事件循环才会执行:

    
  <div id="outer" style="width: 100px; height: 100px; background: yellow;">
      <div id="inner" style="width: 100px; height: 100px; background: red;"/>
  </div>
    
  var inner = document.getElementById('inner');
  var outer = document.getElementById('outer');

  outer.onclick = function () {
    console.log('outer=========');
    setTimeout(() => {
      console.log('outer=========setTimeout');
    }, 0);

    new Promise(function outerPromise(resolve) {
      console.log('outerPromise===========');
      resolve();
    }).then(function promiseThenTest() {
      console.log('outerPromise===========then');
    });
  }

  inner.onclick = function () {

    console.log('1===========');

    setTimeout(function timeout2() {
      console.log('2===========');
    }, 0);

    new Promise(function promiseTest(resolve) {
      console.log('3===========');
      resolve();
    }).then(function promiseThenTest() {
      console.log('4===========');
    });

    console.log('5===========');

  };
复制代码

这是一个事件冒泡的例子,最后的结果是:

1===========
3===========
5===========
4===========
outer=========
outerPromise===========
outerPromise===========then
2===========
outer=========setTimeout
复制代码

我们来详细的分析一下:

  1. 在点击inner元素后,首先进行了事件冒泡,事件触发线程依次将点击事件添加至宏任务队列中。
  2. 然后取出inner元素的点击事件执行,开始执行,然后遇到setTimeout(宏任务)和Promise(微任务),放入各自的任务队列,执行同步代码并打印出:1,3,5,点击事件执行完毕后(宏任务),然后执行微任务,打印出:4。
  3. 此时微任务清空,浏览器也不需要渲染,则进行下一次事件循环。
  4. 从宏任务队列中取出outer的点击事件执行,开始执行,然后遇到setTimeout(宏任务)和Promise(微任务),放入各自的任务队列,执行同步代码并打印出:outer=========,outerPromise===========,点击事件执行完毕后(宏任务),然后执行微任务,打印出:outerPromise===========then
  5. 此时微任务清空,浏览器也不需要渲染,则进行下一次事件循环。
  6. 根据上面的顺序,最前面的宏任务,是inner点击事件中的setTimeout,取出并执行,打印出:2
  7. 宏任务执行完毕,检查微任务队列中是否有任务,现在并没有任务,浏览器也不需要渲染,则进行下一次事件循环。
  8. 从宏任务队列中取出outer点击事件中的setTimeout执行,打印出:outer=========setTimeout
分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改