Generator 函式的語法

簡介

基本概念

Generator 函式是 ES6 提供的一種非同步程式設計解決方案,語法行為與傳統函式完全不同。本章詳細介紹Generator 函式的語法和 API,它的非同步程式設計應用請看《Generator 函式的非同步應用》一章。

Generator 函式有多種理解角度。從語法上,首先可以把它理解成,Generator 函式是一個狀態機,封裝了多個內部狀態。

執行 Generator 函式會返回一個遍歷器物件,也就是說,Generator 函式除了狀態機,還是一個遍歷器物件生成函式。返回的遍歷器物件,可以依次遍歷 Generator 函式內部的每一個狀態。

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

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

var hw = helloWorldGenerator();

上面程式碼定義了一個Generator函式helloWorldGenerator,它內部有兩個yield語句“hello”和“world”,即該函式有三個狀態: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語句的值hello,done屬性的值false,表示遍歷還沒有結束。

第二次呼叫,Generator函式從上次yield語句停下的地方,一直執行到下一個yield語句。next方法返回的物件的value屬性就是當前yield語句的值world,done屬性的值false,表示遍歷還沒有結束。

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

第四次呼叫,此時Generator函式已經執行完畢,next方法返回物件的value屬性為undefined,done屬性為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語句,結果產生一個句法錯誤。

下面是另外一個例子。

var arr = [1, [[2, 3], 4], [5, 6]];

var flat = function* (a) {
  a.forEach(function (item) {
    if (typeof item !== 'number') {
      yield* flat(item);
    } else {
      yield item;
    }
  }
};

for (var f of flat(arr)){
  console.log(f);
}

上面程式碼也會產生句法錯誤,因為forEach方法的引數是一個普通函式,但是在裡面使用了yield語句(這個函式裡面還使用了yield*語句,詳細介紹見後文)。一種修改方法是改用for迴圈。

var arr = [1, [[2, 3], 4], [5, 6]];

var flat = function* (a) {
  var length = a.length;
  for (var i = 0; i < length; i++) {
    var item = a[i];
    if (typeof item !== 'number') {
      yield* flat(item);
    } else {
      yield item;
    }
  }
};

for (var f of flat(arr)) {
  console.log(f);
}
// 1, 2, 3, 4, 5, 6

另外,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等於5,y等於24,所以return語句的值等於42。

注意,由於next方法的引數表示上一個yield語句的返回值,所以第一次使用next方法時,不能帶有引數。V8引擎直接忽略第一次使用next方法時的引數,只有從第二次使用next方法開始,引數才是有效的。從語義上講,第一個next方法用來啟動遍歷器物件,所以不用帶有引數。

如果想要第一次呼叫next方法時,就能夠輸入值,可以在Generator函式外面再包一層。

function wrapper(generatorFunction) {
  return function (...args) {
    let generatorObject = generatorFunction(...args);
    generatorObject.next();
    return generatorObject;
  };
}

const wrapped = wrapper(function* () {
  console.log(`First input: ${yield}`);
  return 'DONE';
});

wrapped().next('hello!')
// First input: hello!

上面程式碼中,Generator函式如果不用wrapper先包一層,是無法第一次呼叫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

上面程式碼是一個很直觀的例子,每次通過next方法向Generator函式輸入值,然後打印出來。

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迴圈之中。

下面是一個利用Generator函式和for...of迴圈,實現斐波那契數列的例子。

function* fibonacci() {
  let [prev, curr] = [0, 1];
  for (;;) {
    [prev, curr] = [curr, prev + curr];
    yield curr;
  }
}

for (let n of fibonacci()) {
  if (n > 1000) break;
  console.log(n);
}

從上面程式碼可見,使用for...of語句時不需要使用next方法。

利用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方法被捕獲以後,會附帶執行下一條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程式碼塊,那麼return方法會推遲到finally程式碼塊執行完再執行。

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

上面程式碼中,呼叫return方法後,就開始執行finally程式碼塊,然後等到finally程式碼塊執行完,再執行return方法。

yield* 語句

如果在 Generator 函式內部,呼叫另一個 Generator 函式,預設情況下是沒有效果的。

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

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

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

上面程式碼中,foobar都是 Generator 函式,在bar裡面呼叫foo,是不會有效果的。

這個就需要用到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的一種簡寫形式,完全可以用後者替代前者。反之,則需要用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

下面是一個稍微複雜的例子,使用yield*語句遍歷完全二叉樹。

// 下面是二叉樹的建構函式,
// 三個引數分別是左樹、當前節點和右樹
function Tree(left, label, right) {
  this.left = left;
  this.label = label;
  this.right = right;
}

// 下面是中序(inorder)遍歷函式。
// 由於返回的是一個遍歷器,所以要用generator函式。
// 函式體內採用遞迴演算法,所以左樹和右樹要用yield*遍歷
function* inorder(t) {
  if (t) {
    yield* inorder(t.left);
    yield t.label;
    yield* inorder(t.right);
  }
}

// 下面生成二叉樹
function make(array) {
  // 判斷是否為葉節點
  if (array.length == 1) return new Tree(null, array[0], null);
  return new Tree(make(array[0]), array[1], make(array[2]));
}
let tree = make([[['a'], 'b', ['c']], 'd', [['e'], 'f', ['g']]]);

// 遍歷二叉樹
var result = [];
for (let node of inorder(tree)) {
  result.push(node);
}

result
// ['a', 'b', 'c', 'd', 'e', 'f', 'g']

作為物件屬性的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.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與狀態機

Generator是實現狀態機的最佳結構。比如,下面的clock函式就是一個狀態機。

var ticking = true;
var clock = function() {
  if (ticking)
    console.log('Tick!');
  else
    console.log('Tock!');
  ticking = !ticking;
}

上面程式碼的clock函式一共有兩種狀態(Tick和Tock),每執行一次,就改變一次狀態。這個函式如果用Generator實現,就是下面這樣。

var clock = function*() {
  while (true) {
    console.log('Tick!');
    yield;
    console.log('Tock!');
    yield;
  }
};

上面的Generator實現與ES5實現對比,可以看到少了用來儲存狀態的外部變數ticking,這樣就更簡潔,更安全(狀態不會被非法篡改)、更符合函數語言程式設計的思想,在寫法上也更優雅。Generator之所以可以不用外部變數儲存狀態,是因為它本身就包含了一個狀態資訊,即目前是否處於暫停態。

Generator與協程

協程(coroutine)是一種程式執行的方式,可以理解成“協作的執行緒”或“協作的函式”。協程既可以用單執行緒實現,也可以用多執行緒實現。前者是一種特殊的子例程,後者是一種特殊的執行緒。

(1)協程與子例程的差異

傳統的“子例程”(subroutine)採用堆疊式“後進先出”的執行方式,只有當呼叫的子函式完全執行完畢,才會結束執行父函式。協程與其不同,多個執行緒(單執行緒情況下,即多個函式)可以並行執行,但是隻有一個執行緒(或函式)處於正在執行的狀態,其他執行緒(或函式)都處於暫停態(suspended),執行緒(或函式)之間可以交換執行權。也就是說,一個執行緒(或函式)執行到一半,可以暫停執行,將執行權交給另一個執行緒(或函式),等到稍後收回執行權的時候,再恢復執行。這種可以並行執行、交換執行權的執行緒(或函式),就稱為協程。

從實現上看,在記憶體中,子例程只使用一個棧(stack),而協程是同時存在多個棧,但只有一個棧是在執行狀態,也就是說,協程是以多佔用記憶體為代價,實現多工的並行。

(2)協程與普通執行緒的差異

不難看出,協程適合用於多工執行的環境。在這個意義上,它與普通的執行緒很相似,都有自己的執行上下文、可以分享全域性變數。它們的不同之處在於,同一時間可以有多個執行緒處於執行狀態,但是執行的協程只能有一個,其他協程都處於暫停狀態。此外,普通的執行緒是搶先式的,到底哪個執行緒優先得到資源,必須由執行環境決定,但是協程是合作式的,執行權由協程自己分配。

由於ECMAScript是單執行緒語言,只能保持一個呼叫棧。引入協程以後,每個任務可以保持自己的呼叫棧。這樣做的最大好處,就是丟擲錯誤的時候,可以找到原始的呼叫棧。不至於像非同步操作的回呼函式那樣,一旦出錯,原始的呼叫棧早就結束。

Generator函式是ECMAScript 6對協程的實現,但屬於不完全實現。Generator函式被稱為“半協程”(semi-coroutine),意思是隻有Generator函式的呼叫者,才能將程式的執行權還給Generator函式。如果是完全執行的協程,任何函式都可以讓暫停的協程繼續執行。

如果將Generator函式當作協程,完全可以將多個需要互相協作的任務寫成Generator函式,它們之間使用yield語句交換控制權。

應用

Generator可以暫停函式執行,返回任意表達式的值。這種特點使得Generator有多種應用場景。

(1)非同步操作的同步化表達

Generator函式的暫停執行的效果,意味著可以把非同步操作寫在yield語句裡面,等到呼叫next方法時再往後執行。這實際上等同於不需要寫回調函數了,因為非同步操作的後續操作可以放在yield語句下面,反正要等到呼叫next方法時再執行。所以,Generator函式的一個重要實際意義就是用來處理非同步操作,改寫回調函式。

function* loadUI() {
  showLoadingScreen();
  yield loadUIDataAsynchronously();
  hideLoadingScreen();
}
var loader = loadUI();
// 載入UI
loader.next()

// 解除安裝UI
loader.next()

上面程式碼表示,第一次呼叫loadUI函式時,該函式不會執行,僅返回一個遍歷器。下一次對該遍歷器呼叫next方法,則會顯示Loading介面,並且非同步載入資料。等到資料載入完成,再一次使用next方法,則會隱藏Loading介面。可以看到,這種寫法的好處是所有Loading介面的邏輯,都被封裝在一個函式,按部就班非常清晰。

Ajax是典型的非同步操作,通過Generator函式部署Ajax操作,可以用同步的方式表達。

function* main() {
  var result = yield request("http://some.url");
  var resp = JSON.parse(result);
    console.log(resp.value);
}

function request(url) {
  makeAjaxCall(url, function(response){
    it.next(response);
  });
}

var it = main();
it.next();

上面程式碼的main函式,就是通過Ajax操作獲取資料。可以看到,除了多了一個yield,它幾乎與同步操作的寫法完全一樣。注意,makeAjaxCall函式中的next方法,必須加上response引數,因為yield語句構成的表示式,本身是沒有值的,總是等於undefined。

下面是另一個例子,通過Generator函式逐行讀取文字檔案。

function* numbers() {
  let file = new FileReader("numbers.txt");
  try {
    while(!file.eof) {
      yield parseInt(file.readLine(), 10);
    }
  } finally {
    file.close();
  }
}

上面程式碼開啟文字檔案,使用yield語句可以手動逐行讀取檔案。

(2)控制流管理

如果有一個多步操作非常耗時,採用回呼函式,可能會寫成下面這樣。

step1(function (value1) {
  step2(value1, function(value2) {
    step3(value2, function(value3) {
      step4(value3, function(value4) {
        // Do something with value4
      });
    });
  });
});

採用Promise改寫上面的程式碼。

Promise.resolve(step1)
  .then(step2)
  .then(step3)
  .then(step4)
  .then(function (value4) {
    // Do something with value4
  }, function (error) {
    // Handle any error from step1 through step4
  })
  .done();

上面程式碼已經把回呼函式,改成了直線執行的形式,但是加入了大量Promise的語法。Generator函式可以進一步改善程式碼執行流程。

function* longRunningTask(value1) {
  try {
    var value2 = yield step1(value1);
    var value3 = yield step2(value2);
    var value4 = yield step3(value3);
    var value5 = yield step4(value4);
    // Do something with value4
  } catch (e) {
    // Handle any error from step1 through step4
  }
}

然後,使用一個函式,按次序自動執行所有步驟。

scheduler(longRunningTask(initialValue));

function scheduler(task) {
  var taskObj = task.next(task.value);
  // 如果Generator函式未結束,就繼續呼叫
  if (!taskObj.done) {
    task.value = taskObj.value
    scheduler(task);
  }
}

注意,上面這種做法,只適合同步操作,即所有的task都必須是同步的,不能有非同步操作。因為這裡的程式碼一得到返回值,就繼續往下執行,沒有判斷非同步操作何時完成。如果要控制非同步的操作流程,詳見後面的《非同步操作》一章。

下面,利用for...of迴圈會自動依次執行yield命令的特性,提供一種更一般的控制流管理的方法。

let steps = [step1Func, step2Func, step3Func];

function *iterateSteps(steps){
  for (var i=0; i< steps.length; i++){
    var step = steps[i];
    yield step();
  }
}

上面程式碼中,陣列steps封裝了一個任務的多個步驟,Generator函式iterateSteps則是依次為這些步驟加上yield命令。

將任務分解成步驟之後,還可以將專案分解成多個依次執行的任務。

let jobs = [job1, job2, job3];

function *iterateJobs(jobs){
  for (var i=0; i< jobs.length; i++){
    var job = jobs[i];
    yield *iterateSteps(job.steps);
  }
}

上面程式碼中,陣列jobs封裝了一個專案的多個任務,Generator函式iterateJobs則是依次為這些任務加上yield *命令。

最後,就可以用for...of迴圈一次性依次執行所有任務的所有步驟。

for (var step of iterateJobs(jobs)){
  console.log(step.id);
}

再次提醒,上面的做法只能用於所有步驟都是同步操作的情況,不能有非同步操作的步驟。如果想要依次執行非同步的步驟,必須使用後面的《非同步操作》一章介紹的方法。

for...of的本質是一個while迴圈,所以上面的程式碼實質上執行的是下面的邏輯。

var it = iterateJobs(jobs);
var res = it.next();

while (!res.done){
  var result = res.value;
  // ...
  res = it.next();
}

(3)部署Iterator介面

利用Generator函式,可以在任意物件上部署Iterator介面。

function* iterEntries(obj) {
  let keys = Object.keys(obj);
  for (let i=0; i < keys.length; i++) {
    let key = keys[i];
    yield [key, obj[key]];
  }
}

let myObj = { foo: 3, bar: 7 };

for (let [key, value] of iterEntries(myObj)) {
  console.log(key, value);
}

// foo 3
// bar 7

上述程式碼中,myObj是一個普通物件,通過iterEntries函式,就有了Iterator介面。也就是說,可以在任意物件上部署next方法。

下面是一個對陣列部署Iterator介面的例子,儘管陣列原生具有這個介面。

function* makeSimpleGenerator(array){
  var nextIndex = 0;

  while(nextIndex < array.length){
    yield array[nextIndex++];
  }
}

var gen = makeSimpleGenerator(['yo', 'ya']);

gen.next().value // 'yo'
gen.next().value // 'ya'
gen.next().done  // true

(4)作為資料結構

Generator可以看作是資料結構,更確切地說,可以看作是一個數組結構,因為Generator函式可以返回一系列的值,這意味著它可以對任意表達式,提供類似陣列的介面。

function *doStuff() {
  yield fs.readFile.bind(null, 'hello.txt');
  yield fs.readFile.bind(null, 'world.txt');
  yield fs.readFile.bind(null, 'and-such.txt');
}

上面程式碼就是依次返回三個函式,但是由於使用了Generator函式,導致可以像處理陣列那樣,處理這三個返回的函式。

for (task of doStuff()) {
  // task是一個函式,可以像回呼函式那樣使用它
}

實際上,如果用ES5表達,完全可以用陣列模擬Generator的這種用法。

function doStuff() {
  return [
    fs.readFile.bind(null, 'hello.txt'),
    fs.readFile.bind(null, 'world.txt'),
    fs.readFile.bind(null, 'and-such.txt')
  ];
}

上面的函式,可以用一模一樣的for...of迴圈處理!兩相一比較,就不難看出Generator使得資料或者操作,具備了類似陣列的介面。

results matching ""

    No results matching ""