从浏览器循环机制构建JS异步体系

我们都知道JavaScript是单线程的,但它却能够处理网络请求、定时器、文件读取等复杂且耗时的时间。实际上JavaScript在遇到耗时的任务的时候都会采用异步机制,这篇文章主要从浏览器机制的原理一点点解析JavaScirpt的异步机制,构建一个比较完整的JavaScript异步知识的体系。

在介绍JavaScript的异步机制前,我们先说说浏览器是如何处理JavaScript任务的.

一、JavaScript引擎线程

在浏览器架构中,浏览器内核是我们的渲染进程,它有多个线程,其中JavaScript引擎线程是负责处理和解析JavaScipt脚本程序,它是单线程的,这也意味着它在某个时间段它只能负责处理一个任务,浏览器通过维护一个消息队列来让它不断地从消息队列中取任务去执行。而JavaScript引擎线程在执行完一个任务后,就会去消息队列里去看看有没有任务,如果有就取出最老的任务去执行。而消息队列的任务则是由I/O线程来添加。I/O线程负责与其他进程信息交换(IPC),读出需要JavaScript引擎执行的任务添加到消息队列末尾。

image.png 这样做可以保证JavaScirpt引擎不断地处理产生的事件,但我们都知道一个著名的问题队头阻塞:假如有一个事件十分耗时,无疑会阻塞排在队列后面的事件,这也是我们为什么需要异步机制的原因——我们需要一种机制来处理耗时较长的JavaScript任务。

二、定时器的异步

定时器的异步比较特殊,他需要一定的时间精度。如果我们把定时器排入到消息队列,毫无疑问是灾难般的抉择——等排在你前面的任务都执行完毕,再执行定时器任务,时间会偏差许多。而浏览器维护了一个延迟队列来解决这个问题。在我们定义一个定时器的时候,它并不会被加入到消息队列,浏览器会用(定时器的回调函数,开始时间,延迟执行时间)创建一个回调任务加入到这个延迟队列

那这个回调任务什么时候执行呢?当JavaScript引擎线程执行完一个宏任务后(消息队列中的任务被叫作宏任务),就会去延迟队列中看看有没有已经到期了的任务(当定时器到了它的结束时间的时,它就算过期了),然后取出它们并执行,这样保证了定时器的一定的时间精度,但这样设计仍然有缺陷:

  • 时间精度并不算很高。因为并不是定时器一到期就会执行它的回调。

image.png

三.异步任务

面对一般的异步任务,我们会把任务完成后执行的操作通过异步回调的方式来执行,执行异步回调的方式一般有两种:

  • 第一种:异步回调函数封装成一个宏任务,添加到消息队列尾部,但循环系统执行到该任务的时候执行回调函数。其中定时器的回调其实也是被封装成了宏任务,只是它没有被添加到消息队列尾部,而是放在了延迟队列中。
  • 第二种:在宏任务的主函数执行结束前,但当前宏任务还未结束的时候执行回调函数。这个等会细说
封装成宏任务

以ajax请求为例子,来讲解这个方式的是怎么运行的

 // 创建一个XMLHttpRequest实例
 let xhr = new XMLHttpRequest();
 ​
 // 将请求发送到服务器,我们使用 XMLHttpRequest 对象的 open() 和 send() 方法
 xhr.open('GET', 'http://domain/service');
 ​
 // 请求状态改变事件
 xhr.onreadystatechange = function () {
     // 请求是否完成?
     if (xhr.readyState !== 4) return;
     if (xhr.status === 200) {
         // request successful - show response
         console.log(xhr.responseText);
     } else {
         // request error
         console.log('HTTP error', xhr.status, xhr.statusText);
     }
 };
 // xhr.error
 // xhr.timeout = 3000; // 3 seconds
 // xhr.ontimeout = () => console.log('timeout', xhr.responseURL);
 ​
 // progress事件可以报告长时间运行的文件上传
 // xhr.upload.onprogress = p => {
 //     console.log(Math.round((p.loaded / p.total) * 100) + '%');
 // }
 ​
 xhr.send();
复制代码

当我们想发起一个ajax请求的时候,这个时间被添加到了消息队列的末尾排队。轮到它的时候,JavaScirpt引擎线程会把需要网络请求的消息传递给O/I线程,O/I线程再把它传递给网络线程,网络线程执行完后才会传递回来,在O/I线程受到消息后,它会把回调任务封装成宏任务添加到消息队列,它会把回调函数封装成一个宏任务重新进入消息队列排队。在重新轮到它的时候,会根据传递回来的状态进行回调处理。

如图所示:

image.png

回调函数则会根据传回来的数据进行状态判断,然后执行相应的回调函数。

封装成微任务

之前说了,第二种执行异步任务的方式是在宏任务的主函数执行结束前,但还没有结束的时候执行回调函数。实际上浏览器不仅仅维护了宏任务队列,还封装了一个微任务队列。这是因为我们有时候需要执行一些高实时性的操作,而宏任务颗粒度太大了(中间间隔时间还会干一些别的事情),微任务应需而生。每一个宏任务都会关联一个微任务队列,把自己宏任务产生的微任务都放进这个微任务队列。微任务产生主要有两个原因:

  • 通过观测者模式监控某个Dom节点,然后JavaScript修改这个DOM节点的时候,产生的回调函数。
  • 使用Promise的时候也产生微任务。

Promise避免了回调地狱,是非常优秀的设计,这里不再赘述。主要说说Promise为什么需要微任务。

我们可以通过一个Promise代码来回答这个问题。

 function executor(resolve, reject) 
 { 
     resolve(100)
 }
 let myPromise = new Promise(executor)
 function onResolve(value){
     console.log(value)
 }
 myPromise.then(onResolve)
复制代码

我们看看这段代码里Promise做了什么:

  • 执行executor的函数
  • 发现执行resolve函数,通过.then找到onResolve函数,并把onResolve函数赋给一个promise作用域的一个变量,方便在resolve中调用
  • 在resolve中调用变量引用的onResolve的函数

问题来了,按照我们的想法,它先把这个函数赋值好,再执行resolve函数,打印出value的值。

但事实上,在我们执行这个函数的时候,.then还没来得及为onResolve赋值,所以我们需要延迟执行resolve中的函数调用

 //有兴趣的同学可以自行查阅 这里是简写
 let onResolve_ = null
 this.then = function (onResolve, onReject) 
 { 
     onResolve_ = onResolve 
 };
 function resolve(value){
     setTimeout(() => {
       onResolve_(value);
    },0);
 }
复制代码

不过使用定时器的效率并不是太高,好在我们有微任务,所以 Promise 又把这个定时器改造成了微任务了,这样既可以让 resolve_ 延时被调用,又提升了代码的执行效率。

Async/await 用同步的方法来执行异步任务

async/await是在ES7中提出的语法糖,是基于Promise的封装,我们这里主要讲讲它是如何实现的,首先来说说生成器。

生成器(Generator)

生成器是ES6提出的语法,它提供了我们操作代码协程的能力.协程是一种比线程更加轻量级的存在,它可以分派跑在线程上的代码,但一个线程上只能执行一个协程。它提供了我们从一段代码中跳出,中止执行,然后再回去继续执行这段代码的能力。

 //生成器
 function* generator() {
     console.log("1")
     yield 'generator 1'
 ​
     console.log("3")
     yield 'generator 2'
 ​
     console.log("5")
     yield 'generator 3'
 ​
     console.log("7")
     return 'generator 4'
 }
 ​
 let gen = generator()
 console.log(gen.next().value)
 console.log('2')
 console.log(gen.next().value)
 console.log('4')
 console.log(gen.next().value)
 console.log('6')
 console.log(gen.next().value)
 console.log('8')
复制代码

执行结果:

image.png

当我们理解协程是怎么样的,理解async,await就很简单了:

  • async 实际上就是声明这个函数是一个生成器函数

  • await 实际上就是yield会跳出这个函数,进入另一个协程

     //example
     async function foo(){
         let result = await fetch('url')
         return result
     }
     let result = foo()
     ​
     function *foo(){
         let result = yield fetch("url")
         return result
     }
     let gen = foo()
     //用promise封装,任务完成后继续执行
     function continue(gen){
         return gen.next().value
     }
     //网络请求是promise对象,成功后继续执行下面的代码
     gen.then((res)=>{
         return continue(gen)
     })
    复制代码

总结

本文主要介绍了现代异步机理和背后的原理,主要有这些知识点:

  • 消息队列,宏任务
  • 定时器异步机制
  • ajax请求发生了什么
  • promise为什么要微任务
  • async/await背后的原理

参考资料

Guess you like

Origin juejin.im/post/7034141273221136414
Recommended