ES6--17 遍歷器生成 -- Generator 函數

Generator 函數的語法

簡介

基本概念

Generator 函數是 ES6 提供的一種異步編程解決方案,語法行爲與傳統函數完全不同。

執行 Generator 函數還是一個遍歷器對象生成函數。返回的遍歷器對象,可以依次遍歷 Generator 函數內部的每一個狀態。

形式上,Generator 函數是一個普通函數,但是有兩個特徵。
一是,function關鍵字與函數名之間有一個星號;
二是,函數體內部使用yield表達式,定義不同的內部狀態(yield在英語裏的意思就是“產出”)。

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();

上面代碼定義了一個 Generator 函數helloWorldGenerator,它內部有兩個yield表達式(helloworld),即該函數有三個狀態:hello,world 和 return 語句(結束執行)。

然後,Generator 函數的調用方法與普通函數一樣,也是在函數名後面加上一對圓括號。不同的是,調用 Generator 函數後,該函數並不執行,返回的也不是函數運行結果,而是一個指向內部狀態的指針對象,也就是遍歷器對象(Iterator Object)。

下一步,必須調用遍歷器對象的next方法,使得指針移向下一個狀態。也就是說,每次調用next方法,內部指針就從函數頭部或上一次停下來的地方開始執行,直到遇到下一個yield表達式(或return語句)爲止。換言之,Generator 函數是分段執行的,yield表達式是暫停執行的標記,而next方法可以恢復執行。

hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

上面代碼一共調用了四次next方法。

第一次調用,Generator 函數開始執行,直到遇到第一個yield表達式爲止。next方法返回一個對象,它的value屬性就是當前yield表達式的值hellodone屬性的值false,表示遍歷還沒有結束。

第二次調用,Generator 函數從上次yield表達式停下的地方,一直執行到下一個yield表達式。next方法返回的對象的value屬性就是當前yield表達式的值worlddone屬性的值false,表示遍歷還沒有結束。

第三次調用,Generator 函數從上次yield表達式停下的地方,一直執行到return語句(如果沒有return語句,就執行到函數結束)。next方法返回的對象的value屬性,就是緊跟在return語句後面的表達式的值(如果沒有return語句,則value屬性的值爲undefined),done屬性的值true,表示遍歷已經結束。

第四次調用,此時 Generator 函數已經運行完畢,next方法返回對象的value屬性爲undefineddone屬性爲true。以後再調用next方法,返回的都是這個值。

總結一下,調用 Generator 函數,返回一個遍歷器對象,代表 Generator 函數的內部指針。以後,每次調用遍歷器對象的next方法,就會返回一個有着valuedone兩個屬性的對象。value屬性表示當前的內部狀態的值,是yield表達式後面那個表達式的值;done屬性是一個布爾值,表示是否遍歷結束。

ES6 沒有規定,function關鍵字與函數名之間的星號,寫在哪個位置。這導致下面的寫法都能通過。

function * foo(x, y) { ··· }
function *foo(x, y) { ··· }
function* foo(x, y) { ··· }
function*foo(x, y) { ··· }

由於 Generator 函數仍然是普通函數,所以一般的寫法是上面的第三種,即星號緊跟在function關鍵字後面。本書也採用這種寫法。

yield 表達式

由於 Generator 函數返回的遍歷器對象,只有調用next方法纔會遍歷下一個內部狀態,所以其實提供了一種可以暫停執行的函數。yield表達式就是暫停標誌。

遍歷器對象的next方法的運行邏輯如下。

(1)遇到yield表達式,就暫停執行後面的操作,並將緊跟在yield後面的那個表達式的值,作爲返回的對象的value屬性值。

(2)下一次調用next方法時,再繼續往下執行,直到遇到下一個yield表達式。

(3)如果沒有再遇到新的yield表達式,就一直運行到函數結束,直到return語句爲止,並將return語句後面的表達式的值,作爲返回的對象的value屬性值。

(4)如果該函數沒有return語句,則返回的對象的value屬性值爲undefined

需要注意的是,yield表達式後面的表達式,只有當調用next方法、內部指針指向該語句時纔會執行,因此等於爲 JavaScript 提供了手動的“惰性求值”(Lazy Evaluation)的語法功能。

function* gen() {
  yield  123 + 456;
}

上面代碼中,yield後面的表達式123 + 456,不會立即求值,只會在next方法將指針移到這一句時,纔會求值。

yield表達式與return語句既有相似之處,也有區別。相似之處在於,都能返回緊跟在語句後面的那個表達式的值。區別在於每次遇到yield,函數暫停執行,下一次再從該位置繼續向後執行,而return語句不具備位置記憶的功能。一個函數裏面,只能執行一次(或者說一個)return語句,但是可以執行多次(或者說多個)yield表達式。正常函數只能返回一個值,因爲只能執行一次return;Generator 函數可以返回一系列的值,因爲可以有任意多個yield。從另一個角度看,也可以說 Generator 生成了一系列的值,這也就是它的名稱的來歷(英語中,generator 這個詞是“生成器”的意思)。

Generator 函數可以不用yield表達式,這時就變成了一個單純的暫緩執行函數。

function* f() {
  console.log('執行了!')
}

var generator = f();

setTimeout(function () {
  generator.next()
}, 2000);

上面代碼中,函數f如果是普通函數,在爲變量generator賦值時就會執行。但是,函數f是一個 Generator 函數,就變成只有調用next方法時,函數f纔會執行。

另外需要注意,yield表達式只能用在 Generator 函數裏面,用在其他地方都會報錯。

(function (){
  yield 1;
})()
// SyntaxError: Unexpected number

上面代碼在一個普通函數中使用yield表達式,結果產生一個句法錯誤。

另外,yield表達式如果用在另一個表達式之中,必須放在圓括號裏面。

function* demo() {
  console.log('Hello' + yield); // SyntaxError
  console.log('Hello' + yield 123); // SyntaxError

  console.log('Hello' + (yield)); // OK
  console.log('Hello' + (yield 123)); // OK
}

yield表達式用作函數參數或放在賦值表達式的右邊,可以不加括號。

function* demo() {
  foo(yield 'a', yield 'b'); // OK
  let input = yield; // OK
}

與 Iterator 接口的關係

任意一個對象的Symbol.iterator方法,等於該對象的遍歷器生成函數,調用該函數會返回該對象的一個遍歷器對象。由於 Generator 函數就是遍歷器生成函數,因此可以把 Generator 賦值給對象的Symbol.iterator屬性,從而使得該對象具有 Iterator 接口。

var myIterable = {};
myIterable[Symbol.iterator] = function* () {
  yield 1;
  yield 2;
  yield 3;
};

[...myIterable] // [1, 2, 3]

上面代碼中,Generator 函數賦值給Symbol.iterator屬性,從而使得myIterable對象具有了 Iterator 接口,可以被...運算符遍歷了。

Generator 函數執行後,返回一個遍歷器對象。該對象本身也具有Symbol.iterator屬性,執行後返回自身。

function* gen(){
  // some code
}

var g = gen();

g[Symbol.iterator]() === g
// true

上面代碼中,gen是一個 Generator 函數,調用它會生成一個遍歷器對象g。它的Symbol.iterator屬性,也是一個遍歷器對象生成函數,執行後返回它自己。

next 方法的參數

yield表達式本身沒有返回值,或者說總是返回undefinednext方法可以帶一個參數,該參數就會被當作上一個yield表達式的返回值。

function* f() {
  for(var i = 0; true; i++) {
    var reset = yield i;
    if(reset) { i = -1; }
  }
}

var g = f();

g.next() // { value: 0, done: false }
g.next() // { value: 1, done: false }
g.next(true) // { value: 0, done: false }

上面代碼先定義了一個可以無限運行的 Generator 函數f,如果next方法沒有參數,每次運行到yield表達式,變量reset的值總是undefined。當next方法帶一個參數true時,變量reset就被重置爲這個參數(即true),因此i會等於-1,下一輪循環就會從-1開始遞增。

這個功能有很重要的語法意義。Generator 函數從暫停狀態到恢復運行,它的上下文狀態(context)是不變的。通過next方法的參數,就有辦法在 Generator 函數開始運行之後,繼續向函數體內部注入值。也就是說,可以在 Generator 函數運行的不同階段,從外部向內部注入不同的值,從而調整函數行爲。

再看一個例子。

function* foo(x) {
  var y = 2 * (yield (x + 1));
  var z = yield (y / 3);
  return (x + y + z);
}

var a = foo(5);
a.next() // Object{value:6, done:false}
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

var b = foo(5);
b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

上面代碼中,第二次運行next方法的時候不帶參數,導致 y 的值等於2 * undefined(即NaN),除以 3 以後還是NaN,因此返回對象的value屬性也等於NaN。第三次運行Next方法的時候不帶參數,所以z等於undefined,返回對象的value屬性等於5 + NaN + undefined,即NaN

如果向next方法提供參數,返回結果就完全不一樣了。上面代碼第一次調用bnext方法時,返回x+1的值6;第二次調用next方法,將上一次yield表達式的值設爲12,因此y等於24,返回y / 3的值8;第三次調用next方法,將上一次yield表達式的值設爲13,因此z等於13,這時x等於5y等於24,所以return語句的值等於42

注意,由於next方法的參數表示上一個yield表達式的返回值,所以在第一次使用next方法時,傳遞參數是無效的。V8 引擎直接忽略第一次使用next方法時的參數,只有從第二次使用next方法開始,參數纔是有效的。從語義上講,第一個next方法用來啓動遍歷器對象,所以不用帶有參數。

再看一個通過next方法的參數,向 Generator 函數內部輸入值的例子。

function* dataConsumer() {
  console.log('Started');
  console.log(`1. ${yield}`);
  console.log(`2. ${yield}`);
  return 'result';
}

let genObj = dataConsumer();
genObj.next();
// Started
genObj.next('a')
// 1. a
genObj.next('b')
// 2. b

for…of 循環

for...of循環可以自動遍歷 Generator 函數運行時生成的Iterator對象,且此時不再需要調用next方法。

function* foo() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
  return 6;
}

for (let v of foo()) {
  console.log(v);
}
// 1 2 3 4 5

上面代碼使用for...of循環,依次顯示 5 個yield表達式的值。這裏需要注意,一旦next方法的返回對象的done屬性爲truefor...of循環就會中止,且不包含該返回對象,所以上面代碼的return語句返回的6,不包括在for...of循環之中。

利用for...of循環,可以寫出遍歷任意對象(object)的方法。原生的 JavaScript 對象沒有遍歷接口,無法使用for...of循環,通過 Generator 函數爲它加上這個接口,就可以用了。

function* objectEntries(obj) {
  let propKeys = Reflect.ownKeys(obj);

  for (let propKey of propKeys) {
    yield [propKey, obj[propKey]];
  }
}

let jane = { first: 'Jane', last: 'Doe' };

for (let [key, value] of objectEntries(jane)) {
  console.log(`${key}: ${value}`);
}
// first: Jane
// last: Doe

上面代碼中,對象jane原生不具備 Iterator 接口,無法用for...of遍歷。這時,我們通過 Generator 函數objectEntries爲它加上遍歷器接口,就可以用for...of遍歷了。加上遍歷器接口的另一種寫法是,將 Generator 函數加到對象的Symbol.iterator屬性上面。

function* objectEntries() {
  let propKeys = Object.keys(this);

  for (let propKey of propKeys) {
    yield [propKey, this[propKey]];
  }
}

let jane = { first: 'Jane', last: 'Doe' };

jane[Symbol.iterator] = objectEntries;

for (let [key, value] of jane) {
  console.log(`${key}: ${value}`);
}
// first: Jane
// last: Doe

除了for...of循環以外,擴展運算符(...)、解構賦值和Array.from方法內部調用的,都是遍歷器接口。這意味着,它們都可以將 Generator 函數返回的 Iterator 對象,作爲參數。

function* numbers () {
  yield 1
  yield 2
  return 3
  yield 4
}

// 擴展運算符
[...numbers()] // [1, 2]

// Array.from 方法
Array.from(numbers()) // [1, 2]

// 解構賦值
let [x, y] = numbers();
x // 1
y // 2

// for...of 循環
for (let n of numbers()) {
  console.log(n)
}
// 1
// 2

Generator.prototype.throw()

Generator 函數返回的遍歷器對象,都有一個throw方法,可以在函數體外拋出錯誤,然後在 Generator 函數體內捕獲。

var g = function* () {
  try {
    yield;
  } catch (e) {
    console.log('內部捕獲', e);
  }
};

var i = g();
i.next();

try {
  i.throw('a');
  i.throw('b');
} catch (e) {
  console.log('外部捕獲', e);
}
// 內部捕獲 a
// 外部捕獲 b

上面代碼中,遍歷器對象i連續拋出兩個錯誤。第一個錯誤被 Generator 函數體內的catch語句捕獲。i第二次拋出錯誤,由於 Generator 函數內部的catch語句已經執行過了,不會再捕捉到這個錯誤了,所以這個錯誤就被拋出了 Generator 函數體,被函數體外的catch語句捕獲。

throw方法可以接受一個參數,該參數會被catch語句接收,建議拋出Error對象的實例。

var g = function* () {
  try {
    yield;
  } catch (e) {
    console.log(e);
  }
};

var i = g();
i.next();
i.throw(new Error('出錯了!'));
// Error: 出錯了!(…)

注意,不要混淆遍歷器對象的throw方法和全局的throw命令。上面代碼的錯誤,是用遍歷器對象的throw方法拋出的,而不是用throw命令拋出的。後者只能被函數體外的catch語句捕獲。

var g = function* () {
  while (true) {
    try {
      yield;
    } catch (e) {
      if (e != 'a') throw e;
      console.log('內部捕獲', e);
    }
  }
};

var i = g();
i.next();

try {
  throw new Error('a');
  throw new Error('b');
} catch (e) {
  console.log('外部捕獲', e);
}
// 外部捕獲 [Error: a]

上面代碼之所以只捕獲了a,是因爲函數體外的catch語句塊,捕獲了拋出的a錯誤以後,就不會再繼續try代碼塊裏面剩餘的語句了。

如果 Generator 函數內部沒有部署try...catch代碼塊,那麼throw方法拋出的錯誤,將被外部try...catch代碼塊捕獲。

var g = function* () {
  while (true) {
    yield;
    console.log('內部捕獲', e);
  }
};

var i = g();
i.next();

try {
  i.throw('a');
  i.throw('b');
} catch (e) {
  console.log('外部捕獲', e);
}
// 外部捕獲 a

上面代碼中,Generator 函數g內部沒有部署try...catch代碼塊,所以拋出的錯誤直接被外部catch代碼塊捕獲。

如果 Generator 函數內部和外部,都沒有部署try...catch代碼塊,那麼程序將報錯,直接中斷執行。

var gen = function* gen(){
  yield console.log('hello');
  yield console.log('world');
}

var g = gen();
g.next();
g.throw();
// hello
// Uncaught undefined

上面代碼中,g.throw拋出錯誤以後,沒有任何try...catch代碼塊可以捕獲這個錯誤,導致程序報錯,中斷執行。

throw方法拋出的錯誤要被內部捕獲,前提是必須至少執行過一次next方法。

function* gen() {
  try {
    yield 1;
  } catch (e) {
    console.log('內部捕獲');
  }
}

var g = gen();
g.throw(1);
// Uncaught 1

上面代碼中,g.throw(1)執行時,next方法一次都沒有執行過。這時,拋出的錯誤不會被內部捕獲,而是直接在外部拋出,導致程序出錯。這種行爲其實很好理解,因爲第一次執行next方法,等同於啓動執行 Generator 函數的內部代碼,否則 Generator 函數還沒有開始執行,這時throw方法拋錯只可能拋出在函數外部。

throw方法被捕獲以後,會附帶執行下一條yield表達式。也就是說,會附帶執行一次next方法。

var gen = function* gen(){
  try {
    yield console.log('a');
  } catch (e) {
    // ...
  }
  yield console.log('b');
  yield console.log('c');
}

var g = gen();
g.next() // a
g.throw() // b
g.next() // c

上面代碼中,g.throw方法被捕獲以後,自動執行了一次next方法,所以會打印b。另外,也可以看到,只要 Generator 函數內部部署了try...catch代碼塊,那麼遍歷器的throw方法拋出的錯誤,不影響下一次遍歷。

另外,throw命令與g.throw方法是無關的,兩者互不影響。

var gen = function* gen(){
  yield console.log('hello');
  yield console.log('world');
}

var g = gen();
g.next();

try {
  throw new Error();
} catch (e) {
  g.next();
}
// hello
// world

在這裏插入圖片描述
上面代碼中,throw命令拋出的錯誤不會影響到遍歷器的狀態,所以兩次執行next方法,都進行了正確的操作。

這種函數體內捕獲錯誤的機制,大大方便了對錯誤的處理。多個yield表達式,可以只用一個try...catch代碼塊來捕獲錯誤。如果使用回調函數的寫法,想要捕獲多個錯誤,就不得不爲每個函數內部寫一個錯誤處理語句,現在只在 Generator 函數內部寫一次catch語句就可以了。

Generator 函數體外拋出的錯誤,可以在函數體內捕獲;反過來,Generator 函數體內拋出的錯誤,也可以被函數體外的catch捕獲。

function* foo() {
  var x = yield 3;
  var y = x.toUpperCase();
  yield y;
}

var it = foo();

it.next(); // { value:3, done:false }

try {
  it.next(42);
} catch (err) {
  console.log(err);
}

上面代碼中,第二個next方法向函數體內傳入一個參數 42,數值是沒有toUpperCase方法的,所以會拋出一個 TypeError 錯誤,被函數體外的catch捕獲。

一旦 Generator 執行過程中拋出錯誤,且沒有被內部捕獲,就不會再執行下去了。如果此後還調用next方法,將返回一個value屬性等於undefineddone屬性等於true的對象,即 JavaScript 引擎認爲這個 Generator 已經運行結束了。

function* g() {
  yield 1;
  console.log('throwing an exception');
  throw new Error('generator broke!');
  yield 2;
  yield 3;
}

function log(generator) {
  var v;
  console.log('starting generator');
  try {
    v = generator.next();
    console.log('第一次運行next方法', v);
  } catch (err) {
    console.log('捕捉錯誤', v);
  }
  try {
    v = generator.next();
    console.log('第二次運行next方法', v);
  } catch (err) {
    console.log('捕捉錯誤', v);
  }
  try {
    v = generator.next();
    console.log('第三次運行next方法', v);
  } catch (err) {
    console.log('捕捉錯誤', v);
  }
  console.log('caller done');
}

log(g());
// starting generator
// 第一次運行next方法 { value: 1, done: false }
// throwing an exception
// 捕捉錯誤 { value: 1, done: false }
// 第三次運行next方法 { value: undefined, done: true }
// caller done

上面代碼一共三次運行next方法,第二次運行的時候會拋出錯誤,然後第三次運行的時候,Generator 函數就已經結束了,不再執行下去了。

Generator.prototype.return()

Generator 函數返回的遍歷器對象,還有一個return方法,可以返回給定的值,並且終結遍歷 Generator 函數。

function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

var g = gen();

g.next()        // { value: 1, done: false }
g.return('foo') // { value: "foo", done: true }
g.next()        // { value: undefined, done: true }

上面代碼中,遍歷器對象g調用return方法後,返回值的value屬性就是return方法的參數foo。並且,Generator 函數的遍歷就終止了,返回值的done屬性爲true,以後再調用next方法,done屬性總是返回true

如果return方法調用時,不提供參數,則返回值的value屬性爲undefined

function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

var g = gen();

g.next()        // { value: 1, done: false }
g.return() // { value: undefined, done: true }

如果 Generator 函數內部有try...finally代碼塊,且正在執行try代碼塊,那麼return方法會導致立刻進入finally代碼塊,執行完以後,整個函數纔會結束。

function* numbers () {
  yield 1;
  try {
    yield 2;
    yield 3;
  } finally {
    yield 4;
    yield 5;
  }
  yield 6;
}
var g = numbers();
g.next() // { value: 1, done: false }
g.next() // { value: 2, done: false }
g.return(7) // { value: 4, done: false }
g.next() // { value: 5, done: false }
g.next() // { value: 7, done: true }

上面代碼中,調用return()方法後,就開始執行finally代碼塊,不執行try裏面剩下的代碼了,然後等到finally代碼塊執行完,再返回return()方法指定的返回值。

next()、throw()、return() 的共同點

next()throw()return()這三個方法本質上是同一件事,可以放在一起理解。它們的作用都是讓 Generator 函數恢復執行,並且使用不同的語句替換yield表達式。

next()是將yield表達式替換成一個值。

const g = function* (x, y) {
  let result = yield x + y;
  return result;
};

const gen = g(1, 2);
gen.next(); // Object {value: 3, done: false}

gen.next(1); // Object {value: 1, done: true}
// 相當於將 let result = yield x + y
// 替換成 let result = 1;

上面代碼中,第二個next(1)方法就相當於將yield表達式替換成一個值1。如果next方法沒有參數,就相當於替換成undefined

throw()是將yield表達式替換成一個throw語句。

gen.throw(new Error('出錯了')); // Uncaught Error: 出錯了
// 相當於將 let result = yield x + y
// 替換成 let result = throw(new Error('出錯了'));

return()是將yield表達式替換成一個return語句。

gen.return(2); // Object {value: 2, done: true}
// 相當於將 let result = yield x + y
// 替換成 let result = return 2;

yield* 表達式

如果在 Generator 函數內部,調用另一個 Generator 函數。需要在前者的函數體內部,自己手動完成遍歷。

function* foo() {
  yield 'a';
  yield 'b';
}

function* bar() {
  yield 'x';
  // 手動遍歷 foo()
  for (let i of foo()) {
     yield i;
  }
  yield 'y';
}

for (let v of bar()){
  console.log(v);
}
// x
// a
// b
// y

上面代碼中,foobar都是 Generator 函數,在bar裏面調用foo,就需要手動遍歷foo。如果有多個 Generator 函數嵌套,寫起來就非常麻煩。

ES6 提供了yield*表達式,作爲解決辦法,用來在一個 Generator 函數裏面執行另一個 Generator 函數。

function* bar() {
  yield 'x';
  yield* foo();
  yield 'y';
}

// 等同於
function* bar() {
  yield 'x';
  yield 'a';
  yield 'b';
  yield 'y';
}

// 等同於
function* bar() {
  yield 'x';
  for (let v of foo()) {
    yield v;
  }
  yield 'y';
}

for (let v of bar()){
  console.log(v);
}
// "x"
// "a"
// "b"
// "y"

再來看一個對比的例子。

function* inner() {
  yield 'hello!';
}

function* outer1() {
  yield 'open';
  yield inner();
  yield 'close';
}

var gen = outer1()
gen.next().value // "open"
gen.next().value // 返回一個遍歷器對象
gen.next().value // "close"

function* outer2() {
  yield 'open'
  yield* inner()
  yield 'close'
}

var gen = outer2()
gen.next().value // "open"
gen.next().value // "hello!"
gen.next().value // "close"

在這裏插入圖片描述
上面例子中,outer2使用了yield*outer1沒使用。結果就是,outer1返回一個遍歷器對象,outer2返回該遍歷器對象的內部值。

從語法角度看,如果yield表達式後面跟的是一個遍歷器對象,需要在yield表達式後面加上星號,表明它返回的是一個遍歷器對象。這被稱爲yield*表達式。

let delegatedIterator = (function* () {
  yield 'Hello!';
  yield 'Bye!';
}());

let delegatingIterator = (function* () {
  yield 'Greetings!';
  yield* delegatedIterator;
  yield 'Ok, bye.';
}());

for(let value of delegatingIterator) {
  console.log(value);
}
// "Greetings!
// "Hello!"
// "Bye!"
// "Ok, bye."

上面代碼中,delegatingIterator是代理者,delegatedIterator是被代理者。由於yield* delegatedIterator語句得到的值,是一個遍歷器,所以要用星號表示。運行結果就是使用一個遍歷器,遍歷了多個 Generator 函數,有遞歸的效果。

yield*後面的 Generator 函數(沒有return語句時),等同於在 Generator 函數內部,部署一個for...of循環。

function* concat(iter1, iter2) {
  yield* iter1;
  yield* iter2;
}

// 等同於

function* concat(iter1, iter2) {
  for (var value of iter1) {
    yield value;
  }
  for (var value of iter2) {
    yield value;
  }
}

上面代碼說明,yield*後面的 Generator 函數(沒有return語句時),不過是for...of的一種簡寫形式,完全可以用後者替代前者。反之,在有return語句時,則需要用var value = yield* iterator的形式獲取return語句的值。

如果yield*後面跟着一個數組,由於數組原生支持遍歷器,因此就會遍歷數組成員。

function* gen(){
  yield* ["a", "b", "c"];
}

gen().next() // { value:"a", done:false }

上面代碼中,yield命令後面如果不加星號,返回的是整個數組,加了星號就表示返回的是數組的遍歷器對象。

實際上,任何數據結構只要有 Iterator 接口,就可以被yield*遍歷。

let read = (function* () {
  yield 'hello';
  yield* 'hello';
})();

read.next().value // "hello"
read.next().value // "h"

上面代碼中,yield表達式返回整個字符串,yield*語句返回單個字符。因爲字符串具有 Iterator 接口,所以被yield*遍歷。

如果被代理的 Generator 函數有return語句,那麼就可以向代理它的 Generator 函數返回數據。

function* foo() {
  yield 2;
  yield 3;
  return "foo";
}

function* bar() {
  yield 1;
  var v = yield* foo();
  console.log("v: " + v);
  yield 4;
}

var it = bar();

it.next()
// {value: 1, done: false}
it.next()
// {value: 2, done: false}
it.next()
// {value: 3, done: false}
it.next();
// "v: foo"
// {value: 4, done: false}
it.next()
// {value: undefined, done: true}

上面代碼在第四次調用next方法的時候,屏幕上會有輸出,這是因爲函數fooreturn語句,向函數bar提供了返回值。

再看一個例子。

function* genFuncWithReturn() {
  yield 'a';
  yield 'b';
  return 'The result';
}
function* logReturned(genObj) {
  let result = yield* genObj;
  console.log(result);
}

[...logReturned(genFuncWithReturn())]
// The result
// 值爲 [ 'a', 'b' ]

上面代碼中,存在兩次遍歷。第一次是擴展運算符遍歷函數logReturned返回的遍歷器對象,第二次是yield*語句遍歷函數genFuncWithReturn返回的遍歷器對象。這兩次遍歷的效果是疊加的,最終表現爲擴展運算符遍歷函數genFuncWithReturn返回的遍歷器對象。所以,最後的數據表達式得到的值等於[ 'a', 'b' ]。但是,函數genFuncWithReturnreturn語句的返回值The result,會返回給函數logReturned內部的result變量,因此會有終端輸出。

yield*命令可以很方便地取出嵌套數組的所有成員。

function* iterTree(tree) {
  if (Array.isArray(tree)) {
    for(let i=0; i < tree.length; i++) {
      yield* iterTree(tree[i]);
    }
  } else {
    yield tree;
  }
}

const tree = [ 'a', ['b', 'c'], ['d', 'e'] ];

for(let x of iterTree(tree)) {
  console.log(x);
}
// a
// b
// c
// d
// e

由於擴展運算符...默認調用 Iterator 接口,所以上面這個函數也可以用於嵌套數組的平鋪。

[...iterTree(tree)] // ["a", "b", "c", "d", "e"]

作爲對象屬性的 Generator 函數

如果一個對象的屬性是 Generator 函數,可以簡寫成下面的形式。

let obj = {
  * myGeneratorMethod() {
    ···
  }
};

上面代碼中,myGeneratorMethod屬性前面有一個星號,表示這個屬性是一個 Generator 函數。

它的完整形式如下,與上面的寫法是等價的。

let obj = {
  myGeneratorMethod: function* () {
    // ···
  }
};

Generator 函數的this

Generator 函數總是返回一個遍歷器,ES6 規定這個遍歷器是 Generator 函數的實例,也繼承了 Generator 函數的prototype對象上的方法。

function* g() {}

g.prototype.hello = function () {
  return 'hi!';
};

let obj = g();

obj instanceof g // true
obj.hello() // 'hi!'

上面代碼表明,Generator 函數g返回的遍歷器obj,是g的實例,而且繼承了g.prototype。但是,如果把g當作普通的構造函數,並不會生效,因爲g返回的總是遍歷器對象,而不是this對象。

function* g() {
  this.a = 11;
}

let obj = g();
obj.next();
obj.a // undefined

上面代碼中,Generator 函數gthis對象上面添加了一個屬性a,但是obj對象拿不到這個屬性。

Generator 函數也不能跟new命令一起用,會報錯。

function* F() {
  yield this.x = 2;
  yield this.y = 3;
}

new F()
// TypeError: F is not a constructor

上面代碼中,new命令跟構造函數F一起使用,結果報錯,因爲F不是構造函數。

那麼,有沒有辦法讓 Generator 函數返回一個正常的對象實例,既可以用next方法,又可以獲得正常的this

下面是一個變通方法。首先,生成一個空對象,使用call方法綁定 Generator 函數內部的this。這樣,構造函數調用以後,這個空對象就是 Generator 函數的實例對象了。

function* F() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}
var obj = {};
var f = F.call(obj);

f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}

obj.a // 1
obj.b // 2
obj.c // 3

上面代碼中,首先是F內部的this對象綁定obj對象,然後調用它,返回一個 Iterator 對象。這個對象執行三次next方法(因爲F內部有兩個yield表達式),完成 F 內部所有代碼的運行。這時,所有內部屬性都綁定在obj對象上了,因此obj對象也就成了F的實例。

上面代碼中,執行的是遍歷器對象f,但是生成的對象實例是obj,有沒有辦法將這兩個對象統一呢?

一個辦法就是將obj換成F.prototype

function* F() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}
var f = F.call(F.prototype);

f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}

f.a // 1
f.b // 2
f.c // 3

再將F改成構造函數,就可以對它執行new命令了。

function* gen() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}

function F() {
  return gen.call(gen.prototype);
}

var f = new F();

f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}

f.a // 1
f.b // 2
f.c // 3

Generator 函數的異步應用

傳統方法

ES6 誕生以前,異步編程的方法,大概有下面四種。

  • 回調函數
  • 事件監聽
  • 發佈/訂閱
  • Promise 對象

Generator 函數將 JavaScript 異步編程帶入了一個全新的階段。

基本概念

異步

所謂"異步",簡單說就是一個任務不是連續完成的,可以理解成該任務被人爲分成兩段,先執行第一段,然後轉而執行其他任務,等做好了準備,再回過頭執行第二段。

比如,有一個任務是讀取文件進行處理,任務的第一段是向操作系統發出請求,要求讀取文件。然後,程序執行其他任務,等到操作系統返回文件,再接着執行任務的第二段(處理文件)。這種不連續的執行,就叫做異步。

相應地,連續的執行就叫做同步。由於是連續執行,不能插入其他任務,所以操作系統從硬盤讀取文件的這段時間,程序只能乾等着。

回調函數

JavaScript 語言對異步編程的實現,就是回調函數。所謂回調函數,就是把任務的第二段單獨寫在一個函數裏面,等到重新執行這個任務的時候,就直接調用這個函數。回調函數的英語名字callback,直譯過來就是"重新調用"。

讀取文件進行處理,是這樣寫的。

fs.readFile('/etc/passwd', 'utf-8', function (err, data) {
  if (err) throw err;
  console.log(data);
});

上面代碼中,readFile函數的第三個參數,就是回調函數,也就是任務的第二段。等到操作系統返回了/etc/passwd這個文件以後,回調函數纔會執行。

Promise

回調函數本身並沒有問題,它的問題出現在多個回調函數嵌套。假定讀取A文件之後,再讀取B文件,代碼如下。

fs.readFile(fileA, 'utf-8', function (err, data) {
  fs.readFile(fileB, 'utf-8', function (err, data) {
    // ...
  });
});

不難想象,如果依次讀取兩個以上的文件,就會出現多重嵌套。代碼不是縱向發展,而是橫向發展,很快就會亂成一團,無法管理。因爲多個異步操作形成了強耦合,只要有一個操作需要修改,它的上層回調函數和下層回調函數,可能都要跟着修改。這種情況就稱爲"回調函數地獄"(callback hell)。

Promise 對象就是爲了解決這個問題而提出的。它不是新的語法功能,而是一種新的寫法,允許將回調函數的嵌套,改成鏈式調用。採用 Promise,連續讀取多個文件,寫法如下。

var readFile = require('fs-readfile-promise');

readFile(fileA)
.then(function (data) {
  console.log(data.toString());
})
.then(function () {
  return readFile(fileB);
})
.then(function (data) {
  console.log(data.toString());
})
.catch(function (err) {
  console.log(err);
});

上面代碼中,Promise 提供then方法加載回調函數,catch方法捕捉執行過程中拋出的錯誤。

可以看到,Promise 的寫法只是回調函數的改進,使用then方法以後,異步任務的兩段執行看得更清楚了,除此以外,並無新意。

Promise 的最大問題是代碼冗餘,原來的任務被 Promise 包裝了一下,不管什麼操作,一眼看去都是一堆then,原來的語義變得很不清楚。

那麼,有沒有更好的寫法呢?

Generator 函數

協程

傳統的編程語言,早有異步編程的解決方案(其實是多任務的解決方案)。其中有一種叫做"協程"(coroutine),意思是多個線程互相協作,完成異步任務。

協程有點像函數,又有點像線程。它的運行流程大致如下。

  • 第一步,協程A開始執行。
  • 第二步,協程A執行到一半,進入暫停,執行權轉移到協程B
  • 第三步,(一段時間後)協程B交還執行權。
  • 第四步,協程A恢復執行。

上面流程的協程A,就是異步任務,因爲它分成兩段(或多段)執行。

舉例來說,讀取文件的協程寫法如下。

function* asyncJob() {
  // ...其他代碼
  var f = yield readFile(fileA);
  // ...其他代碼
}

上面代碼的函數asyncJob是一個協程,它的奧妙就在其中的yield命令。它表示執行到此處,執行權將交給其他協程。也就是說,yield命令是異步兩個階段的分界線。

協程遇到yield命令就暫停,等到執行權返回,再從暫停的地方繼續往後執行。它的最大優點,就是代碼的寫法非常像同步操作,如果去除yield命令,簡直一模一樣。

協程的 Generator 函數實現

Generator 函數是協程在 ES6 的實現,最大特點就是可以交出函數的執行權(即暫停執行)。

整個 Generator 函數就是一個封裝的異步任務,或者說是異步任務的容器。異步操作需要暫停的地方,都用yield語句註明。Generator 函數的執行方法如下。

function* gen(x) {
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上面代碼中,調用 Generator 函數,會返回一個內部指針(即遍歷器)g。這是 Generator 函數不同於普通函數的另一個地方,即執行它不會返回結果,返回的是指針對象。調用指針gnext方法,會移動內部指針(即執行異步任務的第一段),指向第一個遇到的yield語句,上例是執行到x + 2爲止。

換言之,next方法的作用是分階段執行Generator函數。每次調用next方法,會返回一個對象,表示當前階段的信息(value屬性和done屬性)。value屬性是yield語句後面表達式的值,表示當前階段的值;done屬性是一個布爾值,表示 Generator 函數是否執行完畢,即是否還有下一個階段。

Generator 函數的數據交換和錯誤處理

Generator 函數可以暫停執行和恢復執行,這是它能封裝異步任務的根本原因。除此之外,它還有兩個特性,使它可以作爲異步編程的完整解決方案:函數體內外的數據交換和錯誤處理機制。

next返回值的 value 屬性,是 Generator 函數向外輸出數據;next方法還可以接受參數,向 Generator 函數體內輸入數據。

function* gen(x){
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next(2) // { value: 2, done: true }

上面代碼中,第一個next方法的value屬性,返回表達式x + 2的值3。第二個next方法帶有參數2,這個參數可以傳入 Generator 函數,作爲上個階段異步任務的返回結果,被函數體內的變量y接收。因此,這一步的value屬性,返回的就是2(變量y的值)。

Generator 函數內部還可以部署錯誤處理代碼,捕獲函數體外拋出的錯誤。

function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw('出錯了');
// 出錯了

上面代碼的最後一行,Generator 函數體外,使用指針對象的throw方法拋出的錯誤,可以被函數體內的try...catch代碼塊捕獲。這意味着,出錯的代碼與處理錯誤的代碼,實現了時間和空間上的分離,這對於異步編程無疑是很重要的。

異步任務的封裝

下面看看如何使用 Generator 函數,執行一個真實的異步任務。

var fetch = require('node-fetch');

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

上面代碼中,Generator 函數封裝了一個異步操作,該操作先讀取一個遠程接口,然後從 JSON 格式的數據解析信息。就像前面說過的,這段代碼非常像同步操作,除了加上了yield命令。

執行這段代碼的方法如下。

var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上面代碼中,首先執行 Generator 函數,獲取遍歷器對象,然後使用next方法(第二行),執行異步任務的第一階段。由於Fetch模塊返回的是一個 Promise 對象,因此要用then方法調用下一個next方法。

可以看到,雖然 Generator 函數將異步操作表示得很簡潔,但是流程管理卻不方便(即何時執行第一階段、何時執行第二階段)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章