2022 年 8 月 30 日

產生器

一般函式只會回傳一個單一值(或什麼都不回傳)。

產生器可以依需求回傳(「產生」)多個值,一次一個。它們與 可迭代物件 搭配使用效果很好,可以輕鬆建立資料串流。

產生器函式

要建立產生器,我們需要一個特殊的語法結構:function*,稱為「產生器函式」。

它看起來像這樣

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

產生器函式的行為與一般函式不同。當此類函式被呼叫時,它不會執行其程式碼。相反地,它會回傳一個稱為「產生器物件」的特殊物件,用來管理執行。

在此,請看

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

// "generator function" creates "generator object"
let generator = generateSequence();
alert(generator); // [object Generator]

函式程式碼執行尚未開始

產生器的主要方法為 next()。當呼叫時,它會執行程式碼直到最近的 yield <value> 陳述式(value 可以省略,此時為 undefined)。然後函式執行暫停,且產生的 value 會回傳給外部程式碼。

next() 的結果永遠是一個具有兩個屬性的物件

  • value:產生的值。
  • done:如果函式程式碼已結束,則為 true,否則為 false

例如,在此我們建立產生器並取得其第一個產生的值

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

let one = generator.next();

alert(JSON.stringify(one)); // {value: 1, done: false}

到目前為止,我們只取得第一個值,且函式執行在第二行

讓我們再次呼叫 generator.next()。它會繼續執行程式碼並回傳下一個 yield

let two = generator.next();

alert(JSON.stringify(two)); // {value: 2, done: false}

而且,如果我們第三次呼叫它,執行就會到達結束函式的 return 陳述式

let three = generator.next();

alert(JSON.stringify(three)); // {value: 3, done: true}

現在產生器完成了。我們應該從 done:true 看見它,並將 value:3 作為最終結果處理。

generator.next() 的新呼叫不再有意義。如果我們執行它們,它們會傳回相同的物件:{done: true}

function* f(…)function *f(…)

兩種語法都是正確的。

但通常會偏好第一種語法,因為星號 * 表示它是一個產生器函式,它描述的是種類,而不是名稱,所以它應該堅持使用 function 關鍵字。

產生器是可迭代的

正如你可能已經從 next() 方法中猜測的那樣,產生器是 可迭代的

我們可以使用 for..of 迴圈其值

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, then 2
}

看起來比呼叫 .next().value 好多了,對吧?

…但請注意:上面的範例顯示 1,然後是 2,就這樣。它沒有顯示 3

這是因為 for..of 迭代會忽略最後一個 value,當 done: true 時。所以,如果我們希望所有結果都由 for..of 顯示,我們必須使用 yield 傳回它們

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

let generator = generateSequence();

for(let value of generator) {
  alert(value); // 1, then 2, then 3
}

由於產生器是可迭代的,我們可以呼叫所有相關功能,例如展開語法 ...

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

let sequence = [0, ...generateSequence()];

alert(sequence); // 0, 1, 2, 3

在上面的程式碼中,...generateSequence() 將可迭代產生器物件轉換成一個項目陣列(在章節 Rest 參數和展開語法 中進一步了解展開語法)

將產生器用於可迭代

一段時間以前,在章節 可迭代 中,我們建立了一個可迭代的 range 物件,傳回 from..to 的值。

在這裡,讓我們記住程式碼

let range = {
  from: 1,
  to: 5,

  // for..of range calls this method once in the very beginning
  [Symbol.iterator]() {
    // ...it returns the iterator object:
    // onward, for..of works only with that object, asking it for next values
    return {
      current: this.from,
      last: this.to,

      // next() is called on each iteration by the for..of loop
      next() {
        // it should return the value as an object {done:.., value :...}
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

// iteration over range returns numbers from range.from to range.to
alert([...range]); // 1,2,3,4,5

我們可以提供一個產生器函式作為 Symbol.iterator 來進行迭代。

以下是相同的 range,但更為簡潔

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // a shorthand for [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

alert( [...range] ); // 1,2,3,4,5

這樣做有效,因為 range[Symbol.iterator]() 現在傳回一個產生器,而產生器方法正是 for..of 所預期的

  • 它有一個 .next() 方法
  • 它傳回 {value: ..., done: true/false} 形式的值

這當然不是巧合。產生器被新增到 JavaScript 語言中,主要是考慮到迭代器,以便輕鬆地實作它們。

使用產生器的變體比 range 的原始可迭代程式碼簡潔得多,而且保留了相同的功能。

產生器可以永遠產生值

在上面的範例中,我們產生了有限序列,但我們也可以建立一個永遠產生值的產生器。例如,一個無盡的偽亂數序列。

這肯定需要在這樣的產生器上中斷 for..of(或 return)。否則,迴圈會永遠重複並掛起。

產生器組合

產生器組合是產生器的一項特殊功能,允許將產生器透明地「嵌入」在彼此之中。

例如,我們有一個產生數字序列的函數

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

現在我們想要重複使用它來產生一個更複雜的序列

  • 首先,數字 0..9(字元碼 48…57),
  • 接著是大寫字母 A..Z(字元碼 65…90)
  • 接著是小寫字母 a..z(字元碼 97…122)

我們可以使用這個序列,例如透過從中選取字元來建立密碼(也可以加入語法字元),但我們先來產生它。

在一個常規函數中,要結合多個其他函數的結果,我們會呼叫它們,儲存結果,然後在最後串接。

對於產生器,有一個特殊的 yield* 語法,用於將一個產生器「嵌入」(組合)到另一個產生器中。

組合的產生器

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generatePasswordCodes() {

  // 0..9
  yield* generateSequence(48, 57);

  // A..Z
  yield* generateSequence(65, 90);

  // a..z
  yield* generateSequence(97, 122);

}

let str = '';

for(let code of generatePasswordCodes()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z

yield* 指令將執行「委派」給另一個產生器。這個術語表示 yield* gen 會遍歷產生器 gen,並將其產生的值透明地傳遞到外部。就好像這些值是由外部產生器產生的。

結果與我們將巢狀產生器的程式碼內聯時相同

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) yield i;
}

function* generateAlphaNum() {

  // yield* generateSequence(48, 57);
  for (let i = 48; i <= 57; i++) yield i;

  // yield* generateSequence(65, 90);
  for (let i = 65; i <= 90; i++) yield i;

  // yield* generateSequence(97, 122);
  for (let i = 97; i <= 122; i++) yield i;

}

let str = '';

for(let code of generateAlphaNum()) {
  str += String.fromCharCode(code);
}

alert(str); // 0..9A..Za..z

產生器組合是一種將一個產生器的流程插入另一個產生器的自然方式。它不使用額外的記憶體來儲存中間結果。

「yield」是一條雙向道

到目前為止,產生器類似於可迭代物件,具有產生值的特殊語法。但事實上,它們更強大、更靈活。

這是因為 yield 是一條雙向道:它不僅將結果傳回外部,還可以將值傳遞到產生器內部。

為此,我們應該呼叫 generator.next(arg),並帶有一個引數。該引數會成為 yield 的結果。

讓我們看一個範例

function* gen() {
  // Pass a question to the outer code and wait for an answer
  let result = yield "2 + 2 = ?"; // (*)

  alert(result);
}

let generator = gen();

let question = generator.next().value; // <-- yield returns the value

generator.next(4); // --> pass the result into the generator
  1. 第一次呼叫 generator.next() 應該永遠不帶引數(如果傳遞引數,則會忽略)。它會啟動執行,並傳回第一個 yield "2+2=?" 的結果。此時,產生器會暫停執行,同時停留在第 (*) 行。
  2. 然後,如上圖所示,yield 的結果會進入呼叫程式碼中的 question 變數。
  3. generator.next(4) 中,產生器會繼續執行,而 4 會作為結果傳入:let result = 4

請注意,外部程式碼不必立即呼叫 next(4)。這可能會花費一些時間。這沒問題:產生器會等待。

例如

// resume the generator after some time
setTimeout(() => generator.next(4), 1000);

如我們所見,與一般函式不同,產生器和呼叫程式碼可以透過傳遞 next/yield 中的值來交換結果。

為了讓事情更明顯,這裡提供另一個有更多呼叫的範例

function* gen() {
  let ask1 = yield "2 + 2 = ?";

  alert(ask1); // 4

  let ask2 = yield "3 * 3 = ?"

  alert(ask2); // 9
}

let generator = gen();

alert( generator.next().value ); // "2 + 2 = ?"

alert( generator.next(4).value ); // "3 * 3 = ?"

alert( generator.next(9).done ); // true

執行畫面

  1. 第一個 .next() 開始執行…它到達第一個 yield
  2. 結果會傳回給外部程式碼。
  3. 第二個 .next(4)4 傳回給產生器作為第一個 yield 的結果,並繼續執行。
  4. …它到達第二個 yield,而這會成為產生器呼叫的結果。
  5. 第三個 next(9)9 傳入產生器作為第二個 yield 的結果,並繼續執行,而這會到達函式的結尾,因此 done: true

這就像一場「乒乓球」比賽。每個 next(value)(不包括第一個)都會傳遞一個值給產生器,而這會成為目前 yield 的結果,然後再取得下一個 yield 的結果。

generator.throw

正如我們在上述範例中所觀察到的,外部程式碼可能會傳遞一個值給產生器,作為 yield 的結果。

…但它也可以在那裡引發(拋出)錯誤。這很自然,因為錯誤是一種結果。

若要傳遞錯誤給 yield,我們應該呼叫 generator.throw(err)。在這種情況下,err 會在有該 yield 的行中拋出。

例如,這裡 "2 + 2 = ?" 的 yield 會導致錯誤

function* gen() {
  try {
    let result = yield "2 + 2 = ?"; // (1)

    alert("The execution does not reach here, because the exception is thrown above");
  } catch(e) {
    alert(e); // shows the error
  }
}

let generator = gen();

let question = generator.next().value;

generator.throw(new Error("The answer is not found in my database")); // (2)

在第 (2) 行拋入產生器的錯誤會導致第 (1) 行有 yield 的例外狀況。在上述範例中,try..catch 會捕捉並顯示錯誤。

如果我們沒有捕捉它,那麼就像任何例外狀況一樣,它會從產生器「掉出」到呼叫程式碼。

呼叫程式碼的目前行是標記為 (2) 的有 generator.throw 的行。因此,我們可以在這裡捕捉它,如下所示

function* generate() {
  let result = yield "2 + 2 = ?"; // Error in this line
}

let generator = generate();

let question = generator.next().value;

try {
  generator.throw(new Error("The answer is not found in my database"));
} catch(e) {
  alert(e); // shows the error
}

如果我們沒有在那裡捕捉錯誤,那麼它會像往常一樣,傳遞到外部呼叫程式碼(如果有),而且如果未捕捉,就會終止指令碼。

generator.return

generator.return(value) 會完成產生器執行並傳回指定的 value

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

const g = gen();

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

如果我們在已完成的產生器中再次使用 generator.return(),它將再次傳回該值 (MDN)。

我們通常不會使用它,因為大多數時候我們想要取得所有回傳值,但當我們想要在特定條件下停止產生器時,它會很有用。

摘要

  • 產生器是由產生器函式 function* f(…) {…} 建立的。
  • 只有在產生器內部存在 yield 算子。
  • 外部程式碼和產生器可以透過 next/yield 呼叫交換結果。

在現代 JavaScript 中,產生器很少使用。但有時它們會派上用場,因為函式在執行期間與呼叫程式碼交換資料的能力是相當獨特的。而且,它們當然很適合製作可迭代物件。

此外,在下一章中,我們將學習非同步產生器,它用於在 for await ... of 迴圈中讀取非同步產生資料串流(例如透過網路進行分頁擷取)。

在網頁程式設計中,我們經常使用串流資料,所以這是另一個非常重要的使用案例。

任務

有許多領域需要隨機資料。

其中之一是測試。我們可能需要隨機資料:文字、數字等,才能徹底測試事物。

在 JavaScript 中,我們可以使用 Math.random()。但如果出錯,我們希望能夠使用完全相同的資料重複測試。

為此,使用了所謂的「種子偽亂數產生器」。它們會取得「種子」,也就是第一個值,然後使用公式產生後續值,以便相同的種子產生相同的序列,因此整個流程很容易重製。我們只需要記住種子即可重複它。

此類公式的一個範例,會產生分布相當均勻的值

next = previous * 16807 % 2147483647

如果我們使用 1 作為種子,值將會是

  1. 16807
  2. 282475249
  3. 1622650073
  4. …以此類推…

任務是建立一個產生器函式 pseudoRandom(seed),它會取得 seed 並使用此公式建立產生器。

使用範例

let generator = pseudoRandom(1);

alert(generator.next().value); // 16807
alert(generator.next().value); // 282475249
alert(generator.next().value); // 1622650073

開啟一個包含測試的沙盒。

function* pseudoRandom(seed) {
  let value = seed;

  while(true) {
    value = value * 16807 % 2147483647;
    yield value;
  }

};

let generator = pseudoRandom(1);

alert(generator.next().value); // 16807
alert(generator.next().value); // 282475249
alert(generator.next().value); // 1622650073

請注意,可以使用一般函式執行相同的工作,如下所示

function pseudoRandom(seed) {
  let value = seed;

  return function() {
    value = value * 16807 % 2147483647;
    return value;
  }
}

let generator = pseudoRandom(1);

alert(generator()); // 16807
alert(generator()); // 282475249
alert(generator()); // 1622650073

這也行得通。但這樣我們就無法使用 for..of 進行迭代,也無法使用產生器組合,這在其他地方可能會很有用。

在沙盒中開啟包含測試的解答。

教學課程地圖

留言

在評論前閱讀此內容…
  • 如果您有改善建議,請 提交 GitHub 問題 或發起拉取請求,而不是評論。
  • 如果您無法理解文章中的某些內容,請詳細說明。
  • 若要插入少數幾個字的程式碼,請使用 <code> 標籤,若要插入多行,請將它們包覆在 <pre> 標籤中,若要插入 10 行以上,請使用沙盒 (plnkrjsbincodepen…)