進階 Javascript 生成器

我曾一度認爲沒有必要去學習 Javascript 的生成器( Generator ),認爲它只是解決異步行爲的一種過渡解決方案,直到最近對相關工具庫的深刻學習,才逐漸認識到其強大之處。可能你並無手動去寫過一個生成器,可是不得不否定它已經被普遍使用,尤爲是在 redux-sagaRxJS 等優秀的開源工具。javascript


可迭代對象和迭代器

首先須要明確的是生成器其實來源於一種設計模式 —— 迭代器模式,在 Javascript 中迭代器模式的表現形式是可迭代協議,而這也是 ES2015 中迭代器和可迭代對象的來源,這是兩個容易讓人混淆的概念,但事實上 ES2015 對其作了明確區分。java

定義

實現了 next 方法的對象被稱爲迭代器next 方法必須返回一個 IteratorResult 對象,該對象形如:node

{ value: undefined, done: true }
複製代碼

其中 value 表示這次迭代的結果,done 表示是否已經迭代完畢。react

實現了 @@iterator 方法的對象稱爲可迭代對象,也就是說該對象必須有一個名字是 [Symbol.iterator] 的屬性,這個屬性是一個函數,返回值必須是一個迭代器git

String, Array, TypedArray, MapSet 是 Javascript 中內置的可迭代對象,好比,Array.prototype[Symbol.iterator]Array.prototype.entries 會返回同一個迭代器:es6

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 中新增的數組解構也會默認使用迭代器進行迭代:github

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 } 時,迭代結束。json

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

有兩種方法返回生成器:redux

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 的實現原理,其中一樣涉及到本文所所說起的迭代器模式。最後附上相關參考資料,以供感興趣的讀者繼續學習。


參考資料

相關文章
相關標籤/搜索