浅谈ES6 Generator函数的异步应用与co模块的实现原理

379

一.Generator函数的概念

    Generator函数是 ES6 提供的一种异步编程解决方案。前面讨论过的Promise对象也是ES6提供的异步解决方案,为什么还要提出Generator呢。

    使用Promise对象处理异步固然有不少优势,尤其是可以将回调地狱的处理变为then的链式调用。但也不可避免的存在一些缺点,例如经过Promise包装的异步会包含大量的Promise名词(resolve,reject,then...),可读性不好。

    其实,异步任务的最佳处理方式应当是像操作同步任务那样操作异步任务,即异步任务之后的代码直接写在异步下面,而不是写在回调函数或then方法中。Generator 函数的提出就是为了解决这个问题。如何做到将异步的操作同步化呢。试想,我们如果能赋予函数'暂停'执行的功能,即遇到异步任务时,将当前上下文的状态暂存起来,等到异步任务结束后,拿到异步结果再继续向下执行,这样就能实现上述需求。这就是Generator 函数的异步处理思想。

    如何能实现函数的‘暂停'执行?这里要引出Iterator接口(遍历器)的概念

二.Iterator的概念

    Iterator是一种接口,它为不同的数据结构提供统一的访问机制。任何数据结构只要部署了Iterator 接口,就可以完成遍历操作。

    Iterator可以认为是一个指针对象,通过next方法对数据结构进行遍历,每次调用next方法,指针就指向数组的下一个成员并返回数据结构的当前成员的信息。该信息是一个对象,包含value和done两个属性。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

    ES6规定,Iterator 接口部署在数据结构的Symbol.iterator属性,调用这个接口,就会返回一个遍历器对象。 下面用数组为栗子演示

let arr = [1, 2, 3];
// 返回遍历器对象
let it = arr[Symbol.iterator]();
// 通过next方法遍历
console.log(it.next()) // { value: 1, done: false }
console.log(it.next()) // { value: 2, done: false }
console.log(it.next()) // { value: 3, done: false }
console.log(it.next()) // { value: undefined, done: true }

    并不是所有的数据结构都原生具备 Iterator 接口。ES6中原生具备 Iterator 接口的数据结构如下。

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

    Iterator 接口用于for...of循环,也就是说,一个数据结构只要部署了Iterator 接口,他就可以被for...of遍历。反之则无法遍历(如object)。

    不过,我们可以给没有原生Iterator 接口的数据结构手动部署该接口。具体来说,就是给其添加Symbol.iterator属性,它是一个函数,调用该函数,返回遍历器对象。这样,我们用for...of对其遍历时,就会手动调用我们部署的Iterator 接口。下面演示给object部署Iterator 接口。

const obj = {
  a: 'a',
  b: 'b',
  c: 'c',
  [Symbol.iterator]: function () {
    let keys = Object.keys(this);
    let index = 0;
    return {
      next: function () {
        return index < keys.length
          ? {
              value: this[keys[index++]],
              done: false,
            }
          : {
              value: undefined,
              done: true,
            };
      }.bind(this),
    };
  },
};
for (const it of obj) {
  console.log(it)
}
// a 
// b
// c

    经过上面讨论我们知道,对于遍历器,只有执行next方法,才会继续向下遍历。Generator 函数正是利用这一点,实现异步操作的同步化。进一步讲,执行 Generator 函数会返回一个遍历器对象。它可以遍历Generator 函数内部封装的多个状态。下面具体分析。

三.Generator函数的形式与基本使用

1. Generator 函数的形式。

Generator函数有两个区别于普通函数的明显特征。

  • function关键字与函数名之间有一个星号。
  • 函数体内部使用yield表达式划分不同部状态。
function*gen(){
  yield 1
  yield 2
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 1, done: false }
console.log(g.next()) // { value: 2, done: false }
console.log(g.next()) // { value: undefined, done: true }

2. yield表达式

    通过上面例子我们能看出,yield表达式就是用来划分Generator 函数的各个状态,他可以理解为函数暂停的标志。当执行next()方法,遇到yield表达式时,就暂停执行后面的操作,并将yield表达式的值作为next方法返回的信息对象的value属性值。下次调用next()方法,继续执行yield表达式后面的操作。这一点很重要,我们将利用这一点实现像操作同步那样操作异步。

function*gen(){
  yield 1+2
  yield 2+3
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 3, done: false }
console.log(g.next()) // { value: 5, done: false }
console.log(g.next()) // { value: undefined, done: true }

四.Generator函数的异步应用

    我们已经了解了Generator 函数的基本特点,回到最开始的问题,如何实现异步操作的同步化。我们的需求是在异步操作结束后,再执行后面的操作,而Generator 函数的特点是只有在执行next方法后,函数从当前状态变为下一状态。因此我们只需用yield,将每个异步操作划为一个状态,这样就可以保证遇到异步操作时函数暂停执行。而在每个异步操作结束的时,调用next方法,使得函数继续执行,这就实现了用同步操作的逻辑来操作异步。

    要实现上述,还需解决两个问题。

1. 传递异步结果

    我们知道,异步操作之后的处理往往需要异步的返回结果,那么一个首要问题就是如何将异步返回结果传递出来。

    我们要明确一点,yield表达式是没有返回值的(undefinded),也就是说直接使用下面这种方式是行不通的。

function*gen(){
  const res = yield async1()
  yield async2(res)
}

    要传递结果,我们要借助next方法。next方法如果有入参,该参数会被当作上一个yield表达式的返回值。

function*gen(){
  const res1 =  yield 1
  const res2 = yield res1+1
  yield res2+2
}
// 得到遍历器对象
let g = gen()
console.log(g.next()) // { value: 1, done: false }
// next方法传入3 认为res1=3 3+1=4
console.log(g.next(3)) // { value: 4, done: false }
console.log(g.next(4)) // { value: 6, done: false }

因此,我们只需将异步的返回结果传入next方法即可

2. 异步结束后调用next方法

    我们日常对异步的处理无非是回调函数和Promsie两种方式,因此也就有两种思路解决该问题。

2.1 基于回调函数的Generator异步流程处理

    我们只需在异步的回调函数中调用next方法,即可实现异步结束后继续执行Generator函数。

const async1 = () => {
  setTimeout(() => {
    // 执行next方法 传递异步结果
    g.next(1);
  });
};
const async2 = (res) => {
  setTimeout(() => {
    console.log(res + " from async1");
    g.next(2);
  });
};
const async3 = (res) => {
  setTimeout(() => {
    console.log(res + " from async2");
  });
};
function* gen() {
  const res1 = yield async1();
  const res2 = yield async2(res1);
  yield async3(res2);
}
// 得到遍历器对象
let g = gen();
g.next();
//1 from async1
//2 from async2

    上面的代码基本实现了需求,我们发现Generator函数内部的异步逻辑处理,如果去掉yield就基本和同步操作一样了。

    不过,上面代码的问题也很明显,我们需要对每个异步的回调进行处理。这样是很低效的,因为我们发现在回调中做的其实是同一件事,即执行next方法并传入异步返回结果。我们如果能将这个过程抽离出来,并自动执行。将使得代码逻辑大为简化。下面依次解决这两个问题。

  • 抽取回调函数的处理

    如何能将回调函数的处理抽离出来?

    以setTimeot函数为例,它接受两个参数,分别是回调函数和延时时间。而我们希望将这个两个参数分开传入,单独处理。这里就可以想到前面讨论过的柯里化函数柯里化函数可以将接受多个参数的函数变换成接受一个单一参数,并返回接受余下参数的函数

    还是以setTimeot函数为例,如果经过柯里化处理,我们可以先传入延时时间,再向返回的函数中传入回调,这就实现了上述需求。像下面这样

function currying(time) {
  return (cb) => {
    return setTimeout(cb, time);
  };
}
const curryTimeout = currying(500);
curryTimeout(() => {
  console.log("timeOut");
});

    接下来的问题是,在什么地方处理异步回调。我们知道,next方法返回值的value属性,就是yield表达式的执行结果。我们如果在yield后面执行经过柯里化处理过的异步(如上例中的currying(500)),就会使得next方法返回值的value属性是一个函数,可以传入异步的回调。因此我们只需将回调函数传入next方法的value属性即可。下面就基于上述对上例进行改造。

function currying(time) {
  return (cb) => {
    return setTimeout(cb, time);
  };
}

function* gen() {
  const res1 = yield currying(500);
  const res2 = yield currying(res1);
  yield currying(res2);
}

const g = gen();
g.next().value(() => {
  console.log("async1");
  g.next(500).value(() => {
    console.log("async2");
    g.next(500).value(() => {
      console.log("async3");
    });
  });
});
//每隔0.5秒依次打印async1 async2 async3

    可以看到代码逻辑清晰了很多。这里还要说明一点,事实上前面所谓的经过柯里化处理的异步,就是Thunk 函数。所谓的Thunk 函数,其实就是一个临时函数,它可以把一个多参数函数,替换成一个只接受回调函数作为参数的单参数函数。如上例中的curryTimeout函数,它就是一个只接受回调函数作为参数的中间函数,也就是Thunk 函数。用阮一峰老师的话说就是:任何函数,只要参数有回调函数,就能写成 Thunk 函数的形式。上面的例子相当于手动实现了一个丐版Thunk 函数转换器,生产环境中一般使用nodejs的Thunkify模块,它可以实现Thunk 函数的转换。

    接下来要做的就是变手动执行为自动执行。

  • 自动执行

    仔细观察手动执行Generator 函数的代码会返现,我们做的其实只有一件事,即把同一个回调传入next方法的value属性,而回调要做的就是执行next方法并传递异步结果。

    基于上述,我们可以实现Generator 函数按照既定逻辑自动执行的程序。它只需判断next方法返回值的done属性,只要不为true,就一直将回调传入next方法的value属性。

    下面用node.js fs模块的readFileAPI演示,使用thunkify模块将异步API转换为Thunk函数。准备两个文本文件,内容分别是'对酒当歌' '人生几何'。

const thunkify = require("thunkify");
const fs = require("fs");
const readFileThunk = thunkify(fs.readFile)

function* gen() {
  yield readFileThunk("./text1.txt");
  yield readFileThunk("./text2.txt");
}

function run(fn) {
  const gen = fn();
  function next(err, data) {
    // 错误优先的回调
    if (data) console.log(data.toString());
    const res = gen.next(data);
    if (res.done) return;
    res.value(next);
  }
  next();
}
run(gen);
// 对酒当歌
// 人生几何

    可以看到,有了自动执行器,我们只管在Generator函数内部处理异步,最后直接把 Generator 函数传入run函数即可,当然前提是yield表达式必须是Thunk函数。

2.2 基于Promise的Generator异步流程处理

    通过观察前面实现的基于回调的Generator函数自动执行器不难看出,自动执行的关键其实就是在异步结束后调用next方法,让Generator函数继续执行。同样,利用Promise也能做到这一点。

    沿用上面例子对其进行改造,我们要做的其实很简单

  • 将readFile函数包装为Promise
  • 利用Promise.then方法自动执行
const fs = require("fs");
function promisify_readFile(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

function* gen() {
  yield promisify_readFile("./text1.txt");
  yield promisify_readFile("./text2.txt");
}

function run(fn) {
  const gen = fn();
  function next(data) {
    if (data) console.log(data.toString());
    const res = gen.next(data);
    if (res.done) return;
    //res.value返回的是Promise,可以通过then方法继续执行Generator
    res.value.then(next,(r)=>console.log(r))
  }
  next();
}
run(gen);

    至此我们已经基本实现了像文章开头的需求,并实现了自动执行,其实这就是著名的co模块的核心实现原理。

五.co模块及其实现原理

    co模块一个著名的用于Generator函数自动执行的模块。它的使用非常简单,只需将Generator函数传入co,即可自动执行。

const co = require("co");
function* gen() {
  const res1 = yield promisify_readFile("./text1.txt");
  console.log(res1.toString())
  const res2 = yield promisify_readFile("./text2.txt");
  console.log(res2.toString())
}
co(gen)
// 对酒当歌
// 人生几何
实现原理

    其实,经过上面对Generator函数自动执行的讨论我们能够知道,co模块核心实现原理就是我们实现的run函数的扩展。具体如下

  • co返回的是Promise 对象,因此要添加一些改变Promise状态的逻辑
  • 要确保每一步的返回值都是Promise

下面实现一个丐版的co模块

function co(gen) {
  return new Promise(function (resolve, reject) {
    gen = gen();
    if (!gen || typeof gen.next !== "function") return resolve(gen);
    function next(data) {
      const res = gen.next(data);
      if (res.done) {
        return resolve(res.value);
      } else {
        // 确保每一步的返回值都是Promise
        const value = Promise.resolve(res.value);
        value.then(next, (r) => reject(r));
      }
    }
    next();
  });
}
// 由于co返回的是Promise,因此可以指定then方法使得
// 在Generator执行完成后进行一些操作
co(gen).then(()=>console.log('end'))
// 对酒当歌
// 人生几何
// end

    co模块是async/await关键字的前身,async/await被誉为异步编程的终极解决方案,后面会继续讨论。

参考:es6.ruanyifeng.com/#docs/gener…