async/await原理剖析

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

前言

网上讲述async/await类似的文章贼多,我相信大家也看过很多的文章,那我还写类似的文章干啥子咧?因为…正是类似的文章看多了说不定你学会了呐(不要脸!)。好啦,我们言归正传,我们在日常开发中用async/await的频率特别高,这是因为它处理异步操作十分方便。那它是怎么出现的呢,底层原理又是啥子?本文会从它的历史由来和底层原理这两个方面进行讲述,看完如果您有收获,欢迎点个赞呦。

async/await解决了什么问题

这个问题我先不做回答,大家先看看四个场景吧。

青铜选手:callback“回调地狱”

我们都知道,JavaScript是一门单线程语言,在ECMAScript 2015之前,大家处理异步操作比如Ajax、定时任务都是借助回调函数来处理的,JS在调用栈清空后再执行队列里面的异步任务,本文的案例都是基于Ajax的异步任务。

调用栈其实就是JS解释器,比如V8,方便追踪代码的执行,详情查看Call stack

假设我们现在有一个逻辑:比如通过Ajax先获取一个id,如果id值是1,然后再去获取用户名name,如果是zhangsan,那么再去获取他的地址信息address。如果用回调函数处理会是一个什么情况呢?

先封装一个简易Ajax:

/**
 * Ajax简易封装GET请求
 * @param {string} url 请求地址
 * @param {function} cb 回调函数
 */
function ajax(url, cb) {
  let res
  const xhr = new XMLHttpRequest()
  xhr.onreadystatechange = handleReadyStateChange
  xhr.open('GET', url)
  xhr.send()

  function handleReadyStateChange() {
    if (this.readyState === this.DONE) {
      if (this.status === 200) {
        cb(null, JSON.parse(this.responseText))
      } else {
        cb(new Error('error'))
      }
    }
  }
}
复制代码

然后创建三个JSON文件a.jsonb.jsonc.json文件放于data目录下,我们来实现上述需求:

  <script src="./ajax.js"></script>
  <script>
    ajax('./data/a.json', (err, d) => {
      if (err) {
        console.log(err);
      } else {
        if (d.id === 1) {
          ajax('./data/b.json', (err, d) => {
            if (err) {
              console.log(err);
            } else {
              if (d.name === 'zhangsan') {
                ajax('./data/c.json', (err, d) => {
                  if (err) {
                    console.log(err);
                  } else {
                    console.log(d);
                  }
                })
              }
            }
          })
        }
      }
    })
  </script>
复制代码

此时你会发现代码像一只横着走的“螃蟹”,其实它就是大家熟知的回调地狱,碰到这样的代码直接烤着“吃”了吧。

黄金选手:Promise“链式调用”

好在社区里面出现了一中异步解决方案:Promise。最早的有whenbluebird等模块,后面被写进了ECMAScript 2015规范。它的出现极大地加速了前端的发展进程。

在最新版的红宝书(第4版)当中它被翻译为了期约

早期的期约机制在 jQuery 和 Dojo 中是以 Deferred API 的形式出现的。到了 2010 年,CommonJS 项目实现的Promises/A 规范日益流行起来。Q 和 Bluebird 等第三方 JavaScript 期约库也越来越得到社区认可,虽然这些库的实现多少都有些不同。为弥合现有实现之间的差异,2012 年 Promises/A+组织分叉(fork)了 CommonJS 的 Promises/A 建议,并以相同的名字制定了 Promises/A+规范。这个规范最终成为了ECMAScript 6 规范实现的范本。

听起来怪怪的,我更喜欢就叫英文Promise,你可以理解为它是一个具有状态的容器,而且只有三种:fullfilled(已完成)、rejected(已拒绝)和pending(进行中)。

容器里面的操作决定了它的状态,而我们不需要关心它什么时候会发生状态变更,只需要明白一件事:容器(Promise)的状态将来一定会保证变更成fullfilledrejected两种,而且一旦变更状态就不会再变了。我们可以通过thencatch等实例方法拿到它的结果。

我们回到上面的例子,现在Promise,我们可以把Ajax这种操作放到这个“容器”中,并且把请求的成功与失败对应到“容器”的状态,把关注点从回调函数放到状态上,下面看看写法。

用Promise包装Ajax成为一个有状态的“容器”:

function ajax(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.onreadystatechange = handleReadyStateChange
    xhr.open('GET', url)
    xhr.send()

    function handleReadyStateChange() {
      if (this.readyState === this.DONE) {
        if (this.status === 200) {
          resolve(JSON.pa)
        } else {
          reject(new Error('ajax error'))
        }
      }
    }
  })
}
复制代码

请求的方式也变了:

ajax('./data/a.json')
  .then(d => {
    if (d.id === 1) return ajax('./data/b.json')
  })
  .then(d => {
    if (d.name === 'zhangsan') return ajax('./data/c.json')
  })
  .then(d => {
    console.log(d);
  })
  .catch(e => {
    console.log(e);
  })
复制代码

咦?大家发现没有,它不是“横着走”了,而是“竖着走”了啊?。

如果大家接触过过d3.jsjQuery,应该对上面的写法很熟悉,它就是“链式调用”。其实它本质上还是是回调函数,Promise状态变更为fullfilled的时候会调用then方法的回调函数,失败的时候调用catch中的回调函数。

then方法第二个参数也可以接收失败的回调,但是catch可以捕获所有返回的Promise异常,而前者只能捕获上一个的异常,所以一般不推荐使用前者,而是使用catch

虽然比起青铜选手的“回调地狱”,“链式调用”看起来清晰不少,但是看起来一大堆的then我还是感觉脑阔疼,代码语义也不清晰,因为所有逻辑判断都在then里面,还是没有达到理想目的,我们希望以一种同步的写法来写异步代码,让代码看起来不但清晰而且还易于书写,显然Promise的段位还是低了。而同在ES6中出现的APIGenerator函数改变了这种局面,下面我们来看看它是如何改变的。

钻石选手:Generator”同步写法”

考虑到并不是所有人了解Generator,我简单说一下它的语法吧。

Generator是一个生成器函数,调用这个函数它并不会立马执行这个函数,而是生成一个遍历器(或者迭代器)对象(Iterator),必须调用这个遍历器对象的next方法才会执行,而且它并不是一次性全部执行完,如果执行过程中遇到了yield关键字函数会暂停,等调用下一个next方法才会恢复执行。

迭代器的实现需要符合迭代协议,而generator返回的迭代器对象就是一个实现,详情可以查看MDN上的迭代协议

定义生成器函数很简单,在函数名前加*即可,如果函数内没有yield关键字其实与普通函数没啥大区别,所以一般会搭配yield关键字使用。

function* gen(x) {
    let y = yield x
    yield y + 1
}

const g = gen(1) 
g.next() // {value: 1, done: false}
g.next(2) // {value: 3, done: false}
g.next() // {value: undefined, done: true}
复制代码

我们可以看到调用next返回一个对象,它有valuedone这两个属性,valueyield关键字后面的值,done来标识是否遍历完毕。从数据结构上来看,这个g遍历器对象很像单链表的指针,调用next方法就是移动指针,返回当前节点,当移动到单链表尾节点时,done属性自然是true,表明已经结束了。

next函数可以接收参数,作为上一个yield返回的值,它具体有啥用待会我会在下面讲述。

Generator生成器函数有了基本了解后,我们来想一想,它在异步场景中有啥用处。如果yield后面是一个Promise对象,那会碰出什么火花呢,回到上面的例子,我们用Generator改写一下。

  function* getData() {
    const res = yield ajax('./data/a.json')
    if (res.id === 1) {
      const res = yield ajax('./data/b.json')
      if (res.name === 'zhangsan') {
        const res = yield ajax('./data/c.json')
        console.log(res)
      }
    }
  }
复制代码

麻烦的要来了,上面说过Generator函数的执行需要调用next方法,遇到yield会暂停,直到下一个next方法调用才会继续,但是上面的需求中我们需要知道第一次执行返回的值,通过这个值再决定要不要执行下一个异步请求。而第一次调用next后,res的值是undefined,那怎样才能得到上一次异步操作的执行结果呢?

上面说到的next传参就派上用场了,只要把上一次执行后的值作为参数传入下一个next函数就能拿到上一次的返回值了。

  const gen = getData()

  gen.next().value.then(d => {
    gen.next(d).value.then(d => {
      gen.next(d).value.then(d => {
        gen.next(d)
      })
    })
  })
复制代码

上面的写法其实都是在反复调用gen.next().value.then方法,我们可以封装一个执行器,递归自动执行,根据done的值作为结束递归的条件。

  function run(gen) {
    const g = gen()

    function next(d) {
      const res = g.next(d)
      if (res.done) return res.value
      res.value.then((d) => {
        next(d)
      })
    }

    next()
  }

  run(getData)
复制代码

而这个run函数我们根本不需要操心,社区早就有人已经写了一个功能更完善的模块,还有错误处理等,它就是TJ Holowaychuk大神于2013年6月发布的一个小工具,叫做co,我们只需要关注业务逻辑代码本身就行了。

co模块规定yield后面必须是Thunk 函数或 Promise 对象。

王者选手:async/await“更优雅的写法”

庆幸的是,ECMAScript在2017的版本中把上面这种方案纳入了规范,并且取名为async/await,对应Generator函数中的*yield,自此异步编程解决方案自此告一段落了,从青铜升到王者确实不容易啊?。

下面是最终版本的改写:

  async function getData() {
    try {
      const res = await ajax('./data/a.json')
      if (res.id === 1) {
        const res = await ajax('./data/b.json')
        if (res.name === 'zhangsan') {
          const res = await ajax('./data/c.json')
          console.log(res)
        }
      }
    } catch (error) {
      console.log(error)
    }
  }

  getData()
复制代码

我们再回到问题本身:async/await解决了什么问题?

我想你心中已经有了答案,它的出现不是偶然而是必然,技术的发展一定是有原因的。async/await它是一种更优雅的写法,其实它就是Generator函数与Promise的结合体,以下是它的优点:

  • 内置了Generator的自动执行器
  • 更好的语义
  • 返回值是Promise

底层原理剖析

从上述的四个场景中,我们知道了async/await它到目前为止应该是解决异步编程最优雅的方式了,但是大家有没有想过:

  • Generator内部它是如何实现“暂停”然后又恢复执行的呢?
  • JS引擎它是怎么知道上一个执行的位置呢?

对于第一个问题,其实它涉及到了协程这个概念,Generator函数其实就是协程的一个实现,可以解释为:Generator函数是一个协程,当执行到yield时候把执行权交给另外一个协程执行,当另外一个协程的任务执行完毕再从暂停的地方继续执行。

下面是来自维基百科的一段话:

协程(英语:coroutine)是计算机程序的一类组件,推广了协作式多任务的子程序,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务、异常处理、事件循环、迭代器、无限列表和管道。

其中“允许执行被挂起与被恢复”就是很好的解释。

那回到第二个问题,JS引擎它怎么知道上一个执行的位置呢?也是说当执行完另一个协程的任务后它怎么返回到暂停的位置继续执行呢?

我们回到上面那个例子,我们来手动执行来看看:

  function* getData() {
    const res = yield ajax('./data/a.json')
    if (res.id === 1) {
      const res = yield ajax('./data/b.json')
      if (res.name === 'zhangsan') {
        const res = yield ajax('./data/c.json')
        console.log(res)
      }
    }
  }
  
  const gen = getData()
复制代码

在浏览器控制台打印一下g这个对象:

image.png

我们重点查看两个属性:[[GeneratorLocation]][[GeneratorState]],它是宿主环境中暴露出来的私有属性,我们是无法访问的,前者就是记录当前Generator函数执行的位置的,而后者是Generator函数执行的状态,目前状态是suspended(暂停)。很明显,当前函数在index.html12行的位置暂停了,我们点进去查看:

image.png

此时暂停的位置在函数的声明的位置,此时开始调用一次next方法,我们发现它会移动到函数的第一行,也就是index.html文件的13行。当调用了四次next方法后,此时它的状态变为了closed

image.png

而此时它的位置又回到了最初函数声明的地方,所以这也解释了第二个问题了。

总结

本文从四个时期对异步任务不同的处理来展开讲述,不管是什么“段位”的解决方案它们都推动了异步编程的发展。回调函数处理异步虽然不友好,但是它是所有解决方案的根基。而Promise的出现它引入了状态,虽然链式调用让代码逻辑不清晰,但是解决了回调函数带来的“回调地狱”的问题,最后Generator函数与Promise的出现成就了async/await,也被业内认为是异步任务处理的最佳方案。

我还是相信一句话,任何技术的出现一定是为了解决某个场景下的问题,我们除了“追赶时髦”,也要知道它为什么出现,这样我们以后遇到了相关问题就会让它们成为我们解决问题的“利剑”。

希望本文对您有所帮助。

参考

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享