浅谈async与Generator

1,114 阅读10分钟

阅读本文的前置知识:JavaScript异步任务队列、Promise原理

async await串行与并行

MDN上是这么解释的:async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。asyncawait关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise

并行

一个并行使用async await的例子

//例1
function sleep(delay) {
  return new Promise((resolve) => {
    setTimeout(resolve, delay);
  });
}

(async () => {
  const sleepP1 = sleep(1000);
  const sleepP2 = sleep(2000);
  console.log(sleepP1);
  console.log(sleepP2);
  console.time("sleep");
  await sleepP1;
  await sleepP2;
  console.timeEnd("sleep");
})();

这里请你思考一下最后的输出结果。

Promise { <pending> }
Promise { <pending> }
sleep: 2.003s

串行

如果我要你输出的是3秒多呢,代码怎么写?

//例2
;(async () => {
  console.time('sleep')
  await sleep(1000)
  await sleep(2000)
  console.timeEnd('sleep')
})()

这里输出结果是:

sleep: 3.016s

思考

这里我要向聪明的你提两个问题了。

问题1:await不是把异步变成同步了吗,怎么例1里面输出的还是2s多?

问题2:我设的是1秒和2秒,怎么结果多了几毫秒?

问题2 比较好解决,我们先来说说问题2,关于setTimeout的误差,如果看过JS红宝书的同学一定不陌生,chrome浏览器中setTimeout的误差约为4ms,至于node和其他浏览器中的误差,有兴趣的读者可以去查查相关资料。关于这个误差怎么产生的,一种解释是:

当我们执行JS代码的时候其实就是往执行栈中放函数或者表达式,当遇到异步代码时,会被挂起并在需要的时候被加入到任务队列(分为宏任务和微任务)。一旦执行栈为空,Event Loop就会从任务队列中拿出需要执行的函数放入执行栈中执行。

根据上面的解释,setTimeout应该是在设置的delay之后将回调函数加入(宏)任务队列,等待同步任务和微任务执行完再执行回调。这样为什么会多出几ms也就不奇怪了。

关于问题1,要结合一点Promise的原理,这里我贴出一部分自己手写的Promise代码

//Promise的构造器
constructor(executor) {
      this.status = PENDING;

      //将resolve、reject的值挂在实例对象上,方便then、catch访问
      this.value = undefined;
      this.reason = undefined;

      //成功回调队列
      this.onFulfilledCallbacks = [];
      //失败回调队列
      this.onRejectedCallbacks = [];

      const resolve = (value) => {
        //只有pending状态才能改变状态
        if (this.status === PENDING) {
          this.status = FULFILLED;
          this.value = value;
          //依次执行成功回调
          this.onFulfilledCallbacks.forEach((cb) => cb(this.value));
        }
      };

      const reject = (reason) => {
        if (this.status === PENDING) {
          this.status = REJECTED;
          this.reason = reason;
          //依次执行失败回调
          this.onRejectedCallbacks.forEach((cb) => cb(this.reason));
        }
      };

      try {
        executor(resolve, reject);
      } catch (err) {
        //executor执行出错,抛出异常并返回一个失败的promise
        reject(err);
      }
    }

可以看到,我们在执行new Promise的时候,会执行一个executor函数(同步函数),而我们又在exectuor里面执行了resolve函数,那说明例1中const sleepP1 = sleep(1000);不会阻塞后面的代码执行,即两个是并行的,过了2s两个resolve里的定时器的回调都会被加入到宏任务队列中(前一个在1s的时候就加进去了)。我们在await执行时实际上相当于调用了返回的promisethen然后拿到了里面的value。因此,例1中的代码是并行执行,结果是2s多。再来看例2,根据我们上面的分析,await的本质是等待promise的状态由PENDING变为FULFILLED,然后去拿then里面的value,所以它其实是一种串行,因为要等待resolve执行完才能拿到值。我个人的理解是,例1中的await只等待了回调加入微任务队列再取出来的时间,而例2中的await多等了一个setTimeout设置的延时。

这里,我在抛出一个问题,如果你能很迅速的解决,那么说明你对于asyncawait的串行、并行以及promise都有一个很好的理解。

问题3:在仅调整例1中的代码顺序下,如何使得sleep的时间变为3s多?

参考答案:

;(async () => {
  const sleepP1 = sleep(1000)
  console.log(sleepP1)
  console.time('sleep')
  await sleepP1
  const sleepP2 = sleep(2000)
  console.log(sleepP2)
  await sleepP2
  console.timeEnd('sleep')
})()

输出:

Promise { <pending> }
Promise { <pending> }
sleep: 3.005s

可迭代对象与Generator

可迭代(Iterable) 对象是数组的泛化。这个概念是说任何对象都可以被定制为可在 for..of 循环中使用的对象。

可迭代协议

可迭代协议允许JavaScript对象定义或定制他们的迭代行为,例如在一个for..of结构中,哪些值可以被遍历到(有一种说法是,可迭代对象实现了iterator接口供for..of消费)。一些内置类型同时是内置可迭代对象,并且有默认的迭代行为,比如 Array 或者 Map,而其他内置类型则不是(比如 Object

要成为可迭代对象,一个对象必须实现@@iterator方法。这意味着对象或者其原型链上必须有个@@iterator属性,其值为一个函数,函数的返回值是一个符合 迭代器协议 的对象。开发者可以通过[Symbol.iterator]访问该属性并重写该属性的值。

属性
[Symbol.iterator]一个无参数的函数,其返回值为一个符合迭代器协议的对象。

当一个对象需要被迭代的时候(如被置入一个for..of循环),首先会 不带参数 的调用它的@@iterator方法,然后使用此方法返回的 迭代器 获得要迭代的值。

值得一提的是,调用此零个参数函数时,它将作为可迭代对象的方法进行调用。因此,在函数内部,this关键字可用于访问可迭代对象的属性,以决定在迭代过程中提供什么。

此函数可以是普通函数,也可以是生成器函数,以便在调用时返回迭代器对象。在此生成器函数内部,可以使用yield提供每个条目。

迭代器协议

迭代器协议定义了产生一系列值(无论有限个还是无限个)的标准方式。当值为有限个时,所有的值都被迭代完毕后,则会返回一个默认返回值(undefined)。

只有实现了一个拥有以下语义(semantic)的next()方法,一个对象才能成为迭代器:

属性
next一个无参数函数,返回一个应当拥有以下两个属性的对象:done(boolean)如果迭代器可以产生序列中的下一个值,则为 false。(这等价于没有指定 done 这个属性。)如果迭代器已将序列迭代完毕,则为 true。这种情况下,value 是可选的,如果它依然存在,即为迭代结束之后的默认返回值。value迭代器返回的任何 JavaScript 值。done 为 true 时可省略。next() 方法必须返回一个对象,该对象应当有两个属性: donevalue,如果返回了一个非对象值(比如 falseundefined),则会抛出一个 TypeError 异常("iterator.next() returned a non-object value")。

下面我我们来给一个普通对象实现可迭代协议和迭代器协议:

const obj = {
  data: [1, 2, 3, 4, 5],
  [Symbol.iterator] () {
    const self = this
    let index = 0
    return {
      next () {
        if (index < self.data.length) {
          return {
            value: self.data[index++],
            done: false
          }
        } else {
          return {
            value: undefined,
            done: true
          }
        }
      }
    }
  }
}

for (const d of obj) {
  console.log(d)			//换行输出1 2 3 4 5
}

for (const key in obj) {
  console.log(key)			//只输出data,说明@@iterator属性是不可枚举的
}

这里要注意,实现了迭代协议不一定实现了可迭代协议,下面就是一个例子

function makeIterator (array) {
  let index = 0
  return {
    next () {
      return index < array.length
        ? {
            value: array[index++],
            done: false
          }
        : {
            done: true
          }
    }
  }
}

let iter = makeIterator(['hi', 'hello'])

console.log(iter.next())		//{ value: 'hi', done: false }

//TypeError: iter is not iterable
for (const i of iter) {
  console.log(i)
}

仅仅实现了可迭代协议没有实现实现迭代协议的对象也是不可iterable的,如下:

const iterableObj = {
  data: [1, 2],
  [Symbol.iterator]: () => {
    return {
      data: this.data
    }
  }
}
//TypeError: iterableObj is not iterable
console.log([...iterableObj])

综上,一个iterabel对象既要实现可迭代协议,又要实现迭代器协议。

MDN上认为只要实现了@@iterator方法就可以称为可迭代对象,如果该方法返回的对象不满足迭代协议则称之为格式不佳的可迭代对象

这里,个人认为判断一个对象是否可迭代,可以用这种写法

const isIterable = iterable => {
  try {
    for (const _ of iterable) {
      return true
    }
  } catch (error) {
    return false
  }
}

console.log(isIterable({ a: 1 }))	//false
console.log(isIterable([1, 2]))		//true

内置可迭代对象

目前所有的内置可迭代对象如下:StringArrayTypedArrayMapSet

接收可迭代对象的API

需要可迭代对象的语法

一些语句和表达式需要可迭代对象,比如 for...of 循环、展开语法yield*,和解构赋值

思考

问题1:生成器对象到底是一个迭代器还是一个可迭代对象

我们看一段代码

const generator = (function * () {
  yield 1
  yield 2
  yield 3
})()

console.log(typeof generator.next) //输出"function", 因为有一个next方法,所以这是一个迭代器
console.log(typeof generator[Symbol.iterator]) // 输出"function", 因为有一个@@iterator方法,所以这是一个可迭代对象
console.log(generator[Symbol.iterator]() === generator) // 输出true, 因为@@iterator方法返回自身(即迭代器),所以这是一个格式良好的可迭代对象

console.log([...generator]) // 输出[1, 2, 3]

console.log(Symbol.iterator in generator) //// 输出true, 因为@@iterator方法是aGeneratorObject的一个属性

综上:生成器对象既是迭代器,也是可迭代对象:

最后让我们看看ES6类中的迭代器

class SimpleClass {
  constructor (data) {
    this.data = data
  }

  [Symbol.iterator] () {
    //为每个迭代器使用一个新的index,使得该对象多次迭代都是安全的。例如在一个对象上使用break或嵌套循环
    let index = 0

    return {
      next: () => {
        if (index < this.data.length) {
          return { value: this.data[index++], done: false }
        } else {
          return { done: true }
        }
      }
    }
  }
}

const simple = new SimpleClass([1, 2, 3, 4, 5])

for (const val of simple) {
  console.log(val) //'1' '2' '3' '4' '5'
}

Why Generator

先说一个结论:async await本质上是Generatoryield的语法糖。

ES6之前,JS中任何一个函数一旦开始执行,就会运行到结束,期间不会有其他代码能打断它并插入其中。而生成器函数的引入,打破了这种局面。

考虑如下例子:

var x = 1
function foo () {
  x++
  bar()
  console.log('x:', x)
}
function bar () {
  x++
}
foo() //x:3

在这个例子里,我们能确定bar()x++console.log(x)之间运行。但是,如果 bar()并不在那里会怎样呢?显然结果就是2而不是3。

试想一下,如果 bar()不在那儿,但是出于某种原因,其还是在x++console.log(x)之间运行,x的值仍然变为3,又会怎样呢?

学过像Java这样 抢占式多线程 的语言的同学可能很容易的就实现了上面的效果。但很遗憾,JS并不是抢占式的,(目前)也不是多线程的。然而,若果foo()自身可以通过某种形式在代码的这个位置指示暂停的话,那就仍然可以一种合作的方式去实现这样的中断(并发)。

看如下代码:

var x = 1
function * foo () {
  x++
  yield //暂停
  console.log('x:', x)
}
function bar () {
  x++
}
//构造一个迭代器来控制这个生成器
const it = foo()

//启动foo()
it.next()
console.log(x) //2
bar()
console.log(x) //3
it.next() //x:3

这里解释一下运行过程:

  1. it=foo()运算并没有执行*foo()里的代码,只是构造了一个迭代器
  2. 第一个it.next()启动了生成器,并执行了代码直到遇到第一个yield
  3. *foo()yield语句出暂停,执行到这一行时,第一个it.next()调用结束。此时*foo()在运行并仍然是在运行并且是活跃的,但处于暂停状态。
  4. 此时x的值为2
  5. 我们调用bar(),执行x++
  6. 此时x的值为3
  7. 最后的it.next()调用从暂停处恢复了生成器*foo()的执行,并运行了console.log(x)

因此,生成器就是一类特殊的函数,调用它会返回一个生成器,通过这个迭代器可以控制它的启动或停止。有一种说法是:生成器可以实现JS里的协程(也有人说是伪多线程)。

生成器的消息传递

如果说仅仅能控制程序的启动或停止,那是远远达不到协程的目的,所以生成器中提供了yield关键字,将值从函数内部传递出去,在函数外部,通过其生成的迭代器的next(...)将值传入到函数中。

考虑:

function * foo (x) {
  var y = x * (yield)
  return y
}

const it = foo(6)

it.next()

const res = it.next(7)

console.log(res.value)	//42

按惯例,解释一下上面的代码执行:

  1. 调用foo(6)产生一个迭代器,赋值给it PS:由于生成器是特殊的函数,所以也有输入输出,即参数和返回值。
  2. it.next()启动生成器,会开始执行y=x...,但随后遇到一个yield表达式,其就会暂停执行,并期待调用yield表达式提供一个结果值。接下来,调用it.next(7),这一句把7传回给作为被暂停的yield表达式的结果,
  3. 此时赋值语句实际上就是var y = 6*7return y将返回42作为调用it.next()的结果。实际上这里it.next(7)的返回值是{value:42,done:true},通过return拿出来的对象的done属性都是true

注意,这里有一点很让人费解:yield的和next(...)的调用不匹配。一般来说,需要的next(...)调用会比yield表达式多一个,下一个next(...)传的参数会作为上一个yield表达式的返回值传进生成器函数。

为什么会有这个不匹配?

因为第一个next(...)总是启动一个生成器,并运行到第一个yield处。然后,第二个next(...)调用完成第一个被暂停的yield表达式,第三个next(...)调用第二个yield,以此类推,直到遇到函数return或者结尾。

实际上,只考虑生成器代码:

var y = x * (yield)
return y

第一个yield基本上提出了一个问题:“这里我应该插入什么值?”

谁来回答呢?第一个next()已经运行,使得生成器启动并运行到此处,所以显然它无法来回答这个问题。

看到不匹配了吗——第二个next(...)对第一个yield

前面我们说过,yield可以向生成器外面传递值,我们来修改一下上面的代码:

function * foo (x) {
  var y = x * (yield 1)
  return y
}

const it = foo(6)

let res = it.next()

console.log(res.value) //1

res = it.next(7)

console.log(res.value) //42

可以看到,消息是双向传递的,生成器函数可以向外部传值,外部也可以向生成器函数内部传值。既然如此,我们何不从迭代器的角度审视一下不匹配的问题呢?

第一个next()调用基本上就是在提出一个问题:“生成器*foo(...)要给我的下一个值是什么?”谁来回答这个问题呢?第一个yield 1表达式。

看见了吗?这里没有不匹配。

所以,实际上,根据你所处的角度,yieldnext(...)要么有不匹配,要么没有。

但是,与yield语句相比,还是多出来一个next(...)。所以最后一个it.next(7)再次提出这样的问题:生成器将要产生的下一个值是什么?但是,再也没有yield语句来回答这个问题了,那么,谁来回答呢?

return语句

有一种解释是,yield相当于一个特殊的return,因为函数的return可以传值,也可以移交程序的控制权,唯一的区别是,你不能从外部去改变return语句的值。

Generator处理异步

我们用Generator改写一下nodejs里经典的fs.readFile(path,cb)

const fs = require('fs')

function readFile (file) {
  fs.readFile(file, (err, data) => {
    if (err) {
      it.throw(err)
    } else {
      it.next(data.toString())
    }
  })
}

function * main () {
  try {
    const text = yield readFile('./test.txt')
    console.log(text)
  } catch (err) {
    console.log(err)
  }
}

const it = main()
it.next()

结果:

$ node readFileGenerator.js 
Generator is reall NB!

这里值得一提的是,生成器生成的迭代器比普通的迭代器多了一个throw(...)方法,用于处理异常。

仔细看看上面的代码,是不是觉得很眼熟?*main()是不是很像async函数?yield的这种用法是不是很像在async函数里用await表达式?没错,async函数本质就是一个带执行器的生成器,await本质就是yield,只不过await后面跟的异步任务得是promise。下面我们来手撸一个async执行器。

const getData = () =>
  new Promise(resolve => {
    setTimeout(() => {
      const num = Math.floor(Math.random() * 520)
      resolve(num)
    }, 1000)
  })

function asyncFuncRunner (gen) {
  return function () {
    const iter = gen.apply(this, arguments)
    return new Promise((resolve, reject) => {
      function step (method, data) {
        let info
        try {
          info = iter[method](data)
        } catch (error) {
          reject(error)
          return
        }
        if (info.done) {
          resolve(info.value)
        } else {
          return Promise.resolve(info.value).then(
            val => step('next', val),
            err => step('throw', err)
          )
        }
      }
      step('next')
    })
  }
}

function * generator () {
  const num1 = yield getData()
  console.log(num1)
  const num2 = yield getData()
  console.log(num2)
  const num3 = yield getData()
  console.log(num3)

  return 'OK'
}

const test = asyncFuncRunner(generator)
test().then(
  val => console.log(val),
  err => console.log(err)
)

输出结果:

[Running] node "f:\JavaScript\手写API\async-await.js"
161
168
265
OK

[Done] exited with code=0 in 3.129 seconds

如果你对PromisesetTimeout熟悉的话,很容易就能看出getData()是过1s获取一个0到519之间的随机数。

如果你看明白了我前面解释的Generator相关知识或者本来就很熟悉Generator的话,对于generator()函数里面的代码应该很容易就能看懂。

我们重点分析一下asyncFuncRunner(...)这个函数。

  1. 10行,返回一个函数,因为我们模拟的是一个async函数,所以要返回一个函数
  2. 11行,调用生成器生成迭代器,这里用的是apply,主要是为了传值
  3. 12行,返回一个promise,因为async函数返回的是一个promise
  4. 13行,定义了一个step函数去遍历生成器里的所有异步任务,参数method代码执行next/throw方法,参数data代表next(...)中要传的值
  5. 14-20行,是防止两个yield之间的同步代码有抛出的异常,所以用try catch包起来,infonext()返回的对象,有valuedone两个属性,若遇到异常reject(err)然后函数执行结束返回一个失败的promise
  6. 21到23行,判断异步任务是否都执行完毕,如果执行完毕调用resolve(...)返回一个成功的promise,其value为生成器函数的返回值
  7. 23到28行,先将value值转换成成功的promise然后执行其then方法,成功则继续遍历,即调用迭代器的next()方法,失败则调用throw()方法
  8. 30行,在promiseexecutor里执行step('next')方法启动生成器函数

以上,便是本文全部内容,欢迎在评论区讨论交流。