浏览器、前端原理

浏览器原理3:数组存储

2022-03-03  本文已影响0人  刘佳阔

[toc]

示例代码

function foo(){
    var a = {name:"极客时间"}
    var b = a
    a.name = "极客邦" 
    console.log(a)
    console.log(b)
}
foo()
输出
{name: '极客邦'}
{name: '极客邦'}

function foo(){
    var a = 1
    var b = a
    a = 2
    console.log(a)
    console.log(b)
}
foo()
输出
2
1

仅改变了a中name的属性值,但是最终a和b打印出来的值都被改变了。像是ab都引用了 同样的对象,但是对与常量,却是分别打印不同的值。

image-20220227155212166

JavaScript是一种弱类型的、动态的语言。那这些特点意味着什么呢?

image-20220227155318713

内存模型

JavaScript的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间堆空间

function foo(){
    var a = "极客时间"
    var b = a
    var c = {name:"极客时间"}
    var d = c
}
foo()

原始类型的数据值都是直接保存在“栈”中的

引用类型的值是存放在“堆”中的然后再栈上产生一个引用。

image-20220227170019798

为什么设计堆和栈?

这是因为JavaScript引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。比如文中的foo函数执行结束了,JavaScript引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,foo函数执行上下文栈区空间全部回收。

通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间

赋值操作原则

在JavaScript中,赋值操作和其他语言有很大的不同,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址

image-20220227170030392

闭包合赋值操作的结合

function foo() {
    var myName = "极客时间"
    let test1 = 1
    const test2 = 2
    var innerBar = { 
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName("极客邦")
bar.getName()
console.log(bar.getName())
image-20220227162405782
  1. 当JavaScript引擎执行到foo函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数setName,JavaScript引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了foo函数中的myName变量,由于是内部函数引用了外部函数的变量,所以JavaScript引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript是无法访问的),用来保存myName变量。
  3. 接着继续扫描到getName方法时,发现该函数内部还引用变量test1,于是JavaScript引擎又将test1添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了myName和test1两个变量了。
  4. 由于test2并没有被内部函数引用,所以test2依然保存在调用栈中。

垃圾回收

回收栈的数据

实例
function foo(){
    var a = 1
    var b = {name:"极客邦"}
    function showName(){
      var c = 2
      var d = {name:"极客时间"}
    }
    showName()
}
foo()
内存结构
image-20220227163401618

原始类型的数据被分配到栈中,引用类型的数据会被分配到堆中,当foo函数执行结束之后,foo函数的执行上下文会从堆中被销毁掉。

执行到showName函数时,一个记录当前执行状态的指针(称为ESP),指向调用栈中showName函数的执行上下文,表示当前正在执行showName函数。

当showName函数执行完成之后,JavaScript会将ESP下移到foo函数的执行上下文,这个下移操作就是销毁showName函数执行上下文的过程

所以说,当一个函数执行结束之后,JavaScript引擎会通过向下移动ESP来销毁该函数保存在栈中的执行上下文

堆中数据回收

栈中通过移动esp指针完成栈的回收,那么栈回收后堆的数据就无效了,如何回收?要回收堆中的垃圾数据,就需要用到JavaScript中的垃圾回收器了

代际假说

代际假说有以下两个特点:

V8中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。新生区通常只支持1~8M的容量,而老生区支持的容量就大很多了。

垃圾回收的通用流程

第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。

第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。

第三步是做内存整理。频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。如果需要分配较大连续内存的时候,就有可能内存不足。所以最后一步需要整理这些内存碎片

副垃圾回收器

新生区采用Scavenge算法,新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,对象区域中的垃圾做标记;完成之后,,副垃圾回收器会把这些存活的对象复制到空闲区域中,把对象有序地排列起来,复制过程相当于内存整理操作,复制后空闲区域就没有内存碎片了。对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

image-20220227165340416
主垃圾回收器

老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

主垃圾回收器是采用标记-清除(Mark-Sweep)的算法

标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

接下来就是垃圾的清除过程

image-20220227165731131

对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片,于是又产生了另外一种算法——标记-整理(Mark-Compact),后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动

image-20220227165837381
全停顿

不过由于JavaScript是运行在主线程之上的,执行垃圾回收算法,需要将正在执行的JavaScript脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

image-20220227165921784
增量标记

老生代的垃圾回收而造成的卡顿,V8将标记过程分为一个个的子标记过程,同时让垃圾回收标记和JavaScript应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记

image-20220227170002664

编译器和解释器

编译器和解释器

编译器:在程序执行前将静态代码转为机器码
解释器:在运行时动态将代码解释为机器能理解的代码
image-20220227170343244
  1. 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
  2. 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果

js引擎执行代码

image-20220227170535409

1. 生成抽象语法树(AST)和执行上下文

无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个AST

image-20220227170654390
var myName = "极客时间"
function foo(){
  return 23;
}
myName = "geektime"
foo()

抽象语法树

image-20220227170752561

编译器或者解释器后续的工作都需要依赖于AST,而不是源代码。

生成AST需要经过两个阶段。

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个token

image-20220227170924199

第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的token数据,根据语法规则转为AST。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

2. 生成字节码

其实一开始V8并没有字节码,而是直接将AST转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着Chrome在手机上的广泛普及,特别是运行在512M内存的手机上,内存占用问题也暴露出来了,因为V8需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于AST和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

image-20220227172005916

3. 执行代码

如果有一段第一次执行的字节码,解释器Ignition会逐条解释执行.解释器Ignition除了负责生成字节码还会解释执行字节码。果发现有热点代码(HotSpot,一段代码被重复执行多次),那么后台的编译器TurboFan就会把该段热点的字节码编译为高效的机器码。当再次执行这段被优化的代码时,只需要执行编译后的机器码。

image-20220227172952998

4.优化措施

  1. 提升单次脚本的执行速度,避免JavaScript的长任务霸占主线程,这样可以使得页面快速响应交互;
  2. 避免大的内联脚本,因为在解析HTML的过程中,解析和编译也会占用主线程;
  3. 减少JavaScript文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。

消息队列机制

事件循环机制

如果想处理其他线程的消息,则要有一个消息队列,不断循环从消息队列中取出消息执行,没有消息的时候就暂停线程。

js中渲染主线程会频繁接收到来自于IO线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理,比如接收到资源加载完成的消息后,渲染进程就要着手进行DOM解析了;接收到鼠标点击的消息后,渲染主线程就要开始执行相应的JavaScript脚本来处理该点击事件

image-20220301073912106

这个消息模式是很多不同系统都会采用的方式。

  1. 添加一个消息队列;
  2. IO线程中产生的新任务添加进消息队列尾部;
  3. 渲染主线程会循环地从消息队列头部中读取任务,执行任务。

而对于夸进程的消息,则在主线程对应的进程中有一个单独的线程,把进程发来的消息转发给消息队列,进而被主线程获取

image-20220301074734929
安全退出主线程

定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志,如果设置了,那么就直接中断当前的所有任务,退出线程。

页面使用单线程的缺点

任务缺乏优先级

队列中的任务先进先出,先进的任务必须完成,后边的任务才能开始执行,高优先级的任务无法及时得到响应。

比如监控DOM节点的变化情况(节点的插入、修改、删除等动态变化)通过设置监听来实习会调。因为DOM变化非常频繁,如果每次发生变化的时候,都回掉接口,那么这个当前的任务执行时间会被拉长,从而导致**执行效率的下降**。如果这些DOM变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为要等到队列中其他消息都完成。

我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列。每次从消息队列中取出宏任务执行,执行完后,如果宏任务还有微任务,就执行这些微任务。然后在从队列中取出下一个宏任务。

我们把上边的dom更新做成一系列的微任务队列,提高了异步消息的及时性。

单任务执行时间过长

因为所有的任务都是在单线程中执行的,所以每次只能执行一个任务,而其他任务就都处于等待状态。如果其中一个任务执行时间过久,那么下一个任务就要等待很长时间。

image-20220301080816573

setTimeout原理

代码实现

它就是一个定时器,用来指定某个函数在多少毫秒之后执行它会返回一个整数,表示定时器的编号,同时你还可以通过该编号来取消这个定时器。

function showName(){
  console.log("极客时间")
}
var timerID = setTimeout(showName,200);
延迟消息队列

Chrome中维护一个需要延迟执行消息队列,包括了定时器和Chromium内部一些需要延迟执行的任务。所以当通过JavaScript创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。这个会调任务包含了回调函数showName、当前发起时间、延迟执行时间。

消息循环逻辑
//消息循环伪代码
void ProcessTimerTask(){ //处理延迟消息
  //从delayed_incoming_queue中取出已经到期的定时器任务
  //依次执行这些任务
}

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
  for(;;){
    //执行消息队列中的任务
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    //执行延迟队列中的任务
    ProcessDelayTask()

    if(!keep_running) //如果设置了退出标志,那么直接退出线程循环
        break; 
  }
}
延迟消息处理逻辑

理完消息队列中的一个任务之后,就开始执行ProcessDelayTask函数。ProcessDelayTask函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。

延迟消息删除

其实浏览器内部实现取消定时器的操作也是非常简单的,就是直接从delayed_incoming_queue延迟队列中,通过ID查找到对应的任务,然后再将其从队列中删除掉就可以了。

注意事项

通过setTimeout设置的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成,由于当前这段代码要执行5000次的for循环,所以当前这个任务的执行时间会比较久一点。这势必会影响到下个任务的执行时间

function bar() {
    console.log('bar')
}
function foo() {
    setTimeout(bar, 0);
    for (let i = 0; i < 5000; i++) {
        let i = 5+8+8+8
        console.log(i)
    }
}
foo()

定时器被嵌套调用5次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于4毫秒,那么浏览器会将每次调用的时间间隔设置为4毫秒

image-20220301082213589
var name= 1;
var MyObj = {
  name: 2,
  showName: function(){
    console.log(this.name);
  }
}
setTimeout(MyObj.showName,1000)
//输出1,因为这段代码在编译的时候,执行上下文中的this会被设置为全局window

//解决
//第一种是将MyObj.showName放在匿名函数中执行,如下所示:

//箭头函数
setTimeout(() => {
    MyObj.showName()
}, 1000);
//或者function函数
setTimeout(function() {
  MyObj.showName();
}, 1000)

//第二种是使用bind方法,将showName绑定在MyObj上面,代码如下所示:
setTimeout(MyObj.showName.bind(MyObj), 1000)

XMLHttpRequest原理

XMLHttpRequest提供了从Web服务器获取数据的能力,如果你想要更新某条数据,只需要通过XMLHttpRequest请求服务器提供的接口,就可以获取到服务器的数据,然后再操作DOM来更新页面内容,整个过程只需要更新网页的一部分就可以了

异步回调

就是在主线程调用函数里直接执行传入的callback函数

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    cb()
    console.log('end do work')
}
doWork(callback)
异步回调

就是主线程函数继续执行,callback在未来某个时间点在发生回调

let callback = function(){
    console.log('i am do homework')
}
function doWork(cb) {
    console.log('start do work')
    setTimeout(cb,1000)   
    console.log('end do work')
}
doWork(callback)

上边讲过浏览器页面是通过事件循环机制来驱动的,每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行JavaScript事件、解析DOM事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到事件队列的尾部。所以可以说是消息队列和主线程循环机制保证了页面有条不紊地运行。当循环系统在执行一个任务的时候,会为每个任务维护一个系统调用栈

举例

image-20220301222422617

Parse HTML任务在执行过程中会遇到一系列的子过程,比如在解析页面的过程中遇到了JavaScript脚本,那么就暂停解析过程去执行该脚本,等执行完成之后,再恢复解析过程。

每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数,异步回调是指回调函数在主函数之外执行,一般有两种方式:

XMLHttpRequest运作机制

image-20220301222552185

调用xhr.send来发起网络请求,渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用IPC来通知渲染进程;渲染进程接收到消息之后,会将xhr的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数

xmlhttprequest写法

 function GetWebData(URL){
    /**
     * 1:新建XMLHttpRequest请求对象
     */
    let xhr = new XMLHttpRequest()

    /**
     * 2:注册相关事件回调处理函数 
     */
    xhr.onreadystatechange = function () {
        switch(xhr.readyState){
          case 0: //请求未初始化
            console.log("请求未初始化")
            break;
          case 1://OPENED
            console.log("OPENED")
            break;
          case 2://HEADERS_RECEIVED
            console.log("HEADERS_RECEIVED")
            break;
          case 3://LOADING  
            console.log("LOADING")
            break;
          case 4://DONE
            if(this.status == 200||this.status == 304){
                console.log(this.responseText);}
            console.log("DONE")
            break;
        }
    }
    xhr.ontimeout = function(e) { console.log('ontimeout') }
    xhr.onerror = function(e) { console.log('onerror') }
    /**
     * 3:打开请求
     */
    xhr.open('Get', URL, true);//创建一个Get请求,采用异步

    /**
     * 4:配置参数
     */
    xhr.timeout = 3000 //设置xhr请求的超时时间
    xhr.responseType = "text" //设置响应返回的数据格式
    xhr.setRequestHeader("X_TEST","time.geekbang")
    /**
     * 5:发送请求
     */
    xhr.send();
}

XMLHttpRequest存在问题

跨域问题

会报错 Access to XMLHttpRequest at 'https://time.geekbang.org/' from origin 'https://www.geekbang.org' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

HTTPS混合内容的问题

TTPS混合内容是HTTPS页面中包含了不符合HTTPS安全要求的内容,比如包含了HTTP资源,通过HTTP加载的图像、视频、样式表、脚本等,都属于混合内容。

宏任务

定义

页面中的大部分任务都是在主线程上执行的,这些任务包括了:

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个for循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

执行过程

问题

各种IO的完成事件、执行JavaScript脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,js不能准确控制加入队列的位置,也就很难控制任务开始执行的时间。

image-20220301224400020

两个settimeout之间因为插入了系统任务,会导致第二个回调的时间不可控。

微任务

异步任务的两种方式

第一种是把异步回调函数封装成一个宏任务,添加到消息队列尾部,当循环系统执行到该任务的时候执行回调函数。 setTimeout和XMLHttpRequest的回调函数都是这种方式。

第二种方式的执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

当javascript执行一段脚步的时候,系统会为其创建一个全局上下文,同时会在js引擎内部创建一个微任务队列,用来存放要执行的微任务。也就是说,执行一次宏任务后,会执行微任务队列中的所有微任务,当所有微任务都完成,在执行下一个宏任务。每个宏任务都关联了一个微任务队列。

微任务的产生方式

  1. 使用MutationObserver监控某个DOM节点,然后用js来修改节点。当DOM节点发生变化时,就会产生DOM变化记录的微任务。

  2. 使用Promise,当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。

微任务的执行时间

当宏任务中的js快执行完成时,扫描全局执行上下文中的微任务队列,从队列中遍历执行所有微任务,直到微任务为空,若此时还有新的微任务产生,那么继续执行微任务,直到微任务队列为空。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行

image-20220301225430059

在JavaScript脚本的后续执行过程中,分别通过Promise和removeChild创建了两个微任务,并被添加到微任务列表中。接着JavaScript执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文

总结微任务

监听DOM变化方法演变

监听DOM变化一直是前端工程师一项非常核心的需求。

采用轮询方式,

使用setTimeout定时查询dom是否有改变,有改变就执行回调函数,确定就是间隔太长就会响应不及时,间隔太短而页面没变化就会浪费资源。

观察者模式

通过设置同步的回调callback,使用观察者模式来监听dom变动。缺点是js变化太频繁时会造成性能开销。比如利用JavaScript动态创建50个节点,触发50次回调,每个回调函数需要执行时间,假设每次回调的执行时间是4毫秒,50次回调的是200毫秒,浏览器正在执行一个动画效果,由于Mutation Event触发回调事件,就会导致动画的卡顿。

异步模式

使用MutationObserver将响应函数改成异步调用,可以将多次dom变化的事件和为一次,封装一个结构来记录期间所有变化,触发一次异步调用。缺点是settimeout调用时间无法保证,可能会有别的耗时事件插入。、

微任务模式

在每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8引擎就会按照顺序执行微任务了 ,然后异步执行回调函数

Promise原理

js上已知的异步编程模型,就是把主线程的任务发送给其他线程,然后其他线程完成后给主线程的消息队列发消息,主线程接受回调,任务完成。这就是页面编程的一大特点:异步回调

image-20220302073750428

异步回调的缺点就是大量的回调函数会导致代码逻辑不连贯不符合人的直觉。

//执行状态
function onResolve(response){console.log(response) }
function onReject(error){console.log(error) }

let xhr = new XMLHttpRequest()
xhr.ontimeout = function(e) { onReject(e)}
xhr.onerror = function(e) { onReject(e) }
xhr.onreadystatechange = function () { onResolve(xhr.response) }

//设置请求类型,请求URL,是否同步信息
let URL = 'https://time.geekbang.com'
xhr.open('Get', URL, true);

//设置参数
xhr.timeout = 3000 //设置xhr请求的超时时间
xhr.responseType = "text" //设置响应返回的数据格式
xhr.setRequestHeader("X_TEST","time.geekbang")

//发出请求
xhr.send()

封装异步代码

我们重点关注的是输入内容(请求信息)*和*输出内容(回复信息)**,同封装使代码线性。

//makeRequest用来构造request对象
function makeRequest(request_url) {
    let request = {
        method: 'Get',
        url: request_url,
        headers: '',
        body: '',
        credentials: false,
        sync: true,
        responseType: 'text',
        referrer: ''
    }
    return request
}

//[in] request,请求信息,请求头,延时值,返回类型等
//[out] resolve, 执行成功,回调该函数
//[out] reject  执行失败,回调该函数
function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) { reject(e) }
    xhr.onerror = function (e) { reject(e) }
    xhr.onreadystatechange = function () {
        if (xhr.status = 200)
            resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    //补充其他请求信息
    //...
    xhr.send();
}
//最终调用过程,
XFetch(makeRequest('https://time.geekbang.org'),
    function resolve(data) {
        console.log(data)
    }, function reject(e) {
        console.log(e)
    })

这样比较简洁,但是代码复杂后会有回调地狱的问题

image-20220302075751071

因为上个任务的回调内会处理下一个任务,就会形成嵌套,且每个任务都有成功和失败的两个状态,需要分别处理。

Promise改造

//改造发送请求
function XFetch(request) {
  //这里的两个参数上在promise.then和promise.catch时设置
  function executor(resolve, reject) {
      let xhr = new XMLHttpRequest()
      xhr.open('GET', request.url, true)
       //失败事件处理
      xhr.ontimeout = function (e) { reject(e) }
       //失败事件处理
      xhr.onerror = function (e) { reject(e) }
      xhr.onreadystatechange = function () {
          if (this.readyState === 4) {
              if (this.status === 200) {
                //成功事件处理
                  resolve(this.responseText, this)
              } else {
                  let error = {
                      code: this.status,
                      response: this.response
                  }
                  //失败事件处理
                  reject(error, this)
              }
          }
      }
      xhr.send()
  }
  return new Promise(executor)
}

Promise线性执行

我们再利用XFetch来构造请求流程,代码如下:

var x1 = XFetch(makeRequest('https://time.geekbang.org/?category'))
var x2 = x1.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://www.geekbang.org/column'))
})
var x3 = x2.then(value => {
    console.log(value)
    return XFetch(makeRequest('https://time.geekbang.org'))
})
x3.catch(error => {
    console.log(error)
})

Promise特点

image-20220302082158818

之所以可以使用最后一个对象来捕获所有异常,是因为Promise对象的错误具有“冒泡”性质,会一直向后传递,直到被onReject函数处理或catch语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个Promise对象中单独捕获异常了

Promise与微任务

实例

function executor(resolve, reject) {
    resolve(100)
}
let demo = new Promise(executor)

function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

首先执行new Promise时,Promise的构造函数会被执行。Promise的构造函数会调用Promise的参数executor函数,然后执行resolve函数,resolve函数会回调then函数,然后执行onResolve函数。但是onResolve函数时延时设置的,所以内部肯定是有个延时调用onResolve函数的过程。

原理实现

//模拟promise
function Bromise(executor) {
    var onResolve_ = null
    var onReject_ = null
     //模拟实现resolve和then,暂不支持rejcet
    this.then = function (onResolve, onReject) {
        onResolve_ = onResolve
    };
    this.catch = function(onReject){
        onReject_ = onReject
    }
  //    延时发送消息,使在promise。then执行后在执行回调
    function resolve(value) {
          setTimeout(()=>{
            onResolve_(value)
            },0)
    }
  
     function reject(value) {
          setTimeout(()=>{
            onReject_(value)
            },0)
    }
    executor(resolve, null);
}

//调用
function executor(resolve, reject) {
    resolve(100)
}
//将Promise改成我们自己的Bromsie
let demo = new Bromise(executor)

function onResolve(value){
    console.log(value)
}
demo.then(onResolve)

但是settimeout的延时执行效率低,所以内部是把resolve和reject作为微任务添加到微任务队列中,在每个主人任务完成后执行,这样可以保证resolve和reject 的及时执行。

async|await

ES7 引入了async/await,这是JavaScript异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰

案例:连续访问两个地址,对数据做处理

//promise写法
fetch('https://www.geekbang.org')
      .then((response) => {
          console.log(response)
          return fetch('https://www.geekbang.org/test')
      }).then((response) => {
          console.log(response)
      }).catch((error) => {
          console.log(error)
      })
      
//async/wait写法      
async function foo(){
  try{
    let response1 = await fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = await fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
  }catch(err) {
       console.error(err)
  }
}
foo()      

整个异步处理的逻辑都是使用同步代码的方式来实现的,而且还支持try catch来捕获异常,这就是完全在写同步代码,所以是非常符合人的线性思维的

生成器 VS 协程

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的

function* genDemo() {
    console.log("开始执行第一段")
    yield 'generator 2'

    console.log("开始执行第二段")
    yield 'generator 2'

    console.log("执行结束")
    return 'generator 2'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
//结果
main 0
 开始执行第一段
generator 2
main 1
开始执行第二段
generator 2
main 2
 执行结束
generator 2
 main 3

生成器函数的特性,可以暂停执行,也可以恢复执行。

  1. 在生成器函数内部执行一段代码,如果遇到yield关键字,那么JavaScript引擎将返回关键字后面的内容给外部,并暂停该函数的执行
  2. 外部函数可以通过next方法恢复函数的执行
生成器的原理

生成器就是协程的一种实现方式,下边先解释协程

协程

协程是一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如有ab两个协程,a在运行,当a暂停后,b才可以运行。如果从A协程启动B协程,我们就把A协程称为B协程的父协程

一个线程也可以拥有多个协程。协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。

协程执行过程
image-20220302224914481
总结
  1. 通过调用生成器函数genDemo来创建一个协程gen,创建之后,gen协程并没有立即执行。
  2. 要让gen协程执行,需要通过调用gen.next。
  3. 当协程正在执行的时候,可以通过yield关键字来暂停gen协程的执行,并返回主要信息给父协程。
  4. 如果协程在执行期间,遇到了return关键字,那么JavaScript引擎会结束当前协程,并将return后面的内容返回给父协程。
  5. gen协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过yield和gen.next来配合完成的。
  6. 当在gen协程中调用了yield方法时, js会保存gen协程的调用栈,恢复父协程的调用栈。当父协程调用gen.next后时,js在保存父协程的调用栈,恢复gen函数的调用栈。
协程切换过程
image-20220302225732361
生成器实现promise
//promise写法
fetch('https://www.geekbang.org')
      .then((response) => {
          console.log(response)
          return fetch('https://www.geekbang.org/test')
      }).then((response) => {
          console.log(response)
      }).catch((error) => {
          console.log(error)
      })
     
//改造后
//foo函数是一个生成器函数,在foo函数里面实现了用同步代码形式来实现异步操
function* foo() {
    let response1 = yield fetch('https://www.geekbang.org')
    console.log('response1')
    console.log(response1)
    let response2 = yield fetch('https://www.geekbang.org/test')
    console.log('response2')
    console.log(response2)
}

//执行foo函数的代码
let gen = foo()
function getGenPromise(gen) {
    return gen.next().value
}
getGenPromise(gen).then((response) => {//相当于response1.then
    console.log('response1')
    console.log(response)
    return getGenPromise(gen)
}).then((response) => {
    console.log('response2')
    console.log(response)
})
      

async/await原理

async/await技术背后的秘密就是Promise和生成器应用,往低层说就是微任务和协程应用

async是一个通过异步执行隐式返回 Promise 作为结果的函数。

async
async function foo() {
    return 2
}
console.log(foo())  // Promise {: 2}
await
async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)  
foo()
console.log(3)
//打印 
0
1
3
100
2
执行流程图
image-20220303074758340
过程描述
  1. 先执行console.log,打印0

  2. 执行foo函数,因为有async,会保存当前调用栈,然后切换到协程foo,执行打印1

  3. 接着执行await 100这里比较复杂。

    1. 首先创建一个promise对象,如下,并且把resolve(100)函数交给微任务队列

      let promise_ = new Promise((resolve,reject){
        resolve(100)
      })
      

      然后js会暂停当前协程,将主线程的控制权转交给父协程执行,同时会将promise_对象返回给父协程。

  4. 接下来执行父协程开始执行console.log(3),然后父协程执行结束,在结束前进入微任务的检查点,执行微任务队列中的微任务,里边有resolve(100)等待执行,然后就会触发promise_.then的回调函数。

    1. promise_.then((value)=>{
         //回调函数被激活后
        //将主线程控制权交给foo协程,并将vaule值传给协程
      })
      

    此时又切换回foo协程,并将value也就是100传给该协程,

  5. foo协程激活后,把100赋值给变量a,然后继续向后执行,最后把控制权转给父协程。

总结

用async/await可以实现用同步代码的风格来编写异步代码,这是因为async/await的基础技术使用了生成器和Promise,生成器是协程的实现,利用生成器能实现生成器函数的暂停和恢复。而promise又利用了微任务和延时消息。

async function foo() {
    console.log('foo')
    return 100
}
async function bar() {
    console.log('bar start')
    let a =await foo()
    console.log(a)
    console.log('bar end')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
    console.log('promise executor')
    resolve();
    console.log('promise executor2')
}).then(function () {
    console.log('promise then')
})
console.log('script end')

执行过程

image-20220303082728002
上一篇下一篇

猜你喜欢

热点阅读