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

时间:2021-6-12 作者:qvyue

一.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 

    经过上面讨论我们知道,对于遍历器,只有执行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.then方法也能做到这一点。
    沿用上面例子对其进行改造,我们要做的其实很简单

  • 将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被誉为异步编程的终极解决方案,后面会着重介绍。

参考:https://es6.ruanyifeng.com/#docs/generator-async

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:qvyue@qq.com 进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。