JS异步编程(二)Promise对象、Generator函数、async & await

621 阅读10分钟

前言

Javascript是异步编程语言,所以避免不了回调函数的使用。前端开发中经常遇到下面这样的场景:

    $.ajax({
      url: '/***',
      success: function (res) {
        var xxId = res.id
        // 下一个接口的参数需要这个ID
        $.ajax({
          url: '/***',
          data: {
            xxId: xxId, // 使⽤上⼀个请求结果作为参数调⽤下⼀个接⼝
          },
          success: function (res1) {
            
          }
        })
      }
    })

使用ajax请求接口的时候,想要保证多个接口按照指定顺序执行,就需要这样编写代码,需要处理过多的异步回调的时候,需要更多层的嵌套,就会形成回调地狱。这时候代码将很难维护。

ES6中,提出了Promise对象,很好的解决了回调地狱的问题。Promise也是js中使用最广泛的处理异步操作的方法。

关于回调函数

被作为实参传入另一函数,并在该外部函数内被调用,用以来完成某些任务的函数,称为回调函数。

    function greeting(name) {
      alert('Hello ' + name);
    }

    function processUserInput(callback) {
      var name = prompt('请输入你的名字。');
      callback(name);
    }

    processUserInput(greeting);

同步回调,直接执行。

    var a = 0
    setTimeout(function () {
      a = 1
    }, 1000)
    // 异步回调
    setTimeout(function () {
      console.log(a)
    }, 2000)

上述代码是异步回调

JS中的回调函数结构,默认是同步的结构,由于JavaScript单线程异步模型的规则,如果想要编写异步的代码,必须使⽤回调嵌套的形式才能实现,所以回调函数结构不⼀定是异步代码,但是异步代码⼀定是回调函数结构。

Promise

Promise是一个对象,它代表了一个异步操作的最终完成或者失败。

实例化

    // 实例化一个Promise对象
    let p = new Promise(function (resolve, reject) {
        resolve("成功!!!");
        // reject("失败");
    })

new Promise时,传入的回调函数是同步的。回调函数中调用了resolve(),表示操作成功完成,在链式调用时就会执行.then;如果调用reject(),表示操作失败,在链式调用时就会执行.catch

链式调用:

    p.then(function () {
      console.log('then执⾏----', res)
    }).catch(function () {
      console.log('catch')
    }).finally(function () {
      console.log('finally执⾏')
    })

.then .catch .finally的回调函数都是异步的。.finally无论在操作失败还是成功时都会被调用。

Promise 的三种状态

  • pending:初始状态,这是在Promise对象定义初期的状态,这时Promise仅仅做了初始化并注册了他对象上所有的任务。

  • fulfilled:已完成,通常代表成功执⾏了某⼀个任务,当初始化函数中的resolve执⾏时,Promise的状态就变更为fulfilled,并且then函数注册的回调函数会开始执⾏,resolve中传递的参数会进⼊回调函数作为形参。

  • rejected:已拒绝,通常代表执⾏了⼀次失败任务,或者流程中断,当调⽤reject函数时,catch注册的回调函数就会触发,并且reject中传递的内容会变成回调函数的形参。

三种状态的关系:

Promise中约定,当对象创建之后同⼀个Promise对象只能从pending状态变更为fulfilledrejected中的其中一种,并且状态⼀旦变更就不会再改变,此时Promise对象的流程执⾏完成并且finally函数执⾏。

    let p = new Promise(function (resolve, reject) {
      resolve()
      reject()
    })
    p.then(function (res) {
      console.log('then执⾏')
    }).catch(function () {
      console.log('catch')
    }).finally(function () {
      console.log('finally执⾏')
    })

上述代码执行顺序,then执行 -> finally执行。因为状态一旦变更就不会再改变,resolve() 执行后,状态变为了fulfilled不会再改变。

Promise对象

    let p = new Promise(function (resolve, reject) {
      resolve("promise对象...")
    })
    console.log(p)

执行这段代码控制台会输出:

微信图片_20220113153245.png

  • [[Prototype]] 代表Promise的原型对象
  • [[PromiseState]] 代表Promise对象当前的状态,与上面说的三种状态对应
  • [[PromiseResult]] 代表Promise对象的值,分别对应resolve或reject传⼊的结果

链式调用

运行下面的代码:

      let p = new Promise(function (resolve, reject) {
        resolve("我是resolve");
      });
      console.log(p);
      p.then(function (res) {
        console.log("1---", res);
      })
        .then(function (res) {
          console.log("2---", res);
          return "步骤2 return返回的值";
        })
        .then(function (res) {
          console.log("3---", res);
          return new Promise(function (resolve) {
            resolve("return new Promise 返回的值");
          });
        })
        .then(function (res) {
          console.log("4---", res);
          return "步骤4 return返回的值";
        })
        .then()
        .then("不返回值")
        .then(function (res) {
          console.log("5---", res);
        });

观察上面代码在控制台输出:

2022.html:52  1--- 我是resolve
2022.html:55  2--- undefined
2022.html:59  3--- 步骤2 return返回的值
2022.html:65  4--- return new Promise 返回的值
2022.html:71  5--- 步骤4 return返回的值

总结:

  • 只要有then()并且触发了resolve,整个链条就会执⾏到结尾,这个过程中的第⼀个回调函数的参数是resolve 传⼊的值
  • 后续的函数返回一个普通变量,则这个变量作为下一个then回调函数的参数
  • 后续的函数返回一个Promise对象,则这个Promise的resolve的结果作为下一个then回调函数的参数
  • 如果没有return 返回,则下一个then回调函数的参数为undefined
  • 如果then中传⼊的不是函数或者未传值,Promise链条并不会中断then的链式调⽤,并且在这之前最后⼀次 的返回结果,会直接进⼊离它最近的正确的then中的回调函数作为参数

中断链式调用

有两种方式可以让then链式中断,如下代码:

      p.then(function (res) {
        console.log(res);
      })
        .then(function (res) {
          //有两种⽅式中断Promise
          // throw('throw抛出异常中断')
          return Promise.reject("reject中断");
        })
        .then(function (res) {
          console.log(res);
        })
        .then(function (res) {
          console.log(res);
        })
        .catch(function (err) {
          console.log(err);
        });

可以使用throw或者return Promise.reject()中断then,从中断开始到catch中间的then都不会执行,最后触发catch函数,流程结束。

Promise相关Api

在说Promise的Api之前,先来解决一下前言中提到的ajax回调地狱的问题,该如何使用Promise解决回调地狱的问题呢?看下面代码:

      let p = new Promise(function (resolve, reject) {
          $.ajax({
            url: "/user",
            success: function (res1) {
                resolve(res1.id);
            },
          });
      });
      // 得到id之后调用下一个接口
      p.then(function(xxId){
           $.ajax({
            url: "/userinfo",
            data: {
              xxId: xxId, // 使⽤上⼀个请求结果作为参数调⽤下⼀个接⼝
            },
            success: function (res2) {},
          });
      })

这样就解决了本文前言中说的回调地狱的问题。下面来看Promise常见的Api:

Promise.all()

根据上面的描述,可以知道,我们可以通过Promise.then来控制异步流程按指定的顺序来执行。假设一个场景,a页面需要调用两个接口都成功返回数据后,再渲染。接口1耗时1s,接口2耗时0.6s,如果使用上面的链式调用就会耗费1.6s的时间才渲染出页面。这时就可使用Promise.all()

      let p1 = new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve("第⼀个promise执⾏完毕");
        }, 1000);
      });
      let p2 = new Promise((resolve, reject) => {
        setTimeout(() => {
          resolve("第⼆个promise执⾏完毕");
        }, 600);
      });

      let p = Promise.all([p1, p2])
        .then((res) => {
          console.log(res);
        })
        .catch(function (err) {
          console.log(err);
        });

上述代码在在1s后输出结果,也就是说Promise.all()会等待最慢的接口返回数据后统一处理。只有p1、p2的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2的返回值组成一个数组,传递给p的回调函数。

Promise.race()

race()Promise.all() 方法一样用于将多个 Promise 实例,包装成一个新的 Promise 实例。

let p = Promise.race([p1, p2])

区别是只要p1、p2之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的Promise实例的返回值,就传递给p的返回值。

Generator函数

Promise功能很强大,但是如果直接使⽤then()函数进⾏链式调⽤,代码仍然是很臃肿的,想要开发⼀个⾮常复杂的异步流程,依然需要⼤量的链式调⽤进⾏⽀撑。如果能有一种方法更明确的让异步看起来像同步,这应该是很好的结果。

ES6 新引入了 Generator 函数,可以通过 yield 关键字,把函数的执行流挂起,为改变执行流程提供了可能,从而为异步编程提供解决方案。它的存在提供了让函数可以分步执行的能力。

Generator 函数组成

Generator区别于普通函数的地方:

  • function 后面,函数名之前有个 *
  • 函数内部有 yield 表达式
      function* test(){
        yield 11;
        yield 12;
        yield "string";
      }

执行机制

调用 Generator 函数和调用普通函数一样,在函数名后面加上()即可,但是 Generator 函数不会像普通函数一样立即执行,而是返回一个指向内部状态对象的指针,所以要调用遍历器对象Iterator 的 next 方法,指针就会从函数头部或者上一次停下来的地方开始执行。

      let gen = test();
      console.log(gen.next());
      console.log(gen.next());
      console.log(gen.next());
      console.log(gen.next());

执行代码会输出:

 {value: 11, done: false}
 {value: 12, done: false}
 {value: 'string', done: false}
 {value: undefined, done: true}

可以看到next方法会返回一个对象,可以使用next().value取到具体的值。执行完毕后,done会变为true

异步流程同步化

      function* test() {
        yield new Promise(function (resolve, reject) {
          setTimeout(function () {
            console.log("延迟3秒");
            resolve();
          }, 3000);
        });
        yield new Promise(function (resolve, reject) {
          setTimeout(function () {
            console.log("延迟2秒");
            resolve();
          }, 2000);
        });
      }

上述代码,按照正常的逻辑来说,应该延迟2秒部分的代码先输出,延迟3秒 后输出。我们要把这段异步流程的代码转化成同步执行,从上到下的顺序执行,可以像下面这样处理:

      gen.next().value.then(function () {
        gen.next();
      });

输出:

2022.html:55 延迟3秒
2022.html:61 延迟2秒

这样就改变了原来的异步执行的顺序,让延迟3秒先输出了。

还可以封装成一个工具函数,来处理更复杂的异步流程,下面是一个简易函数,没有考虑到异常等一些边界情况。

      // 一个简易的工具函数
      function toSync(gen) {
        const item = gen.next();
        if (item.done) {
          return item.value;
        }

        const { value, done } = item;
        if (value instanceof Promise) {
          value.then((e) => toSync(gen));
        } else {
          toSync(gen);
        }
      }

使用函数:

    toSync(test());

输出的结果和上面一样。

上面所说的内容是JavaScript异步编程的一个过渡期,下面即将说到的asyncawait是现在处理异步编程最主流的一个方法。

Aync & Await

JS的异步编程是一件比较麻烦的事,人们一直在寻找解决方案。从最早的回调函数,到 Promise 对象,再到 Generator 函数,每次都有所改进,但又让人觉得不彻底。aysnc函数在es8版本中发布,被大多数人认为是异步编程的终极解决方案,也是现在使用最多的。

可以认为async 函数就是 Generator 函数的语法糖。

认识async函数

创建一个函数

      async function test() {
        return 1;
      }
      let res = test();
      console.log(res);

查看控制台输出:

Promise {<fulfilled>: 1}
[[Prototype]]: Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: 1

看控制台结果发现其实async修饰的函数,本身就是⼀个Promise对象。

await

  • async 函数中可能会有 await 表达式,async 函数执行时,如果遇到 await 就会先暂停执行 ,等到触发的异步操作完成后,恢复 async 函数的执行并返回解析值。
  • await 关键字仅在 async function 中有效。
  • 正常情况下,await 命令后面是一个 Promise 对象,它也可以跟其他值,如字符串,布尔值,数值以及普通函数。非Promise对象时直接返回相关值。

看下面的示例:

      async function test() {
        await new Promise((resolve)=>{
          setTimeout(()=>{
            console.log(4);
            resolve();
          },2000)
        })
        let a = await 2;
        console.log(a);
      }
      console.log(1);
      test();
      console.log(3);

输出顺序 1,3,4,2。代码执行先输出 1,然后执行test(),test函数里面遇到两个await都暂停执行,所以第二个输出 3。然后按照上下顺序执行两个await,两秒后输出 4,紧接着输出2

再看另一个例子:

      async function test() {
        var res1 = await new Promise(function (resolve) {
          setTimeout(function () {
            resolve("第⼀秒运⾏");
          }, 1000);
        });
        console.log(res1);
        var res2 = await new Promise(function (resolve) {
          setTimeout(function () {
            resolve("第⼆秒运⾏");
          }, 1000);
        });
        console.log(res2);
        var res3 = await new Promise(function (resolve) {
          setTimeout(function () {
            resolve("第三秒运⾏");
          }, 1000);
        });
        console.log(res3);
      }
      test();

输出:

2022.html:52 第⼀秒运⾏
2022.html:58 第⼆秒运⾏
2022.html:64 第三秒运⾏

上面的代码每隔一秒输出一次。

通过上面的示例可以看到,通过asyncawait可以自动将代码同步化。让我们用更简洁的代码就能控制异步流程的运行,所以async也是使用频率最高的语法。

上一篇

JS异步编程(一)事件循环模型