阅读 2803

进阶 Javascript 生成器

我曾一度认为没有必要去学习 Javascript 的生成器( Generator ),认为它只是解决异步行为的一种过渡解决方案,直到最近对相关工具库的深入学习,才逐渐认识到其强大之处。可能你并没有手动去写过一个生成器,但是不得不否认它已经被广泛使用,尤其是在 redux-sagaRxJS 等优秀的开源工具。


可迭代对象和迭代器

首先需要明确的是生成器其实来源于一种设计模式 —— 迭代器模式,在 Javascript 中迭代器模式的表现形式是可迭代协议,而这也是 ES2015 中迭代器和可迭代对象的来源,这是两个容易让人混淆的概念,但事实上 ES2015 对其做了明确区分。

定义

实现了 next 方法的对象被称为迭代器next 方法必须返回一个 IteratorResult 对象,该对象形如:

{ value: undefined, done: true }
复制代码

其中 value 表示此次迭代的结果,done 表示是否已经迭代完毕。

实现了 @@iterator 方法的对象称为可迭代对象,也就是说该对象必须有一个名字是 [Symbol.iterator] 的属性,这个属性是一个函数,返回值必须是一个迭代器

String, Array, TypedArray, MapSet 是 Javascript 中内置的可迭代对象,比如,Array.prototype[Symbol.iterator]Array.prototype.entries 会返回同一个迭代器:

const a = [1, 3, 5];
a[Symbol.iterator]() === a.entries();   // true
const iter = a[Symbol.iterator]();      // Array Iterator {}
iter.next()                             // { value: 1, done: false }
复制代码

ES2015 中新增的数组解构也会默认使用迭代器进行迭代:

const arr = [1, 3, 5];
[...a];                        // [1, 3, 5]
const str = 'hello';
[...str];                      // ['h', 'e', 'l', 'l', 'o']
复制代码

自定义迭代行为

既然可迭代对象是实现了 @@iterator 方法的对象,那么可迭代对象就可以通过重写 @@iterator 方法实现自定义迭代行为:

const arr = [1, 3, 5, 7];
arr[Symbol.iterator] = function () {
  const ctx = this;
  const { length } = ctx;
  let index = 0;
  return {
    next: () => {
      if (index < length) {
        return { value: ctx[index++] * 2, done: false };
      } else {
        return { done: true };
      }
    }
  };
};

[...arr];       // [2, 6, 10, 14]
复制代码

从上面可以看出,当 next 方法返回 { done: true } 时,迭代结束。

生成器既是可迭代对象也是迭代器

有两种方法返回生成器:

const counter = (function* () {
  let c = 0;
  while(true) yield ++c;
})();

counter.next();   // { value: 1, done: false },counter 是一个迭代器

counter[Symbol.iteratro]();
// counterGen {[[GeneratorStatus]]: "suspended"}, counter 是一个可迭代对象
复制代码

上面的代码中的 counter 就是一个生成器,实现了一个简单的计数功能。不仅没有使用闭包也没有使用全局变量,实现过程非常优雅。


生成器的基本语法

生成器的强大之处在于能方便地对生成器函数内部的逻辑进行控制。在生成器函数内部,通过 yieldyield* ,将当前生成器函数的控制权移交给外部,外部通过调用生成器的 nextthrowreturn 方法将控制权返还给生成器函数,并且还能够向其传递数据。

yield 和 yield* 表达式

yieldyield* 只能在生成器函数中使用。生成器函数内部通过 yield 提前返回,前面的计数器就是利用这个特性向外部传递计数的结果。需要注意的是前面的计数器是无限执行的,只要生成器调用 next 方法,IteratorResultvalue 就会一直递增下去,如果想计数个有限值,需要在生成器函数里面使用 return 表达式:

const ceiledCounter = (function* (ceil) {
  let c = 0;
  while(true) {
    ++c;
    if (c === ceil) return c;
    yield c;
  }
})(3);

ceiledCounter.next();   // { value: 1, done: false }
ceiledCounter.next();   // { value: 2, done: false }
ceiledCounter.next();   // { value: 3, done: true }
ceiledCounter.next();   // { value: undefined, done: true }
复制代码

yield 后可以不带任何表达式,返回的 valueundefined

const gen = (function* () {
  yield;
})();
gen.next();    // { value: undefined, done: false }
复制代码

生成器函数通过使用 yield* 表达式用于委托给另一个可迭代对象,包括生成器。

委托给 Javascript 内置的可迭代对象:

const genSomeArr = function* () {
  yield 1;
  yield* [2, 3];
};

const someArr = genSomeArr();
greet.next();   // { value: 1, done: false }
greet.next();   // { value: 2, done: false }
greet.next();   // { value: 3, done: false }
greet.next();   // { value: undefined, done: true }
复制代码

委托给另一个生成器(还是利用上面的 genGreet 生成器函数):

const genAnotherArr = function* () {
  yield* genSomeArr();
  yield* [4, 5];
};

const anotherArr = genAnotherArr();
greetWorld.next();   // { value: 1, done: false}
greetWorld.next();   // { value: 2, done: false}
greetWorld.next();   // { value: 3, done: false}
greetWorld.next();   // { value: 4, done: false}
greetWorld.next();   // { value: 5, done: false}
greetWorld.next();   // { value: undefined, done: true}
复制代码

yield 表达式是有返回值的,接下来解释具体行为。

next 、throw 和 return 方法

生成器函数外部正是通过这三个方法去控制生成器函数的内部执行过程的。

next

生成器函数外部可以向 next 方法传递一个参数,这个参数会被当作上一个 yield 表达式的返回值,如果不传递任何参数,yield 表达式返回 undefined

const canBeStoppedCounter = (function* () {
  let c = 0;
  let shouldBreak = false;
  while (true) {
    shouldBreak = yield ++c;
    console.log(shouldBreak);
    if (shouldBreak) return;
  }
};

canBeStoppedCounter.next();
// { value: 1, done: false }

canBeStoppedCounter.next();
// undefined,第一次执行 yield 表达式的返回值
// { value: 2, done: false }

canBeStoppedCounter.next(true);
// true,第二次执行 yield 表达式的返回值
// { value: undefined, done: true }
复制代码

再来看一个连续传入值的例子:

const greet = (function* () {
  console.log(yield);
  console.log(yield);
  console.log(yield);
  return;
})();
greet.next();        // 执行第一个 yield表达式
greet.next('How');   // 第一个 yield 表达式的返回值是 "How",输出 "How"
greet.next('are');   // 第二个 yield 表达式的返回值是 "are",输出"are"
greet.next('you?');  // 第三个 yield 表达式的返回值是 "you?",输出 "you"
greet.next();        // { value: undefined, done: true }
复制代码

throw

生成器函数外部可以向 throw 方法传递一个参数,这个参数会被 catch 语句捕获,如果不传递任何参数,catch 语句捕获到的将会是 undefinedcatch 语句捕获到之后会恢复生成器的执行,返回带有 IteratorResult

const caughtInsideCounter = (function* () {
  let c = 0;
  while (true) {
    try {
      yield ++c;
    } catch (e) {
      console.log(e);
    }
  }
})();

caughtInsideCounter.next();    // { value: 1, done: false}
caughtIndedeCounter.throw(new Error('An error occurred!'));
// 输出 An error occurred!
// { value: 2, done: false }
复制代码

需要注意的是如果生成器函数内部没有 catch 到,则会在外部 catch 到,如果外部也没有 catch 到,则会像所有未捕获的错误一样导致程序终止执行:

return

生成器的 return 方法会结束生成器,并且会返回一个 IteratorResult,其中 donetruevalue 是向 return 方法传递的参数,如果不传递任何参数,value 将会是 undefined

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

g.next();        // { value: 1, done: false }
g.return("foo"); // { value: "foo", done: true }
g.next();        // { value: undefined, done: true }
复制代码

通过上面三个方法,使得生成器函数外部对生成器函数内部程序执行流程有了一个非常强的控制力。


生成器的异步应用

生成器函数与异步操作结合是非常自然的表达:

const fetchUrl = (function* (url) {
  const result = yield fetch(url);
  console.log(result);
})('https://api.github.com/users/github');

const fetchPromise = fetchUrl.next().value;
fetchPromise
  .then(response => response.json())
  .then(jsonData => fetchUrl.next(jsonData));

// {login: "github", id: 9919, avatar_url: "https://avatars1.githubusercontent.com/u/9919?v=4", gravatar_id: "", url: "https://api.github.com/users/github", …}
复制代码

在上面的代码中,fetch 方法返回一个 Promise 对象 fetchPromisefetchPromise 通过一系列的解析之后会返回一个 JSON 格式的对象 jsonData,将其通过 fetchUrlnext 方法传递给生成器函数中的 result,然后打印出来。

生成器的局限性

从上面的过程可以看出,生成器配合 Promise 确实可以很简洁的进行异步操作,但是还不够,因为整个异步流程都是我们手动编写的。当异步行为变的更加复杂起来之后(比如一个异步操作的队列),生成器的异步流程管理过程也将会变得难以编写和维护。

需要一种能自动执行异步任务的工具进行配合,生成器才能真正派上用场。实现这种工具通常有两种思路:

  • 通过不断进行回调函数的执行,直到全部过程执行完毕,基于这种思路的是 thunkify 模块;
  • 使用 Javascript 原生支持的 Promise 对象,将异步过程扁平化处理,基于这种思路的是 co 模块;

下面来分别理解和实现。

thunkify

thunk 函数的起源其实很早,而 thunkify 模块也作为异步操作的一种普遍解决方案,thunkify源码非常简洁,加上注释也才三十行左右,建议所有学习异步编程的开发者都去阅读一遍。

理解了 thunkify 的思想之后,可以将其删减为一个简化版本(只用于理解,不用于生产环境中):

const thunkify = fn => {
  return (...args) => {
    return callback => {
      return Reflect.apply(fn, this, [...args, callback]);
    };
  };
};
复制代码

从上面的代码可以看出,thunkify 函数适用于回调函数是最后一个参数的异步函数,下面我们构造一个符合该风格的异步函数便于我们调试:

const asyncFoo = (id, callback) => {
  console.log(`Waiting for ${id}...`)
  return setTimeout(callback, 2000, `Hi, ${id}`)
};
复制代码

首先是基本使用:

const foo = thunkify(asyncFoo);
foo('Juston')(greetings => console.log(greetings));
// Waiting for Juston...
// ... 2s later ...
// Hi, Juston
复制代码

接下来我们模拟实际需求,实现每隔 2s 输出一次结果。首先是构造生成器函数:

const genFunc = function* (callback) {
  callback(yield foo('Carolanne'));
  callback(yield foo('Madonna'));
  callback(yield foo('Michale'));
};
复制代码

接下来实现一个自动执行生成器的辅助函数 runGenFunc

const runGenFunc = (genFunc, callback, ...args) => {
  const g = genFunc(callback, ...args);

  const seqRun = (data) => {
    const result = g.next(data);
    if (result.done) return; 
    result.value(data => seqRun(data));
  }

  seqRun();
};
复制代码

注意 g.next().value 是一个函数,并且接受一个回调函数作为参数,runGenFunc 通过第 7 行的代码实现了两个关键步骤:

  • 将上一个 yield 表达式的结果返回之生成器函数
  • 执行当前 yield 表达式

最后是调用 runGenFunc 并且将 genFunc 、需要用到的回调函数 callback 以及其他的生成器函数参数(这里的生成器函数只有一个回调函数作为参数)传入:

runGenFunc(genFunc, greetings => console.log(greetings));
// Waiting for Carolanne...
// ... 2s later ...
// Hi, Carolanne
// Waiting for Madonna...
// ... 2s later ...
// Hi, Madonna
// Waiting for Michale...
// ... 2s later ...
// Hi, Michale
复制代码

可以看到输出结果确实如期望的那样,每隔 2s 进行一次输出。

从上面的过程来看,使用 thunkify 模块进行异步流程的管理还是不够方便,原因在于我们不得不自己引入一个辅助的 runGenFunc 函数来进行异步流程的自动执行。

co

co 模块可以帮我们完成异步流程的自动执行工作。co 模块是基于 Promise 对象的。co 模块的源码同样非常简洁,也比较适合阅读。

co 模块的 API 只有两个:

  • co(fn*).then(val => )

    co 方法接受一个生成器函数为唯一参数,并且返回一个 Promise 对象,基本使用方法如下:

    const promise = co(function* () {
      return yield Promise.resolve('Hello, co!');
    })
    promise
      .then(val => console.log(val))   // Hello, co!
      .catch((err) => console.error(err.stack));
    复制代码
  • fn = co.wrap(fn*)

    co.wrap 方法在 co 方法的基础上进行了进一步的包装,返回一个类似于 createPromise 的函数,它与 co 方法的区别就在与可以向内部的生成器函数传递参数,基本使用方法如下。

    const createPromise = co.wrap(function* (val) {
      return yield Promise.resolve(val);
    });
    createPromise('Hello, jkest!')
      .then(val => console.log(val))   // Hello, jkest!
      .catch((err) => console.error(err.stack));
    复制代码

co 模块需要我们将 yield 关键字后面的对象改造为一个 co 模块自定义的 yieldable 对象,通常可以认为是 Promise 对象或基于 Promise 对象的数据结构。

了解了 co 模块的使用方法后,不难写出基于 co 模块的自动执行流程。

只需要改造 asyncFoo 函数让其返回一个 yieldable 对象,在这里即是 Promise 对象:

const asyncFoo = (id) => {
    return new Promise((resolve, reject) => {
      console.log(`Waiting for ${id}...`);
      if(!setTimeout(resolve, 2000, `Hi, ${id}`)) {
        reject(new Error(id));
      }
    });
};
复制代码

然后就可以使用 co 模块进行调用,由于需要向 genFunc 函数传入一个 callback 参数,所以必须使用 co.wrap 方法:

co.wrap(genFunc)(greetings => console.log(greetings));
复制代码

上述结果与期望一致。

其实 co 模块内部的实现方式与 thunkify 小节中的 runGenFunc 函数有异曲同工之处,都是使用递归函数反复去执行 yield 语句,知道生成器函数迭代结束,主要的区别就在于 co 模块是基于 Promise 实现的。


可能在实际工作中的大部分时候都可以使用外部模块去完成相应的功能,但是想理解实现原理或者不想引用外部模块,则深入理解生成器的使用就很重要了。在下一篇文章[观察者模式在 Javascript 中的应用]中我会探究 RxJS 的实现原理,其中同样涉及到本文所所提及的迭代器模式。最后附上相关参考资料,以供感兴趣的读者继续学习。


参考资料

关注下面的标签,发现更多相似文章
评论