美文网首页
你真的懂异步编程吗?

你真的懂异步编程吗?

作者: 西岭老湿 | 来源:发表于2021-01-14 09:48 被阅读0次
    为什么要学习异步编程?

    在JS 代码中,异步无处不在,Ajax通信,Node中的文件读写等等等,只有搞清楚异步编程的原理和概念,才能在JS的世界中任意驰骋,随便撒欢;

    单线程 JavaScript 异步方案

    首先我们需要了解,JavaScript 代码的运行是单线程,采用单线程模式工作的原因也很简单,最早就是在页面中实现 Dom 操作,如果采用多线程,就会造成复杂的线程同步问题,如果一个线程修改了某个元素,另一个线程又删除了这个元素,浏览器渲染就会出现问题;
    单线程的含义就是: JS执行环境中负责执行代码的线程只有一个;就类似于只有一个人干活;一次只能做一个任务,有多个任务自然是要排队的;
    优点:安全,简单
    缺点:遇到任务量大的操作,会阻塞,后面的任务会长时间等待,出现假死的情况;


    image-20201224170055928.gif

    为了解决阻塞的问题,Javascript 将任务的执行模式分成了两种,同步模式( Synchronous)和 异步模式( Asynchronous)
    后面我们将分以下几个内容,来详细讲解 JavaScript 的同步与异步:
    1、同步模式与异步模式
    2、事件循环与消息队列
    3、异步编程的几种方式
    4、Promise 异步方案、宏任务/微任务队列
    5、Generator 异步方案、 Async / Await语法糖

    同步与异步

    代码依次执行,后面的任务需要等待前面任务执行结束后,才会执行,同步并不是同时执行,而是排队执行;
    先来看一段代码:

    console.log('global begin')
    function bar () {
      console.log('bar task')
    }
    function foo () {
      console.log('foo task')
      bar()
    }
    foo()
    console.log('global end')
    

    动画形式展现 同步代码 的执行过程:

    image-20201224190320238.gif

    代码会按照既定的语法规则,依次执行,如果中间遇到大量复杂任务,后面的代码则会阻塞等待;

    再来看一段异步代码:

    console.log('global begin')
    
    setTimeout(function timer1 () {
      console.log('timer1 invoke')
    }, 1800)
    
    setTimeout(function timer2 () {
      console.log('timer2 invoke')
      setTimeout(function inner () {
        console.log('inner invoke')
      }, 1000)
    }, 1000)
    
    console.log('global end')
    

    异步代码的执行,要相对复杂一些:


    image-20201224190320240.gif

    代码首先按照同步模式执行,当遇到异步代码时,会开启异步执行线程,在上面的代码中,setTimeout 会开启环境运行时的执行线程运行相关代码,代码运行结束后,会将结果放入到消息队列,等待 JS 线程结束后,消息队列的任务再依次执行;

    流程图如下:


    clipboard.png
    回调函数

    通过上图,我们会看到,在整个代码的执行中,JS 本身的执行依然是单线程的,异步执行的最终结果,依然需要回到 JS 线程上进行处理,在JS中,异步的结果 回到 JS 主线程 的方式采用的是 “ 回调函数 ” 的形式 , 所谓的 回调函数 就是在 JS 主线程上声明一个函数,然后将函数作为参数传入异步调用线程,当异步执行结束后,调用这个函数,将结果以实参的形式传入函数的调用(也有可能不传参,但是函数调用一定会有),前面代码中 setTimeout 就是一个异步方法,传入的第一个参数就是 回调函数,这个函数的执行就是消息队列中的 “回调”;

    下面我们自己封装一个 ajax 请求,来进一步说明回调函数与异步的关系

    Ajax 的异步请求封装
    function myAjax(url,callback) {
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function () {
            if (this.readyState == 4) {
                if (this.status == 200) {
                    // 成功的回调
                    callback(null,this.responseText)
                } else {
                    // 失败的回调
                    callback(new Error(),null);
                }
            }
        }
        xhr.open('get', url)
        xhr.send();
    }
    

    上面的代码,封装了一个 myAjax 的函数,用于发送异步的 ajax 请求,函数调用时,代码实际是按照同步模式执行的,当执行到 xhr.send() 时,就会开启异步的网络请求,向指定的 url 地址发送网络请求,从建立网络链接到断开网络连接的整个过程是异步线程在执行的;换个说法就是 myAjax 函数执行到 xhr.send() 后,函数的调用执行就已经结束了,如果 myAjax 函数调用的后面有代码,则会继续执行,不会等待 ajax 的请求结果;
    但是,myAjax 函数调用结束后,ajax 的网络请求却依然在进行着,如果想要获取到 ajax 网络请求的结果,我们就需要在结果返回后,调用一个 JS 线程的函数,将结果以实参的形式传入:

    myAjax('./d1.json',function(err,data){
        console.log(data);
    })
    

    回调函数让我们轻松处理异步的结果,但是,如果代码是异步执行的,而逻辑是同步的; 就会出现 “回调地狱”,举个栗子:
    代码B需要等待代码A执行结束才能执行,而代码C又需要等待代码B,代码D又需要等待代码C,而代码 A、B、C都是异步执行的;

    // 回调函数 回调地狱 
    myAjax('./d1.json',function(err,data){
        console.log(data);
        if(!err){
            myAjax('./d2.json',function(err,data){
                console.log(data);
                if(!err){
                    myAjax('./d3.json',function(){
                        console.log(data);
                    })
                }
            })
        }
    })
    

    没错,代码执行是异步的,但是异步的结果,是需要有强前后顺序的,著名的"回调地狱"就是这么诞生的;

    相对来说,代码逻辑是固定的,但是,这个编码体验,要差很多,尤其在后期维护的时候,层级嵌套太深,让人头皮发麻;
    如何让我们的代码不在地狱中受苦呢?

    有请 Promise 出山,拯救程序员的头发;

    Promise
    Snipaste_2020-11-20_14-00-99.gif

    Promise 译为 承诺、许诺、希望,意思就是异步任务交给我来做,一定(承诺、许诺)给你个结果;在执行的过程中,Promise 的状态会修改为 pending ,一旦有了结果,就会再次更改状态,异步执行成功的状态是 Fulfilled , 这就是承诺给你的结果,状态修改后,会调用成功的回调函数 onFulfilled 来将异步结果返回;异步执行成功的状态是 Rejected, 这就是承诺给你的结果,然后调用 onRejected 说明失败的原因(异常接管);

    将前面对 ajax 函数的封装,改为 Promise 的方式;

    Promise 重构 Ajax 的异步请求封装
    function myAjax(url) {
        return new Promise(function (resolve, reject) {
            var xhr = new XMLHttpRequest();
            xhr.onreadystatechange = function () {
                if (this.readyState == 4) {
                    if (this.status == 200) {
                        // 成功的回调
                        resolve(this.responseText)
                    } else {
                        // 失败的回调
                        reject(new Error());
                    }
                }
            }
    
            xhr.open('get', url)
            xhr.send();
        })
    }
    

    还是前面提到的逻辑,如果返回的结果中,又有 ajax 请求需要发送,可一定记得使用链式调用,不要在then中直接发起下一次请求,否则,又是地狱见了:

     //  ==== Promise 误区====
    myAjax('./d1.json').then(data=>{
        console.log(data);
        myAjax('./d2.json').then(data=>{
            console.log(data)
            // ……回调地狱……
        })
    })
    

    链式的意思就是在上一次 then 中,返回下一次调用的 Promise 对象,我们的代码,就不会进地狱了;

    myAjax('./d1.json')
        .then(data=>{
        console.log(data);
        return myAjax('./d2.json')
    })
        .then(data=>{
        console.log(data)
        return myAjax('./d3.json')
    })
        .then(data=>{
        console.log(data);
    })
        .catch(err=>{
        console.log(err);
    })
    

    虽然我们脱离了回调地狱,但是 .then 的链式调用依然不太友好,频繁的 .then 并不符合自然的运行逻辑,Promise 的写法只是回调函数的改进,使用then方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆 then,原来的语义变得很不清楚。于是,在 Promise 的基础上,Async 函数来了;

    终极异步解决方案,千呼万唤的在 ES2017中发布了;

    Async/Await 语法糖

    Async 函数使用起来,也是很简单,将调用异步的逻辑全部写进一个函数中,函数前面使用 async 关键字,在函数中异步调用逻辑的前面使用 await ,异步调用会在 await 的地方等待结果,然后进入下一行代码的执行,这就保证了,代码的后续逻辑,可以等待异步的 ajax 调用结果了,而代码看起来的执行逻辑,和同步代码几乎一样;

    async function callAjax(){
         var a = await myAjax('./d1.json')
         console.log(a);
         var b = await myAjax('./d2.json');
         console.log(b)
         var c = await myAjax('./d3.json');
         console.log(c)
     }
    callAjax();
    

    注意:await 关键词 只能在 async 函数内部使用

    因为使用简单,很多人也不会探究其使用的原理,无非就是两个 单词,加到前面,用就好了,虽然会用,日常开发看起来也没什么问题,但是一遇到 Bug 调试,就凉凉,面试的时候也总是知其然不知其所以然,咱们先来一个面试题试试,你看你能运行出正确的结果吗?

    async 面试题

    请写出以下代码的运行结果:

    setTimeout(function () {
        console.log('setTimeout')
    }, 0)
    
    async function async1() {
        console.log('async1 start')
        await async2();
        console.log('async1 end')
    }
    
    async function async2() {
        console.log('async2')
    }
    
    console.log('script start')
    
    async1();
    
    console.log('script end')
    

    答案我放在最后面,你也可以自己写出来运行一下;
    想要把结果搞清楚,我们需要引入另一个内容:Generator 生成器函数;
    Generator 生成器函数,返回 遍历器对象,先看一段代码:

    Generator 基础用法
    function * foo(){
        console.log('test');
        // 暂停执行并向外返回值 
        yield 'yyy'; // 调用 next 后,返回对象值
        console.log(33);
    }
    
    // 调用函数 不会立即执行,返回 生成器对象
    const generator =  foo();
    
    // 调用 next 方法,才会 *开始* 执行 
    // 返回 包含 yield 内容的对象 
    const yieldData = generator.next();
    
    console.log(yieldData) //=> {value: "yyy", done: false}
    // 对象中 done ,表示生成器是否已经执行完毕
    // 函数中的代码并没有执行结束
    
    // 下一次的 next 方法调用,会从前面函数的 yeild 后的代码开始执行
    console.log(generator.next()); //=> {value: undefined, done: true}
    

    你会发现,在函数声明的地方,函数名前面多了 * 星号,函数体中的代码有个 yield ,用于函数执行的暂停;简单点说就是,这个函数不是个普通函数,调用后不会立即执行全部代码,而是在执行到 yield 的地方暂停函数的执行,并给调用者返回一个遍历器对象,yield 后面的数据,就是遍历器对象的 value 属性值,如果要继续执行后面的代码,需要使用 遍历器对象中的 next() 方法,代码会从上一次暂停的地方继续往下执行;
    是不是so easy 啊;
    同时,在调用next 的时候,还可以传递参数,函数中上一次停止的 yeild 就会接受到当前传入的参数;

    function * foo(){
        console.log('test');
        // 下次 next 调用传参接受
        const res = yield 'yyy'; 
        console.log(res);
    }
    
    const generator =  foo();
    
    // next 传值 
    const yieldData = generator.next();
    console.log(yieldData) 
    
    // 下次 next 调用传参,可以在 yield 接受返回值
    generator.next('test123');
    

    Generator 的最大特点就是让函数的运行,可以暂停,不要小看他,有了这个暂停,我们能做的事情就太多,在调用异步代码时,就可以先 yield 停一下,停下来我们就可以等待异步的结果了;那么如何把 Generator 写到异步中呢?

    Generator 异步方案

    将调用ajax的代码写到 生成器函数的 yield 后面,每次的异步执行,都要在 yield 中暂停,调用的返回结果是一个 Promise 对象,我们可以从 迭代器对象的 value 属性获取到Promise 对象,然后使用 .then 进行链式调用处理异步结果,结果处理的代码叫做 执行器,就是具体负责运行逻辑的代码;

    function ajax(url) {
        ……
    }
    
    // 声明一个生成器函数
    function * fun(){
        yield myAjax('./d1.json')
        yield myAjax('./d2.json')
        yield myAjax('./d3.json')
    }
    
    // 返回 遍历器对象 
    var f = fun();
    // 生成器函数的执行器 
    // 调用 next 方法,执行异步代码
    var g = f.next();
    g.value.then(data=>{
        console.log(data);
        // console.log(f.next());
        g = f.next();
        g.value.then(data=>{
            console.log(data)
            // g.......
        })
    })
    

    而执行器的逻辑中,是相同嵌套的,因此可以写成递归的方式对执行器进行改造:

    // 声明一个生成器函数
    function * fun(){
        yield myAjax('./d1.json')
        yield myAjax('./d2.json')
        yield myAjax('./d3.json')
    }
    
    // 返回 遍历器对象 
    var f = fun();
    // 递归方式 封装
    // 生成器函数的执行器
    function handle(res){
        if(res.done) return;
        res.value.then(data=>{
            console.log(data)
            handle(f.next())
        })
    }
    handle(f.next());
    

    然后,再将执行的逻辑,进行封装复用,形成独立的函数模块;

    function co(fun) {
        // 返回 遍历器对象 
        var f = fun();
        // 递归方式 封装
        // 生成器函数的执行器
        function handle(res) {
            if (res.done) return;
            res.value.then(data => {
                console.log(data)
                handle(f.next())
            })
        }
        handle(f.next());
    }
    
    co(fun);
    

    封装完成后,我们再使用时,只需要关注 Generator 中的 yield 部分就行了

    function co(fun) {
        ……
    }
    
    function * fun(){
        yield myAjax('./d1.json')
        yield myAjax('./d2.json')
        yield myAjax('./d3.json')
    }
    

    此时你会发现,使用 Generator 封装后,异步的调用就变的非常简单了,但是,这个封装还是有点麻烦,有大神帮我们做了这个封装,相当强大:https://github.com/tj/co ,感兴趣看一研究一下,而随着 JS 语言的发展,更多的人希望类似 co 模块的封装,能够写进语言标准中,我们直接使用这个语法规则就行了;

    其实你也可以对比一下,使用 co 模块后的 Generator 和 async 这两段代码:

    //  async / await 
    async function callAjax(){
         var a = await myAjax('./d1.json')
         console.log(a);
         var b = await myAjax('./d2.json');
         console.log(b)
         var c = await myAjax('./d3.json');
         console.log(c)
     }
     
     // 使用 co 模块后的 Generator
     function * fun(){
        yield myAjax('./d1.json')
        yield myAjax('./d2.json')
        yield myAjax('./d3.json')
    }
    

    你应该也发现了,async 函数就是 Generator 语法糖,不需要自己再去实现 co 执行器函数或者安装 co 模块,写法上将 * 星号 去掉换成放在函数前面的 async ,把函数体的 yield 去掉,换成 await; 完美……

     async function callAjax(){
         var a = await myAjax('./d1.json')
         console.log(a);
         var b = await myAjax('./d2.json');
         console.log(b)
         var c = await myAjax('./d3.json');
         console.log(c)
     }
    callAjax();
    

    我们再来看一下 Generator ,相信下面的代码,你能很轻松的阅读;

    function * f1(){
        console.log(11)
        yield 2;
        console.log('333')
        yield 4;
        console.log('555')
    }
    
    var g = f1();
    g.next();
    console.log(666);
    g.next();
    console.log(777);
    

    代码运行结果:


    image-20201230193712942.png

    带着 Generator 的思路,我们再回头看看那个 async 的面试题;
    请写出以下代码的运行结果:

    setTimeout(function () {
        console.log('setTimeout')
    }, 0)
    
    async function async1() {
        console.log('async1 start')
        await async2();
        console.log('async1 end')
    }
    
    async function async2() {
        console.log('async2')
    }
    
    console.log('script start')
    
    async1();
    
    console.log('script end')
    

    运行结果:


    image-20201230193446596.png

    是不是恍然大明白呢……

    相关文章

      网友评论

          本文标题:你真的懂异步编程吗?

          本文链接:https://www.haomeiwen.com/subject/bhimaktx.html