异步编程的实现方式

162 阅读13分钟

JavaScript 中的异步机制可以分为以下几种:

回调函数 的方式,使用回调函数的方式有一个缺点是,多个回调函 数嵌套的时候会造成回调函数地狱,上下两层的回调函数间的代码耦 合度太高,不利于代码的可维护。

Promise 的方式,使用 Promise 的方式可以将嵌套的回调函数作为 链式调用。但是使用这种方法,有时会造成多个 then 的链式调用, 可能会造成代码的语义不够明确。

generator 的方式,它可以在函数的执行过程中,将函数的执行权转 移出去,在函数外部还可以将执行权转移回来。当遇到异步函数执行 的时候,将函数执行权转移出去,当异步函数执行完毕时再将执行权给转移回来。因此在 generator 内部对于异步操作的方式,可以以同步的顺序来书写。使用这种方式需要考虑的问题是何时将函数的控 制权转移回来,因此需要有一个自动执行 generator 的机制,比如说 co 模块等方式来实现 generator 的自动执行。

async 函数 的方式,async 函数是 generator 和 promise 实现的 一个自动执行的语法糖,它内部自带执行器,当函数内部执行到一个 await 语句的时候,如果语句返回一个 promise 对象,那么函数将会等待 promise 对象的状态变为 resolve 后再继续向下执行。因此 可以将异步逻辑,转化为同步的顺序来书写,并且这个函数可以自动执行。

Promise

Promise是解决回调地狱的一种方案。

Promise有三种状态:

待定(pending):初始状态,既没有被完成,也没有被拒绝。

已完成(fulfilled):操作成功完成。

已拒绝(rejected):操作失败。

Promise的状态是不可逆的。当待定状态的 Promise 对象执行的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生时,我们用 Promise 的 then 方法排列起来的相关处理程序就会被调用。因为最后 Promise.prototype.then 和 Promise.prototype.catch 方法返回的是一个 Promise, 所以它们可以继续被链式调用。

Promise解决回调地狱主要依靠延迟绑定、值穿透和错误冒泡。

延迟绑定:在.then()里面进行回调函数延迟绑定。

let readFilePromise = filename => {

  return new Promise((resolve, reject) => {

    fs.readFile(filename, (err, data) => {

      if (err) {

        reject(err)

      } else {

        resolve(data)

      }

    })

  })

}

readFilePromise('1.json').then(data => {

  return readFilePromise('2.json')

});

值穿透指:根据 then 中回调函数的传入值创建不同类型的 Promise,然后把返回的 Promise 穿透到外层,以供后续的调用。

let x = readFilePromise('1.json').then(data => {

  return readFilePromise('2.json')  //这是返回的Promise

});

x.then(/* 内部逻辑省略 */)

//值穿透
readFilePromise('1.json').then(data => {

    return readFilePromise('2.json');

}).then(data => {

    return readFilePromise('3.json');

}).then(data => {

    return readFilePromise('4.json');

});

错误冒泡:Promise的错误信息会一直向后传递无法用thr捕获只能统一在Promise的.catch中捕获。这样就不用频繁地检查错误了。

readFilePromise('1.json').then(data => {

    return readFilePromise('2.json');

}).then(data => {

    return readFilePromise('3.json');

}).then(data => {

    return readFilePromise('4.json');

}).catch(err => {

  // xxx

})

Promise虽然通过.then()让异步流程以同步的方式展示出来了,但是如果操作过多的话一样会让代码变的难以阅读所以出现了async和awati的。Promise 实例被创建时,内部的代码就会立即被执行,而且无法从外部停止。比如无法取消超时或消耗性能的异步调用,容易导致资源的浪费。Promise 处理的问题都是“一次性”的,因为一个 Promise 实例只能 resolve 或 reject 一次,所以面对某些需要持续响应的场景时就会变得力不从心。比如上传文件获取进度时,默认采用的就是通过事件监听的方式来实现。

Promise常用的静态方法(并行)

all

语法: Promise.all(iterable)

参数: 一个可迭代对象,如 Array。

描述: 此方法对于汇总多个 promise 的结果很有用,在 ES6 中可以将多个 Promise.all 异步请求并行操作,返回结果一般有下面两种情况。

  1. 当所有结果成功返回时按照请求顺序返回成功。

  2. 当其中有一个失败方法时,则进入失败方法。

应用场景:将多个请求合并到一起,在一个页面中需要加载获取轮播列表、获取店铺列表、获取分类列表这三个操作,页面需要同时发出请求进行页面渲染,这样用 Promise.all 来实现。

//1.获取轮播数据列表

function getBannerList(){

  return new Promise((resolve,reject)=>{

      setTimeout(function(){

        resolve('轮播数据')

      },300) 

  })

}

//2.获取店铺列表

function getStoreList(){

  return new Promise((resolve,reject)=>{

    setTimeout(function(){

      resolve('店铺数据')

    },500)

  })

}

//3.获取分类列表

function getCategoryList(){

  return new Promise((resolve,reject)=>{

    setTimeout(function(){

      resolve('分类数据')

    },700)

  })

}

function initLoad(){ 

  Promise.all([getBannerList(),getStoreList(),getCategoryList()])

  .then(res=>{

    console.log(res) 

  }).catch(err=>{

    console.log(err)

  })

} 

initLoad()

allSettled:该方法与all类似,接受一个以 Promise为值的数组,最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值。

const resolved = Promise.resolve(2);

const rejected = Promise.reject(-1);

const allSettledPromise = Promise.allSettled([resolved, rejected]);

allSettledPromise.then(function (results) {

  console.log(results);

});

// 返回结果:

// [

//    { status: 'fulfilled', value: 2 },

//    { status: 'rejected', reason: -1 }

// ]

any

语法: Promise.any(iterable)

参数: iterable 可迭代的对象,例如 Array。

描述: any 方法返回一个 Promise,只要参数 Promise 实例有一个变成 fulfilled 状态,最后 any 返回的实例就会变成 fulfilled 状态;如果所有参数 Promise 实例都变成 rejected 状态,包装实例就会变成 rejected 状态。

const resolved = Promise.resolve(2);

const rejected = Promise.reject(-1);

const anyPromise = Promise.any([resolved, rejected]);

anyPromise.then(function (results) {

  console.log(results);

});

// 返回结果:

// 2

race

语法: Promise.race(iterable)

参数: iterable 可迭代的对象,例如 Array。

描述: race 方法返回一个 Promise,只要参数的 Promise 之中有一个实例率先改变状态,则 race 方法的返回状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 race 方法的回调函数。

//请求某个图片资源

function requestImg(){

  var p = new Promise(function(resolve, reject){

    var img = new Image();

    img.onload = function(){ resolve(img); }

    img.src = 'http://www.baidu.com/img/flexible/logo/pc/result.png';

  });

  return p;

}

//延时函数,用于给请求计时

function timeout(){

  var p = new Promise(function(resolve, reject){

    setTimeout(function(){ reject('图片请求超时'); }, 5000);

  });

  return p;

}

Promise.race([requestImg(), timeout()])

.then(function(results){

  console.log(results);

})

.catch(function(reason){

  console.log(reason);

});

应用场景:对于图片的加载,特别适合用 race 方法来解决,将图片请求和超时判断放到一起,用 race 来实现图片的超时判断。

Promise 高级应用

提前预加载应用

有这样一个场景:页面的数据量较大,通过缓存类将数据缓存在了本地,下一次可以直接使用缓存,在一定数据规模时,本地的缓存初始化和读取策略也会比较耗时。这个时候我们可以继续等待缓存类初始完成并读取本地数据,也可以不等待缓存类,而是直接提前去后台请求数据。两种方法最终谁先返回的时间不确定。那么为了让我们的数据第一时间准备好,让用户尽可能早地看到页面,我们可以通过 Promise 来做加载优化。

策略是页面加载后,立马调用 Promise 封装的后台请求,去后台请求数据。同时初始化缓存类并调用 Promise 封装的本地读取数据。最后在显示数据的时候,看谁先返回用谁的。

中断场景应用

实际应用中,还有这样一种场景:我们正在发送多个请求用于请求数据,等待完成后将数据插入到不同的 dom 元素中,而如果在中途 dom 元素被销毁了(比如 react 在 useEffect 中请求的数据时,组件销毁),这时就可能会报错。因此我们需要提前中断正在请求的 Promise,不让其进入到 then 中执行回调。

useEffect(() => {
    let dataPromise = new Promise(...);
    let data = await dataPromise();
    // TODO 接下来处理 data,此时本组件可能已经销毁了,dom 也不存在了,所以需要在下面对 Promise 进行中断

    return (() => {
      // TODO 组件销毁时,对 dataPromise 进行中断或取消
    })

});

我们可以对生成的 Promise 对象进行再一次包装,返回一个新的 Promise 对象,而新的对象上被我们增加了 cancel 方法,用于取消。这里的原理就是在 cancel 方法里面去阻止 Promise 对象执行 then()方法。

下面构造了一个 cancelPromise 用于和原始 Promise 竞速,最终返回合并后的 Promise,外层如果调用了 cancel 方法,cancelPromise 将提前结束,整个 Promise 结束。

function getPromiseWithCancel(originPromise) {
  let cancel = (v) => {};
  let isCancel = false;
  const cancelPromise = new Promise(function (resolve, reject) {
    cancel = e => {
      isCancel = true;
      reject(e);
    };
  });
  const groupPromise = Promise.race([originPromise, cancelPromise])
  .catch(e => {
    if (isCancel) {
      // 主动取消时,不触发外层的 catch
      return new Promise(() => {});
    } else {
      return Promise.reject(e);
    }
  });
  return Object.assign(groupPromise, { cancel });
}

// 使用如下
const originPromise = axios.get(url);
const promiseWithCancel = getPromiseWithCancel(originPromise);
promiseWithCancel.then((data) => {
  console.log('渲染数据', data);
});
promiseWithCancel.cancel(); // 取消 Promise,将不会再进入 then() 渲染数据

Promise 深入理解之控制反转

熟悉了 Promise 的基本运用后,我们再来深入点理解。Promise 和 callback 还有个本质区别,就是控制权反转。

callback 模式下,回调函数是由业务层传递给封装层的,封装层在任务结束时执行了回调函数。

而 Promise 模式下,业务层并没有把回调函数直接传递给封装层( Promise 对象内部),封装层在任务结束时也不知道要做什么回调,只是通过 resolve 或 reject 来通知到 业务层,从而由业务层自己在 then() 或 reject() 里面去控制自己的回调执行。

这里可能理解起来有点绕,换种等效的简单理解:我们知道函数一般是分定义+ 调用步骤的,先定义,后调用。谁调用了函数,就表示谁在控制这个函数的执行。

那么我们来看 callback 模式下,业务层将回调函数的定义传给了封装层,封装层在内部完成了回调函数的调用执行**,业务层**并没有调用回调函数,甚至业务层都看不到其调用代码,所以回调函数的执行控制权在封装层。

而 Promise 模式下,回调函数的调用执行是在 then() 里面完成的,是由业务层发起的,业务层不仅能看到回调函数的调用代码,也能修改,因此回调函数的控制权在业务层。

手动实现 Promise 类的思路

现在我们已经熟悉了 Promise 的详细使用方式,假设让你回到 Promise 类出现之前,那时的 ES6 还没出现,你为了淘汰 callback 的回调写法,准备自己写一个 Promise 类,你会怎么做?

其实这就是常见面试手写 Promise 题目。我们只要抓住 Promise 的一些特点和关键点就能比较顺利实现。

首先 Promise 是一个类,构造函数接收参数是一个函数,而这个函数的参数是 resolve 和 reject 两个内部函数,也就是我们需要构建 resolve 和 reject 传给它,同时让它立即执行。另外咱这个类是有三种状态及 then 和 catch 等方法。根据这些就能快速先把类框架创建好。

class MyPromise () {
  constructor (fun) {
    this.status = 'pending'; // pending、fulfilled、rejected
    fun(this.resolve, this.reject); // 立即执行主体函数,参数函数可能需要 bind(this) 
  }
  resolve() {} // 定义 resolve,内容待定
  reject() {} // 定义 reject,内容待定
  then() {}
  catch() {}
}

Promise注意点

1.如果我们在调用 then() 之前,Promise 主体里的异步任务已经执行完了,即 Promise 的状态已经标注为成功了。那么我们调用 then 的时候,并不会错过,还是会执行。但需要记着,即使主体的异步任务早就执行完了,then() 里面的回调永远是放到微任务里面异步执行的,而不是立马执行。

2.通过 then() 的第 2 个参数这种方式能捕获到 promise 主体里面的异常,并执行 errorCallback。但是如果 Promise 主体里面没有异常,然后进入到 successCallback 里面发生了异常,此时将不会进入到 errorCallback。因此我们经常使用下面的方式二来处理异常。我们可以用catch()不管是 Promise 主体,还是 successCallback 里面的出了异常,都会进入到 errorCallback。这里需要注意,按这种链式写法才正确,如果按下面的写法将会和方式一类似,不能按预期捕获。try...catchtry catch 是传统的异常捕获方式,这里只能捕获同步代码的异常,并不能捕获异步异常,因此无法对 Promise 进行完整的异常捕获。

3.每次 then() 或者 catch() 后,返回的是一个新的 Promise,和上一次的 Promise 实例对象已经不是同一个引用了。而这个新的 Promise 实例对象包含了上一次 then 里面的结果,这也是为什么链式调用的 catch 才能捕获到上一次 then 里面的异常的原因。

async/await

async/await 其实是 Generator 的语法糖,它能实现的效果都能用 then 链来实现,它是为优化 then 链而开发出来的。从字面上来看, async 是“异步”的简写,await 则为等待,所以很好理解 async 用

于申明一个 function 是异步的,而 await 用于等待一个异步方法 执行完成。当然语法上强制规定 await 只能出现在 asnyc 函数中,async 函数(包含 函数语句、函数表达式、Lambda 表达式)会返回一个 Promise 对象,如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。在最外层不能用 await 获取其返回值的情况下,当然应该用原来的方式:then() 链来处理这个 Promise 对象,如果 async 函数没有返回值,它会返回Promise.resolve(undefined)。联想一下 Promise 的特点——无等待,所以在没有 await 的情况下 执行 async 函数,它会立即执行,返回一个 Promise 对象,并且, 绝不会阻塞后面的语句。这和普通返回 Promise 对象的函数并无二 致。

注意:Promise.resolve(x) 可以看作是 new Promise(resolve => resolve(x)) 的简写,可以用于快速封装字面量对象或其他对象,将 其封装成 Promise 实例。

async/await 的优势:单一的 Promise 链并不能发现 async/await 的优势,但是,如果需要处理由多个 Promise 组成的 then 链的时候,优势就能体现出来 了(很有意思,Promise 通过 then 链来解决多层回调的问题,现在 又用 async/await 来进一步优化它)。

async/await 对比 Promise 的优势:代码读起来更加同步,Promise 虽然摆脱了回调地狱,但是 then 的 链式调⽤也会带来额外的阅读负担 Promise 传递中间值⾮常麻烦,⽽async/await⼏乎是同步的写法, ⾮常优雅 错误处理友好,async/await 可以⽤成熟的 try/catch,Promise 的 错误捕获⾮常冗余 调试友好,Promise 的调试很差,由于没有代码块,你不能在⼀个返 回表达式的箭头函数中设置断点,如果你在⼀个.then 代码块中使⽤ 调试器的步进(step-over)功能,调试器并不会进⼊后续的.then 代 码块,因为调试器只能跟踪同步代码的每⼀步。