22. 產生器
目錄
請支持這本書:購買它(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

22. 產生器



22.1 概觀

22.1.1 什麼是產生器?

您可以將產生器視為可以暫停和繼續的程序(程式碼片段)

function* genFunc() {
    // (A)
    console.log('First');
    yield;
    console.log('Second');
}

請注意新的語法:function*產生器函式 的新「關鍵字」(也有 產生器方法)。yield 是產生器可以用來暫停自己的運算子。此外,產生器也可以透過 yield 接收輸入和傳送輸出。

當您呼叫產生器函式 genFunc() 時,您會取得一個 產生器物件 genObj,您可以使用它來控制程序

const genObj = genFunc();

程序最初在 A 行暫停。genObj.next() 繼續執行,genFunc() 內部的 yield 會暫停執行

genObj.next();
// Output: First
genObj.next();
// output: Second

22.1.2 產生器的種類

產生器有四種

  1. 產生器函式宣告
     function* genFunc() { ··· }
     const genObj = genFunc();
    
  2. 產生器函式運算式
     const genFunc = function* () { ··· };
     const genObj = genFunc();
    
  3. 物件文字中的產生器方法定義
     const obj = {
         * generatorMethod() {
             ···
         }
     };
     const genObj = obj.generatorMethod();
    
  4. 類別定義(類別宣告或類別運算式)中的產生器方法定義
     class MyClass {
         * generatorMethod() {
             ···
         }
     }
     const myInst = new MyClass();
     const genObj = myInst.generatorMethod();
    

22.1.3 使用案例:實作可迭代物件

產生器傳回的物件是可迭代的;每個 yield 都會貢獻到迭代值的序列。因此,您可以使用產生器來實作可迭代物件,而這些物件可以使用各種 ES6 語言機制來使用:for-of 迴圈、展開運算子 (...) 等。

以下函式傳回物件屬性的可迭代物件,每個屬性一組 [key, value]

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

    for (const propKey of propKeys) {
        // `yield` returns a value and then pauses
        // the generator. Later, execution continues
        // where it was previously paused.
        yield [propKey, obj[propKey]];
    }
}

objectEntries() 的使用方式如下

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

objectEntries() 的運作方式詳細說明於 專門的章節 中。在沒有產生器的情況下實作相同的功能需要更多工作。

22.1.4 使用案例:更簡單的非同步程式碼

你可以使用產生器大幅簡化使用 Promise 的工作。讓我們看看一個基於 Promise 的函數 fetchJson(),以及如何透過產生器來改善它。

function fetchJson(url) {
    return fetch(url)
    .then(request => request.text())
    .then(text => {
        return JSON.parse(text);
    })
    .catch(error => {
        console.log(`ERROR: ${error.stack}`);
    });
}

有了 函式庫 co 和一個產生器,這個非同步程式碼看起來像同步程式碼

const fetchJson = co.wrap(function* (url) {
    try {
        let request = yield fetch(url);
        let text = yield request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
});

ECMAScript 2017 將會有非同步函數,它們在內部是基於產生器的。有了它們,程式碼看起來像這樣

async function fetchJson(url) {
    try {
        let request = await fetch(url);
        let text = await request.text();
        return JSON.parse(text);
    }
    catch (error) {
        console.log(`ERROR: ${error.stack}`);
    }
}

所有版本都可以像這樣呼叫

fetchJson('http://example.com/some_file.json')
.then(obj => console.log(obj));

22.1.5 使用案例:接收非同步資料

產生器可以透過 yieldnext() 接收輸入。這表示你可以隨時喚醒一個產生器,只要有新的資料非同步地到達,而對產生器而言,它感覺就像同步接收資料一樣。

22.2 什麼是產生器?

產生器 是可以暫停和恢復的函數(想想協作式多工處理或協程),這使得各種應用程式成為可能。

作為第一個範例,考慮以下名稱為 genFunc 的產生器函數

function* genFunc() {
    // (A)
    console.log('First');
    yield; // (B)
    console.log('Second'); // (C)
}

有兩件事讓 genFunc 與一般的函數宣告不同

呼叫 genFunc 不會執行它的主體。相反地,你會得到一個所謂的產生器物件,你可以用它來控制主體的執行

> const genObj = genFunc();

genFunc() 在主體之前(A 行)最初會暫停。方法呼叫 genObj.next() 會繼續執行,直到下一個 yield

> genObj.next()
First
{ value: undefined, done: false }

正如你在最後一行看到的,genObj.next() 也會傳回一個物件。我們現在先忽略它。它在稍後會很重要。

genFunc 現在在 B 行暫停。如果我們再次呼叫 next(),執行會恢復,並且會執行 C 行

> genObj.next()
Second
{ value: undefined, done: true }

之後,函數結束,執行已離開主體,而進一步呼叫 genObj.next() 沒有任何作用。

22.2.1 產生器扮演的角色

產生器可以扮演三個角色

  1. 迭代器(資料產生器):每個 yield 都能透過 next() 傳回一個值,這表示產生器能透過迴圈和遞迴產生一系列的值。由於產生器物件實作了介面 Iterable(在 迭代章節 中有說明),這些序列可以由任何支援可迭代項目的 ECMAScript 6 結構處理。兩個範例是:for-of 迴圈和展開運算子 (...)。
  2. 觀察者(資料消費者):yield 也能從 next()(透過參數)接收一個值。這表示產生器會變成資料消費者,在透過 next() 將新值推入之前會暫停。
  3. 協程(資料產生器和消費者):由於產生器可以暫停,而且可以同時是資料產生器和資料消費者,因此將它們轉換成協程(合作式多工任務)不需要太多工作。

下一個章節會更深入地說明這些角色。

22.3 產生器作為迭代器(資料產生)

如前所述,產生器物件可以是資料產生器、資料消費者或兩者兼具。此章節將它們視為資料產生器,它們同時實作介面 IterableIterator(如下所示)。這表示產生器函式的結果既是可迭代項目,也是迭代器。產生器物件的完整介面將在稍後顯示。

interface Iterable {
    [Symbol.iterator]() : Iterator;
}
interface Iterator {
    next() : IteratorResult;
}
interface IteratorResult {
    value : any;
    done : boolean;
}

我省略了介面 Iterable 的方法 return(),因為它與此章節無關。

產生器函式透過 yield 產生一系列的值,資料消費者透過迭代器方法 next() 消耗這些值。例如,以下產生器函式產生值 'a''b'

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

此互動顯示如何透過產生器物件 genObj 擷取已產生的值

> const genObj = genFunc();
> genObj.next()
{ value: 'a', done: false }
> genObj.next()
{ value: 'b', done: false }
> genObj.next() // done: true => end of sequence
{ value: undefined, done: true }

22.3.1 迭代產生器的方法

由於產生器物件是可迭代的,因此支援可迭代項目的 ES6 語言結構可以套用在它們身上。以下三個特別重要。

首先,for-of 迴圈

for (const x of genFunc()) {
    console.log(x);
}
// Output:
// a
// b

其次,展開運算子 (...),它會將迭代的序列轉換成陣列的元素(請參閱 參數處理章節 以取得關於此運算子的更多資訊)

const arr = [...genFunc()]; // ['a', 'b']

第三,解構

> const [x, y] = genFunc();
> x
'a'
> y
'b'

22.3.2 從產生器傳回

前一個產生器函數不包含明確的 return。明確的 return 等同於傳回 undefined。我們來檢視一個有明確 return 的產生器

function* genFuncWithReturn() {
    yield 'a';
    yield 'b';
    return 'result';
}

傳回的值顯示在 next() 傳回的最後一個物件中,其屬性 donetrue

> const genObjWithReturn = genFuncWithReturn();
> genObjWithReturn.next()
{ value: 'a', done: false }
> genObjWithReturn.next()
{ value: 'b', done: false }
> genObjWithReturn.next()
{ value: 'result', done: true }

然而,大多數與可迭代物件一起運作的建構忽略 done 物件中的值

for (const x of genFuncWithReturn()) {
    console.log(x);
}
// Output:
// a
// b

const arr = [...genFuncWithReturn()]; // ['a', 'b']

yield*,一個用於進行遞迴產生器呼叫的運算子,會考量 done 物件中的值。稍後會說明。

22.3.3 從產生器擲回例外

如果例外離開產生器的本體,則 next() 會擲回它

function* genFunc() {
    throw new Error('Problem!');
}
const genObj = genFunc();
genObj.next(); // Error: Problem!

這表示 next() 可以產生三個不同的「結果」

22.3.4 範例:迭代屬性

我們來看一個範例,說明產生器在實作可迭代物件方面有多麼方便。下列函數 objectEntries() 傳回一個物件屬性的可迭代物件

function* objectEntries(obj) {
    // In ES6, you can use strings or symbols as property keys,
    // Reflect.ownKeys() retrieves both
    const propKeys = Reflect.ownKeys(obj);

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

這個函數讓你可以透過 for-of 迴圈來迭代物件 jane 的屬性

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

做個比較 – 不使用產生器的 objectEntries() 實作複雜多了

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

    return {
        [Symbol.iterator]() {
            return this;
        },
        next() {
            if (index < propKeys.length) {
                let key = propKeys[index];
                index++;
                return { value: [key, obj[key]] };
            } else {
                return { done: true };
            }
        }
    };
}

22.3.5 你只能在產生器中 yield

產生器的重大限制是,你只能在(靜態地)位於產生器函數內時 yield。也就是說,在回呼函式中 yield 是不行的

function* genFunc() {
    ['a', 'b'].forEach(x => yield x); // SyntaxError
}

在非產生器函數中不允許 yield,這就是為什麼前一個程式碼會造成語法錯誤。在這種情況下,很容易改寫程式碼,使其不使用回呼函式(如下所示)。但很不幸地,這並不總是可行的。

function* genFunc() {
    for (const x of ['a', 'b']) {
        yield x; // OK
    }
}

這個限制的好處是 稍後說明:它使產生器更容易實作,並與事件迴圈相容。

22.3.6 透過 yield* 遞迴

你只能在產生器函式中使用 yield。因此,如果你想用產生器實作遞迴演算法,你需要一種從一個產生器呼叫另一個產生器的方法。本節將說明這比聽起來的更複雜,這就是為什麼 ES6 有個特殊運算子 yield* 來處理這件事。現在,我只說明如果兩個產生器都產生輸出,yield* 如何運作,我稍後會說明如果涉及輸入時,事情如何運作。

一個產生器如何遞迴呼叫另一個產生器?假設你寫了一個產生器函式 foo

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

你會如何從另一個產生器函式 bar 呼叫 foo?以下方法無法運作!

function* bar() {
    yield 'x';
    foo(); // does nothing!
    yield 'y';
}

呼叫 foo() 會傳回一個物件,但並未實際執行 foo()。這就是為什麼 ECMAScript 6 有運算子 yield* 來進行遞迴產生器呼叫

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

// Collect all values yielded by bar() in an array
const arr = [...bar()];
    // ['x', 'a', 'b', 'y']

在內部,yield* 大致上如下運作

function* bar() {
    yield 'x';
    for (const value of foo()) {
        yield value;
    }
    yield 'y';
}

yield* 的運算元不一定要是產生器物件,它可以是任何可迭代的

function* bla() {
    yield 'sequence';
    yield* ['of', 'yielded'];
    yield 'values';
}

const arr = [...bla()];
    // ['sequence', 'of', 'yielded', 'values']
22.3.6.1 yield* 考慮迭代結束值

支援可迭代的大部分建構都忽略迭代結束物件中包含的值(其屬性 donetrue)。產生器透過 return 提供該值。yield* 的結果是迭代結束值

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

如果我們想要到 A 行,我們必須先迭代 logReturned() 產生的所有值

> [...logReturned(genFuncWithReturn())]
The result
[ 'a', 'b' ]
22.3.6.2 迭代樹狀結構

使用遞迴迭代樹狀結構很簡單,用傳統方式撰寫樹狀結構的迭代器很複雜。這就是為什麼產生器在此發揮作用:它們讓你透過遞迴實作迭代器。舉例來說,考慮以下二元樹的資料結構。它是可迭代的,因為它有一個金鑰為 Symbol.iterator 的方法。該方法是一個產生器方法,在呼叫時會傳回一個迭代器。

class BinaryTree {
    constructor(value, left=null, right=null) {
        this.value = value;
        this.left = left;
        this.right = right;
    }

    /** Prefix iteration */
    * [Symbol.iterator]() {
        yield this.value;
        if (this.left) {
            yield* this.left;
            // Short for: yield* this.left[Symbol.iterator]()
        }
        if (this.right) {
            yield* this.right;
        }
    }
}

以下程式碼建立一個二元樹並透過 for-of 迭代它

const tree = new BinaryTree('a',
    new BinaryTree('b',
        new BinaryTree('c'),
        new BinaryTree('d')),
    new BinaryTree('e'));

for (const x of tree) {
    console.log(x);
}
// Output:
// a
// b
// c
// d
// e

22.4 產生器作為觀察者(資料消耗)

作為資料的消費者,產生器物件符合產生器介面的後半部分,Observer

interface Observer {
    next(value? : any) : void;
    return(value? : any) : void;
    throw(error) : void;
}

作為觀察者,產生器會暫停,直到它收到輸入。有透過介面指定的輸入方法傳輸的三種輸入

22.4.1 透過 next() 傳送值

如果你使用產生器作為觀察者,你會透過 next() 傳送值給它,它會透過 yield 接收那些值

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

讓我們互動式地使用這個產生器。首先,我們建立一個產生器物件

> const genObj = dataConsumer();

現在我們呼叫 genObj.next(),這會啟動產生器。執行會持續到第一個 yield,這是產生器暫停的地方。next() 的結果是 A 行中產生的值 (undefined,因為 yield 沒有運算元)。在此區段中,我們對 next() 回傳的內容不感興趣,因為我們只用它來傳送值,而不是擷取值。

> genObj.next()
Started
{ value: undefined, done: false }

我們再呼叫 next() 兩次,目的是傳送值 'a' 給第一個 yield,以及值 'b' 給第二個 yield

> genObj.next('a')
1. a
{ value: undefined, done: false }

> genObj.next('b')
2. b
{ value: 'result', done: true }

最後一次 next() 的結果是從 dataConsumer() 回傳的值。donetrue 表示產生器已完成。

很不幸地,next() 是不對稱的,但這無可避免:它總是將值傳送給目前暫停的 yield,但會回傳下一個 yield 的運算元。

22.4.1.1 第一個 next()

當將產生器用作觀察者時,請務必注意,第一次呼叫 next() 的唯一目的是啟動觀察者。它之後才會準備好輸入,因為第一次呼叫會將執行推進到第一個 yield。因此,您透過第一次 next() 傳送的任何輸入都會被忽略

function* gen() {
    // (A)
    while (true) {
        const input = yield; // (B)
        console.log(input);
    }
}
const obj = gen();
obj.next('a');
obj.next('b');

// Output:
// b

最初,執行會在 A 行暫停。第一次呼叫 next()

第二次呼叫 next()

下列的工具函式修復了這個問題

/**
 * Returns a function that, when called,
 * returns a generator object that is immediately
 * ready for input via `next()`
 */
function coroutine(generatorFunction) {
    return function (...args) {
        const generatorObject = generatorFunction(...args);
        generatorObject.next();
        return generatorObject;
    };
}

為了了解 coroutine() 的運作方式,讓我們比較一個包裝過的產生器和一個正常的產生器

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

包裝過的產生器會立即準備好輸入

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

正常的產生器需要額外的 next() 才會準備好輸入

> const genObj = normal();
> genObj.next()
{ value: undefined, done: false }
> genObj.next('hello!')
First input: hello!
{ value: 'DONE', done: true }

22.4.2 yield 會鬆散繫結

yield 繫結非常鬆散,因此我們不必將其運算元放在括號中

yield a + b + c;

這被視為

yield (a + b + c);

而不是

(yield a) + b + c;

因此,許多運算元繫結比 yield 更緊密,如果你想將其用作運算元,則必須將 yield 放在括號中。例如,如果你將未加括號的 yield 作為加號的運算元,則會得到一個 SyntaxError

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

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

如果 yield 是函式或方法呼叫中的直接引數,則不需要括號

foo(yield 'a', yield 'b');

如果你在指定項目的右側使用 yield,則也不需要括號

const input = yield;
22.4.2.1 ES6 語法中的 yield

ECMAScript 6 規格 中的下列語法規則中,可以看到 yield 周圍需要括號。這些規則描述如何分析表達式。我將它們從一般(鬆散繫結,較低優先順序)列到具體(緊密繫結,較高優先順序)。無論何時需要某種類型的表達式,你也可以使用更具體的表達式。相反的說法不成立。層級以 ParenthesizedExpression 結束,這表示如果你將任何表達式放在括號中,你可以在任何地方提到它。

Expression :
    AssignmentExpression
    Expression , AssignmentExpression
AssignmentExpression :
    ConditionalExpression
    YieldExpression
    ArrowFunction
    LeftHandSideExpression = AssignmentExpression
    LeftHandSideExpression AssignmentOperator AssignmentExpression

···

AdditiveExpression :
    MultiplicativeExpression
    AdditiveExpression + MultiplicativeExpression
    AdditiveExpression - MultiplicativeExpression
MultiplicativeExpression :
    UnaryExpression
    MultiplicativeExpression MultiplicativeOperator UnaryExpression

···

PrimaryExpression :
    this
    IdentifierReference
    Literal
    ArrayLiteral
    ObjectLiteral
    FunctionExpression
    ClassExpression
    GeneratorExpression
    RegularExpressionLiteral
    TemplateLiteral
    ParenthesizedExpression
ParenthesizedExpression :
    ( Expression )

AdditiveExpression 的運算元是 AdditiveExpressionMultiplicativeExpression。因此,使用(更具體的)ParenthesizedExpression 作為運算元是可以的,但使用(更一般的)YieldExpression 則不行。

22.4.3 return()throw()

產生器物件有兩個額外的函式,return()throw(),類似於 next()

讓我們回顧一下 next(x) 的運作方式(在第一次呼叫之後)

  1. 產生器目前暫停在 yield 運算元。
  2. 將值 x 傳送到該 yield,這表示它等於 x
  3. 繼續下一個 yieldreturnthrow
    • yield x 導致 next() 返回 { value: x, done: false }
    • return x 導致 next() 返回 { value: x, done: true }
    • throw err(未在產生器內捕獲)導致 next() 擲出 err

return()throw() 的運作方式類似於 next(),但它們在步驟 2 中執行不同的操作

22.4.4 return() 終止產生器

return() 在導致產生器最後一次暫停的 yield 的位置執行 return。讓我們使用以下產生器函數來看看它是如何運作的。

function* genFunc1() {
    try {
        console.log('Started');
        yield; // (A)
    } finally {
        console.log('Exiting');
    }
}

在以下互動中,我們首先使用 next() 來啟動產生器並進行處理,直到 A 行的 yield。然後我們透過 return() 從該位置返回。

> const genObj1 = genFunc1();
> genObj1.next()
Started
{ value: undefined, done: false }
> genObj1.return('Result')
Exiting
{ value: 'Result', done: true }
22.4.4.1 防止終止

如果您在 finally 子句中產生 (在該子句中使用 return 陳述式也是可行的),您可以防止 return() 終止產生器。

function* genFunc2() {
    try {
        console.log('Started');
        yield;
    } finally {
        yield 'Not done, yet!';
    }
}

這次,return() 沒有退出產生器函數。因此,它所傳回物件的 done 屬性為 false

> const genObj2 = genFunc2();

> genObj2.next()
Started
{ value: undefined, done: false }

> genObj2.return('Result')
{ value: 'Not done, yet!', done: false }

您可以再次呼叫 next()。與非產生器函數類似,產生器函數的傳回值是在進入 finally 子句之前排隊的值。

> genObj2.next()
{ value: 'Result', done: true }
22.4.4.2 從新生產生器傳回

允許從新生產生器 (尚未啟動) 傳回值

> function* genFunc() {}
> genFunc().return('yes')
{ value: 'yes', done: true }

22.4.5 throw() 傳送錯誤訊號

throw() 在導致產生器最後一次暫停的 yield 的位置擲回例外。讓我們透過以下產生器函數來檢視它是如何運作的。

function* genFunc1() {
    try {
        console.log('Started');
        yield; // (A)
    } catch (error) {
        console.log('Caught: ' + error);
    }
}

在以下互動中,我們首先使用 next() 來啟動產生器並進行處理,直到 A 行的 yield。然後我們從該位置擲回例外。

> const genObj1 = genFunc1();

> genObj1.next()
Started
{ value: undefined, done: false }

> genObj1.throw(new Error('Problem!'))
Caught: Error: Problem!
{ value: undefined, done: true }

throw() 的結果 (顯示在最後一行) 來自我們使用隱含 return 離開函數。

22.4.5.1 從新生產生器擲回

允許在新生產生器 (尚未啟動) 中擲回例外

> function* genFunc() {}
> genFunc().throw(new Error('Problem!'))
Error: Problem!

22.4.6 範例:處理非同步推播資料

由於產生器作為觀察者在等待輸入時會暫停,因此它們非常適合依需求處理非同步接收的資料。設定用於處理的產生器鏈的模式如下

整個鏈由一個非產生器函數為前綴,該函數發出非同步請求,並透過 next() 將結果推送到產生器鏈中。

舉例來說,讓我們鏈結產生器來處理非同步讀取的檔案。

下列程式碼設定串連:它包含產生器 splitLinesnumberLinesprintLines。資料透過非產生器函式 readFile 推入串連中。

readFile(fileName, splitLines(numberLines(printLines())));

我會在展示這些函式的程式碼時說明它們的功能。

如前所述,如果產生器透過 yield 接收輸入,則產生器物件上的第一個 next() 呼叫不會執行任何動作。這就是我使用 先前展示的輔助函式 coroutine() 在此建立非同步函式的緣故。它會為我們執行第一個 next()

readFile() 是啟動所有動作的非產生器函式

import {createReadStream} from 'fs';

/**
 * Creates an asynchronous ReadStream for the file whose name
 * is `fileName` and feeds it to the generator object `target`.
 *
 * @see ReadStream https://node.dev.org.tw/api/fs.html#fs_class_fs_readstream
 */
function readFile(fileName, target) {
    const readStream = createReadStream(fileName,
        { encoding: 'utf8', bufferSize: 1024 });
    readStream.on('data', buffer => {
        const str = buffer.toString('utf8');
        target.next(str);
    });
    readStream.on('end', () => {
        // Signal end of output sequence
        target.return();
    });
}

產生器串連從 splitLines 開始

/**
 * Turns a sequence of text chunks into a sequence of lines
 * (where lines are separated by newlines)
 */
const splitLines = coroutine(function* (target) {
    let previous = '';
    try {
        while (true) {
            previous += yield;
            let eolIndex;
            while ((eolIndex = previous.indexOf('\n')) >= 0) {
                const line = previous.slice(0, eolIndex);
                target.next(line);
                previous = previous.slice(eolIndex+1);
            }
        }
    } finally {
        // Handle the end of the input sequence
        // (signaled via `return()`)
        if (previous.length > 0) {
            target.next(previous);
        }
        // Signal end of output sequence
        target.return();
    }
});

請注意一個重要的模式

下一個產生器是 numberLines

//**
 * Prefixes numbers to a sequence of lines
 */
const numberLines = coroutine(function* (target) {
    try {
        for (const lineNo = 0; ; lineNo++) {
            const line = yield;
            target.next(`${lineNo}: ${line}`);
        }
    } finally {
        // Signal end of output sequence
        target.return();
    }
});

最後一個產生器是 printLines

/**
 * Receives a sequence of lines (without newlines)
 * and logs them (adding newlines).
 */
const printLines = coroutine(function* () {
    while (true) {
        const line = yield;
        console.log(line);
    }
});

這段程式碼的優點是所有動作都是延遲執行的(依需求):當行到來時,會將其分割、編號並列印出來;我們不必等到所有文字都到齊才能開始列印。

22.4.7 yield*:完整說明

根據經驗法則,yield* 會執行(等同於)從一個產生器(呼叫者)到另一個產生器(被呼叫者)的函式呼叫。

到目前為止,我們只看過 yield 的一個面向:它會將被呼叫者產生的值傳播到呼叫者。現在我們有興趣讓產生器接收輸入,另一個面向就變得相關:yield* 也會將呼叫者接收到的輸入轉發給被呼叫者。在某種程度上,被呼叫者會變成主動產生器,而且可以透過呼叫者的產生器物件來控制。

22.4.7.1 範例:yield* 轉發 next()

下列產生器函式 caller() 透過 yield* 呼叫產生器函式 callee()

function* callee() {
    console.log('callee: ' + (yield));
}
function* caller() {
    while (true) {
        yield* callee();
    }
}

callee 會記錄透過 next() 接收到的值,這讓我們可以檢查它是否接收我們傳送給 caller 的值 'a''b'

> const callerObj = caller();

> callerObj.next() // start
{ value: undefined, done: false }

> callerObj.next('a')
callee: a
{ value: undefined, done: false }

> callerObj.next('b')
callee: b
{ value: undefined, done: false }

throw()return() 以類似方式轉送。

22.4.7.2 JavaScript 中表達的 yield* 語意

我將透過說明在 JavaScript 中實作 yield* 的方式,來解釋其完整的語意。

以下陳述

let yieldStarResult = yield* calleeFunc();

大致等於

let yieldStarResult;

const calleeObj = calleeFunc();
let prevReceived = undefined;
while (true) {
    try {
        // Forward input previously received
        const {value,done} = calleeObj.next(prevReceived);
        if (done) {
            yieldStarResult = value;
            break;
        }
        prevReceived = yield value;
    } catch (e) {
        // Pretend `return` can be caught like an exception
        if (e instanceof Return) {
            // Forward input received via return()
            calleeObj.return(e.returnedValue);
            return e.returnedValue; // “re-throw”
        } else {
            // Forward input received via throw()
            calleeObj.throw(e); // may throw
        }
    }
}

為了簡化起見,此程式碼中缺少幾項內容

22.5 作為協同常式的產生器(協同多工處理)

我們已看到產生器用作資料來源或接收器。對於許多應用程式而言,嚴格區分這兩個角色是很好的做法,因為這可以讓事情變得更簡單。本節說明完整的產生器介面(結合這兩個角色),以及需要這兩個角色的一個使用案例:協同多工處理,其中任務必須能夠同時傳送和接收資訊。

22.5.1 完整的產生器介面

產生器物件的完整介面 Generator 處理輸出和輸入

interface Generator {
    next(value? : any) : IteratorResult;
    throw(value? : any) : IteratorResult;
    return(value? : any) : IteratorResult;
}
interface IteratorResult {
    value : any;
    done : boolean;
}

介面 Generator 結合了我們先前看過的兩個介面:用於輸出的 Iterator 和用於輸入的 Observer

interface Iterator { // data producer
    next() : IteratorResult;
    return?(value? : any) : IteratorResult;
}

interface Observer { // data consumer
    next(value? : any) : void;
    return(value? : any) : void;
    throw(error) : void;
}

22.5.2 合作式多工

合作式多工是產生器的應用,我們需要它們來處理輸出和輸入。在我們深入探討其運作方式之前,讓我們先回顧 JavaScript 中並行處理的現況。

JavaScript 在單一程序中執行。有兩種方法可以廢除這種限制

兩個用例受益於合作式多工,因為它們涉及的控制流程大多是順序的,偶爾會暫停

22.5.2.1 透過產生器簡化非同步運算

幾個基於 Promise 的函式庫透過產生器簡化非同步程式碼。產生器是 Promise 的理想客戶端,因為它們可以在結果到達之前暫停。

以下範例示範了如果使用 T.J. Holowaychuk 的函式庫 co會是什麼樣子。我們需要兩個函式庫(如果我們透過 babel-node 執行 Node.js 程式碼)

import fetch from 'isomorphic-fetch';
const co = require('co');

co 是用於合作式多工處理的實際函式庫,isomorphic-fetch 是新的基於 Promise 的 fetch API 的多重載入(XMLHttpRequest 的替代品;閱讀 Jake Archibald 的「That’s so fetch!」以取得更多資訊)。fetch 讓撰寫 getFile 函式變得容易,該函式透過 Promise 傳回 url 中檔案的文字

function getFile(url) {
    return fetch(url)
        .then(request => request.text());
}

我們現在具備使用 co 的所有要素。下列工作會讀取兩個檔案的文字,解析其中的 JSON 並記錄結果。

co(function* () {
    try {
        const [croftStr, bondStr] = yield Promise.all([  // (A)
            getFile('https://127.0.0.1:8000/croft.json'),
            getFile('https://127.0.0.1:8000/bond.json'),
        ]);
        const croftJson = JSON.parse(croftStr);
        const bondJson = JSON.parse(bondStr);

        console.log(croftJson);
        console.log(bondJson);
    } catch (e) {
        console.log('Failure to read: ' + e);
    }
});

請注意,即使在 A 行中進行非同步呼叫,這段程式碼看起來很同步。將產生器當成工作會透過讓排程器函式 co 產生 Promise 來進行非同步呼叫。產生會暫停產生器。一旦 Promise 傳回結果,排程器就會透過 next() 將結果傳遞給產生器,讓產生器繼續執行。co 的簡化版本如下所示。

function co(genFunc) {
    const genObj = genFunc();
    step(genObj.next());

    function step({value,done}) {
        if (!done) {
            // A Promise was yielded
            value
            .then(result => {
                step(genObj.next(result)); // (A)
            })
            .catch(error => {
                step(genObj.throw(error)); // (B)
            });
        }
    }
}

我忽略了 next()(A 行)和 throw()(B 行)可能會引發例外情況(每當例外情況跳脫產生器函式的本體時)。

22.5.3 透過產生器進行合作式多工處理的限制

常式 是沒有限制的合作式多工處理工作:在常式中,任何函式都可以暫停整個常式(函式啟動本身、函式呼叫者的啟動、呼叫者的呼叫者,等等)。

相反地,你只能從產生器內部直接暫停產生器,而且只會暫停目前的函式啟動。由於這些限制,產生器偶爾會被稱為 淺層常式 [3]

22.5.3.1 產生器限制的優點

產生器的限制有兩個主要優點

JavaScript 已經具備非常簡單的合作式多工處理樣式:事件迴圈,它會在佇列中排程執行工作。每個工作都是透過呼叫函式啟動,並在函式完成後結束。事件、setTimeout() 和其他機制會將工作加入佇列。

這種多工處理樣式提供一個重要的保證:執行至完成;每個函式都可以依賴在完成之前不會被其他工作中斷。函式會變成交易,而且可以在沒有人看到其在中間狀態操作資料的情況下執行完整的演算法。並發存取共用資料會讓多工處理變得複雜,而且 JavaScript 的並發模型不允許這麼做。這就是執行至完成是一件好事的原因。

唉,協程會妨礙執行至完成,因為任何函式都可能會暫停其呼叫者。例如,下列演算法包含多個步驟

step1(sharedData);
step2(sharedData);
lastStep(sharedData);

如果 step2 要暫停演算法,其他工作可以在演算法的最後一個步驟執行之前執行。那些工作可能會包含應用程式的其他部分,而這些部分會看到 sharedData 處於未完成狀態。產生器會保留執行至完成,它們只會暫停自己並返回給呼叫者。

co 和類似的函式庫會提供大部分協程的效能,而且沒有它們的缺點

22.6 產生器的範例

本節提供產生器可以用於哪些用途的幾個範例。

22.6.1 透過產生器實作可迭代物件

迭代章節 中,我「手動」實作了幾個可迭代物件。在本節中,我改用產生器。

22.6.1.1 可迭代組合器 take()

take() 將(潛在無限的)反覆值序列轉換為長度為 n 的序列

function* take(n, iterable) {
    for (const x of iterable) {
        if (n <= 0) return;
        n--;
        yield x;
    }
}

以下為使用範例

const arr = ['a', 'b', 'c', 'd'];
for (const x of take(2, arr)) {
    console.log(x);
}
// Output:
// a
// b

不使用產生器的 take() 實作較為複雜

function take(n, iterable) {
    const iter = iterable[Symbol.iterator]();
    return {
        [Symbol.iterator]() {
            return this;
        },
        next() {
            if (n > 0) {
                n--;
                return iter.next();
            } else {
                maybeCloseIterator(iter);
                return { done: true };
            }
        },
        return() {
            n = 0;
            maybeCloseIterator(iter);
        }
    };
}
function maybeCloseIterator(iterator) {
    if (typeof iterator.return === 'function') {
        iterator.return();
    }
}

請注意,可迭代組合器 zip() 透過產生器實作並未獲益太多,因為涉及多個可迭代物件,且無法使用 for-of

22.6.1.2 無限可迭代物件

naturalNumbers() 傳回所有自然數的可迭代物件

function* naturalNumbers() {
    for (let n=0;; n++) {
        yield n;
    }
}

此函式通常與組合器搭配使用

for (const x of take(3, naturalNumbers())) {
    console.log(x);
}
// Output
// 0
// 1
// 2

以下是供您比較用的非產生器實作

function naturalNumbers() {
    let n = 0;
    return {
        [Symbol.iterator]() {
            return this;
        },
        next() {
            return { value: n++ };
        }
    }
}
22.6.1.3 受陣列啟發的可迭代組合器:mapfilter

陣列可透過 mapfilter 方法轉換。這些方法可廣義化,將可迭代物件作為輸入和輸出。

22.6.1.3.1 廣義化的 map()

這是 map 的廣義化版本

function* map(iterable, mapFunc) {
    for (const x of iterable) {
        yield mapFunc(x);
    }
}

map() 可搭配無限可迭代物件使用

> [...take(4, map(naturalNumbers(), x => x * x))]
[ 0, 1, 4, 9 ]
22.6.1.3.2 廣義化的 filter()

這是 filter 的廣義化版本

function* filter(iterable, filterFunc) {
    for (const x of iterable) {
        if (filterFunc(x)) {
            yield x;
        }
    }
}

filter() 可搭配無限可迭代物件使用

> [...take(4, filter(naturalNumbers(), x => (x % 2) === 0))]
[ 0, 2, 4, 6 ]

22.6.2 用於延遲評估的產生器

接下來兩個範例說明如何使用產生器處理字元串流。

最棒的是,所有運算都是延遲(逐步且依需求)執行的:運算會在第一個字元抵達時開始。例如,我們不必等到收到所有字元才能取得第一個字詞。

22.6.2.1 延遲拉取(產生器作為反覆器)

使用產生器的延遲拉取運作方式如下。實作步驟 1-3 的三個產生器會以下列方式串連

addNumbers(extractNumbers(tokenize(CHARS)))

鏈結中的每個成員會從來源拉取資料,並產生一連串項目。處理會從 tokenize 開始,其來源為字串 CHARS

22.6.2.1.1 步驟 1 – 產生詞彙

以下技巧讓程式碼更簡單:序列結束迭代器的結果(其屬性 donefalse)轉換成哨兵值 END_OF_SEQUENCE

/**
 * Returns an iterable that transforms the input sequence
 * of characters into an output sequence of words.
 */
function* tokenize(chars) {
    const iterator = chars[Symbol.iterator]();
    let ch;
    do {
        ch = getNextItem(iterator); // (A)
        if (isWordChar(ch)) {
            let word = '';
            do {
                word += ch;
                ch = getNextItem(iterator); // (B)
            } while (isWordChar(ch));
            yield word; // (C)
        }
        // Ignore all other characters
    } while (ch !== END_OF_SEQUENCE);
}
const END_OF_SEQUENCE = Symbol();
function getNextItem(iterator) {
    const {value,done} = iterator.next();
    return done ? END_OF_SEQUENCE : value;
}
function isWordChar(ch) {
    return typeof ch === 'string' && /^[A-Za-z0-9]$/.test(ch);
}

這個產生器如何做到延遲?當你透過 next() 要求它提供一個詞彙時,它會根據需要拉取其 iterator(A 和 B 行)以產生一個詞彙,然後產生那個詞彙(C 行)。接著,它會暫停,直到再次要求它提供一個詞彙。這表示詞彙化會在第一個字元可用時立即開始,這對於串流來說很方便。

我們來試試詞彙化。請注意,空格和句點是非詞彙。它們會被忽略,但它們會區分詞彙。我們利用字串可以迭代字元(Unicode 編碼點)的事實。tokenize() 的結果是可以迭代詞彙的物件,我們透過展開運算子(...)將其轉換成陣列。

> [...tokenize('2 apples and 5 oranges.')]
[ '2', 'apples', 'and', '5', 'oranges' ]
22.6.2.1.2 步驟 2 – 萃取數字

這個步驟相對簡單,我們只會 yield 僅包含數字的詞彙,在透過 Number() 將它們轉換成數字後。

/**
 * Returns an iterable that filters the input sequence
 * of words and only yields those that are numbers.
 */
function* extractNumbers(words) {
    for (const word of words) {
        if (/^[0-9]+$/.test(word)) {
            yield Number(word);
        }
    }
}

你再次可以看到延遲:如果你透過 next() 要求一個數字,你會在 words 中遇到一個數字後立即取得一個數字(透過 yield)。

我們從一個詞彙陣列中萃取數字

> [...extractNumbers(['hello', '123', 'world', '45'])]
[ 123, 45 ]

請注意,字串會轉換成數字。

22.6.2.1.3 步驟 3 – 加總數字
/**
 * Returns an iterable that contains, for each number in
 * `numbers`, the total sum of numbers encountered so far.
 * For example: 7, 4, -1 --> 7, 11, 10
 */
function* addNumbers(numbers) {
    let result = 0;
    for (const n of numbers) {
        result += n;
        yield result;
    }
}

我們來試試一個簡單的範例

> [...addNumbers([5, -2, 12])]
[ 5, 3, 15 ]
22.6.2.1.4 拉取輸出

產生器鏈本身不會產生輸出。我們需要透過展開運算子主動拉取輸出

const CHARS = '2 apples and 5 oranges.';
const CHAIN = addNumbers(extractNumbers(tokenize(CHARS)));
console.log([...CHAIN]);
    // [ 2, 7 ]

輔助函式 logAndYield 讓我們可以檢查事情是否真的延遲運算

function* logAndYield(iterable, prefix='') {
    for (const item of iterable) {
        console.log(prefix + item);
        yield item;
    }
}

const CHAIN2 = logAndYield(addNumbers(extractNumbers(tokenize(logAndYield(CHA\
RS)))), '-> ');
[...CHAIN2];

// Output:
// 2
//  
// -> 2
// a
// p
// p
// l
// e
// s
//  
// a
// n
// d
//  
// 5
//  
// -> 7
// o
// r
// a
// n
// g
// e
// s
// .

輸出顯示 addNumbers 在收到字元 '2'' ' 後立即產生結果。

22.6.2.2 延遲推播(產生器作為可觀察物件)

將先前的拉取式演算法轉換成推播式演算法不需要太多工作。步驟相同。但我們不是透過拉取結束,而是透過推播開始。

如前所述,如果產生器透過 yield 接收輸入,則產生器物件上的第一個 next() 呼叫不會執行任何動作。這就是我使用 先前展示的輔助函式 coroutine() 在此建立非同步函式的緣故。它會為我們執行第一個 next()

以下函式 send() 負責推播。

/**
 * Pushes the items of `iterable` into `sink`, a generator.
 * It uses the generator method `next()` to do so.
 */
function send(iterable, sink) {
    for (const x of iterable) {
        sink.next(x);
    }
    sink.return(); // signal end of stream
}

當產生器處理串流時,它需要知道串流的結尾,以便可以適當地清理。對於拉取,我們透過特殊串流結尾哨兵執行此操作。對於推入,串流結尾會透過 return() 發出訊號。

讓我們透過只輸出它接收的所有內容的產生器來測試 send()

/**
 * This generator logs everything that it receives via `next()`.
 */
const logItems = coroutine(function* () {
    try {
        while (true) {
            const item = yield; // receive item via `next()`
            console.log(item);
        }
    } finally {
        console.log('DONE');
    }
});

讓我們透過字串(這是 Unicode 編碼點的 iterable)傳送三個字元給 logItems()

> send('abc', logItems());
a
b
c
DONE
22.6.2.2.1 步驟 1 – 標記化

請注意此產生器如何透過兩個 finally 子句對串流結尾(透過 return() 發出訊號)做出反應。我們依賴於傳送 return() 到兩個 yield 中的任何一個。否則,產生器永遠不會終止,因為從 A 行開始的無限迴圈永遠不會終止。

/**
 * Receives a sequence of characters (via the generator object
 * method `next()`), groups them into words and pushes them
 * into the generator `sink`.
 */
const tokenize = coroutine(function* (sink) {
    try {
        while (true) { // (A)
            let ch = yield; // (B)
            if (isWordChar(ch)) {
                // A word has started
                let word = '';
                try {
                    do {
                        word += ch;
                        ch = yield; // (C)
                    } while (isWordChar(ch));
                } finally {
                    // The word is finished.
                    // We get here if
                    // - the loop terminates normally
                    // - the loop is terminated via `return()` in line C
                    sink.next(word); // (D)
                }
            }
            // Ignore all other characters
        }
    } finally {
        // We only get here if the infinite loop is terminated
        // via `return()` (in line B or C).
        // Forward `return()` to `sink` so that it is also
        // aware of the end of stream.
        sink.return();
    }
});

function isWordChar(ch) {
    return /^[A-Za-z0-9]$/.test(ch);
}

這次,惰性是由推入驅動的:一旦產生器收到足夠的字元來組成一個字詞(在 C 行),它就會將字詞推入 sink(D 行)。也就是說,產生器不會等到收到所有字元。

tokenize() 證明產生器很適合作為線性狀態機器的實作。在這種情況下,機器有兩個狀態:「在字詞內」和「不在字詞內」。

讓我們標記化一個字串

> send('2 apples and 5 oranges.', tokenize(logItems()));
2
apples
and
5
oranges
22.6.2.2.2 步驟 2 – 萃取數字

這個步驟很簡單。

/**
 * Receives a sequence of strings (via the generator object
 * method `next()`) and pushes only those strings to the generator
 * `sink` that are “numbers” (consist only of decimal digits).
 */
const extractNumbers = coroutine(function* (sink) {
    try {
        while (true) {
            const word = yield;
            if (/^[0-9]+$/.test(word)) {
                sink.next(Number(word));
            }
        }
    } finally {
        // Only reached via `return()`, forward.
        sink.return();
    }
});

事情再次變得惰性:一旦遇到數字,就會將其推入 sink

我們從一個詞彙陣列中萃取數字

> send(['hello', '123', 'world', '45'], extractNumbers(logItems()));
123
45
DONE

請注意,輸入是字串序列,而輸出是數字序列。

22.6.2.2.3 步驟 3 – 加總數字

這次,我們透過推入單一值然後關閉 sink 來對串流結尾做出反應。

/**
 * Receives a sequence of numbers (via the generator object
 * method `next()`). For each number, it pushes the total sum
 * so far to the generator `sink`.
 */
const addNumbers = coroutine(function* (sink) {
    let sum = 0;
    try {
        while (true) {
            sum += yield;
            sink.next(sum);
        }
    } finally {
        // We received an end-of-stream
        sink.return(); // signal end of stream
    }
});

讓我們試試這個產生器

> send([5, -2, 12], addNumbers(logItems()));
5
3
15
DONE
22.6.2.2.4 推入輸入

產生器鏈從 tokenize 開始,以 logItems 結束,它會記錄它接收的所有內容。我們透過 send 將字元序列推入鏈中

const INPUT = '2 apples and 5 oranges.';
const CHAIN = tokenize(extractNumbers(addNumbers(logItems())));
send(INPUT, CHAIN);

// Output
// 2
// 7
// DONE

以下程式碼證明處理確實是惰性發生的

const CHAIN2 = tokenize(extractNumbers(addNumbers(logItems({ prefix: '-> ' })\
)));
send(INPUT, CHAIN2, { log: true });

// Output
// 2
//  
// -> 2
// a
// p
// p
// l
// e
// s
//  
// a
// n
// d
//  
// 5
//  
// -> 7
// o
// r
// a
// n
// g
// e
// s
// .
// DONE

輸出顯示 addNumbers 在字元 '2'' ' 被推入後立即產生結果。

22.6.3 透過產生器進行合作式多工

22.6.3.1 暫停長時間執行的任務

在此範例中,我們建立一個顯示在網頁上的計數器。我們改善初始版本,直到我們有一個合作式多工版本,不會封鎖主執行緒和使用者介面。

這是網頁中應顯示計數器的部分

<body>
    Counter: <span id="counter"></span>
</body>

此函式顯示一個持續計數的計數器5

function countUp(start = 0) {
    const counterSpan = document.querySelector('#counter');
    while (true) {
        counterSpan.textContent = String(start);
        start++;
    }
}

如果您執行此函式,它會完全封鎖執行它的使用者介面執行緒,而其分頁將會沒有回應。

讓我們透過一個會定期暫停的產生器來實作相同的功能,透過 yield(此產生器的執行排程函式稍後會顯示)

function* countUp(start = 0) {
    const counterSpan = document.querySelector('#counter');
    while (true) {
        counterSpan.textContent = String(start);
        start++;
        yield; // pause
    }
}

讓我們增加一個小小的改進。我們將使用者介面的更新移到另一個產生器 displayCounter,我們透過 yield* 呼叫它。由於它是一個產生器,它也可以處理暫停。

function* countUp(start = 0) {
    while (true) {
        start++;
        yield* displayCounter(start);
    }
}
function* displayCounter(counter) {
    const counterSpan = document.querySelector('#counter');
    counterSpan.textContent = String(counter);
    yield; // pause
}

最後,這是一個排程函式,我們可以使用它來執行 countUp()。產生器的每個執行步驟都由一個透過 setTimeout() 建立的個別任務處理。這表示使用者介面可以在其間排程其他任務,而且會保持回應。

function run(generatorObject) {
    if (!generatorObject.next().done) {
        // Add a new task to the event queue
        setTimeout(function () {
            run(generatorObject);
        }, 1000);
    }
}

run 的協助下,我們得到一個(幾乎)無限的計數器,不會封鎖使用者介面

run(countUp());
22.6.3.2 使用產生器與 Node.js 風格的回呼進行合作式多工處理

如果您呼叫產生器函式(或方法),它無法存取其產生器物件;它的 this 是它如果是非產生器函式時會有的 this。一個解決方法是透過 yield 將產生器物件傳遞到產生器函式中。

下列 Node.js 腳本使用此技術,但將產生器物件包裝在一個回呼中(next,A 行)。它必須透過 babel-node 執行。

import {readFile} from 'fs';

const fileNames = process.argv.slice(2);

run(function* () {
    const next = yield;
    for (const f of fileNames) {
        const contents = yield readFile(f, { encoding: 'utf8' }, next);
        console.log('##### ' + f);
        console.log(contents);
    }
});

在 A 行,我們取得一個回呼,我們可以使用它與遵循 Node.js 回呼約定的函式。回呼使用產生器物件喚醒產生器,如您在 run() 的實作中所見

function run(generatorFunction) {
    const generatorObject = generatorFunction();

    // Step 1: Proceed to first `yield`
    generatorObject.next();

    // Step 2: Pass in a function that the generator can use as a callback
    function nextFunction(error, result) {
        if (error) {
            generatorObject.throw(error);
        } else {
            generatorObject.next(result);
        }
    }
    generatorObject.next(nextFunction);

    // Subsequent invocations of `next()` are triggered by `nextFunction`
}
22.6.3.3 通訊順序處理(CSP)

函式庫 js-csp 將通訊順序處理(CSP)帶到 JavaScript,這是一種合作式多工處理,類似於 ClojureScript 的 core.async 和 Go 的 goroutinesjs-csp 有兩個抽象

舉例來說,讓我們使用 CSP 來處理 DOM 事件,以類似於函數式反應式程式設計的方式。下列程式碼使用函式 listen()(稍後會顯示)建立一個輸出 mousemove 事件的通道。然後它在一個無限迴圈中透過 take 持續擷取輸出。感謝 yield,處理程序會封鎖,直到通道有輸出。

import csp from 'js-csp';

csp.go(function* () {
    const element = document.querySelector('#uiElement1');
    const channel = listen(element, 'mousemove');
    while (true) {
        const event = yield csp.take(channel);
        const x = event.layerX || event.clientX;
        const y = event.layerY || event.clientY;
        element.textContent = `${x}, ${y}`;
    }
});

listen() 的實作如下。

function listen(element, type) {
    const channel = csp.chan();
    element.addEventListener(type,
        event => {
            csp.putAsync(channel, event);
        });
    return channel;
}

22.7 反覆運算 API(包括產生器)中的繼承

這是 ECMAScript 6 中各種物件如何連接的圖表(它基於 ECMAScript 規格中 Allen Wirf-Brock 的圖表

圖例

此圖表揭露了兩個有趣的事實

首先,產生器函式 g 非常像建構函式(不過,您無法透過 new 呼叫它;那會導致 TypeError):它建立的產生器物件是它的實例,新增到 g.prototype 的方法會變成原型方法,等等。

> function* g() {}
> g.prototype.hello = function () { return 'hi!'};
> const obj = g();
> obj instanceof g
true
> obj.hello()
'hi!'

其次,如果您想要讓所有產生器物件都可以使用這些方法,最好將它們新增到 (Generator).prototype。存取該物件的方法之一如下

const Generator = Object.getPrototypeOf(function* () {});
Generator.prototype.hello = function () { return 'hi!'};
const generatorObject = (function* () {})();
generatorObject.hello(); // 'hi!'

22.7.1 IteratorPrototype

圖表中沒有 (Iterator),因為沒有這樣的物件。但是,由於 instanceof 的運作方式,而且因為 (IteratorPrototype)g1() 的原型,所以您仍然可以說 g1()Iterator 的實例。

ES6 中的所有反覆運算器在它們的原型鏈中都有 (IteratorPrototype)。該物件是可反覆運算的,因為它有下列方法。因此,所有 ES6 反覆運算器都是可反覆運算的(因此,您可以對它們套用 for-of 等)。

[Symbol.iterator]() {
    return this;
}

規格建議使用下列程式碼存取 (IteratorPrototype)

const proto = Object.getPrototypeOf.bind(Object);
const IteratorPrototype = proto(proto([][Symbol.iterator]()));

您也可以使用

const IteratorPrototype = proto(proto(function* () {}.prototype));

引用 ECMAScript 6 規格

ECMAScript 代碼也可以定義繼承自 IteratorPrototype 的物件。IteratorPrototype 物件提供一個地方,可以新增適用於所有迭代器物件的其他方法。

IteratorPrototype 可能會在 ECMAScript 的後續版本中直接存取,並包含工具方法,例如 map()filter() (來源).

22.7.2 產生器中的 this

產生器函式結合了兩個考量

  1. 它是一個函式,用來設定和傳回一個產生器物件。
  2. 它包含產生器物件逐步執行的程式碼。

這就是為什麼產生器內部 this 的值並不明顯。

在函式呼叫和方法呼叫中,this 的值與 gen() 不是產生器函式,而是常態函式時相同

function* gen() {
    'use strict'; // just in case
    yield this;
}

// Retrieve the yielded value via destructuring
const [functionThis] = gen();
console.log(functionThis); // undefined

const obj = { method: gen };
const [methodThis] = obj.method();
console.log(methodThis === obj); // true

如果你在透過 new 呼叫的產生器中存取 this,你會得到一個 ReferenceError (來源:ES6 規格)

function* gen() {
    console.log(this); // ReferenceError
}
new gen();

一個解決方法是將產生器包裝在一個常態函式中,透過 next() 將產生器傳遞給其產生器物件。這表示產生器必須使用其第一個 yield 來擷取其產生器物件

const generatorObject = yield;

22.8 樣式考量:星號前後的空白

星號格式化的合理且合法的變化包括

讓我們找出這些變化中哪些適用於哪些建構,以及原因。

22.8.1 產生器函式宣告和表達式

在此,星號僅用於 generator(或類似字詞)不可用作關鍵字。如果可用,則產生器函式宣告會如下所示

generator foo(x, y) {
    ···
}

ECMAScript 6 使用星號標記 function 關鍵字,而不是 generator。因此,function* 可以視為 generator 的同義字,建議如下撰寫產生器函式宣告。

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

匿名產生器函數表達式將會以這種方式格式化

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

22.8.2 產生器方法定義

在撰寫產生器方法定義時,我建議以以下方式格式化星號。

const obj = {
    * generatorMethod(x, y) {
        ···
    }
};

在星號後加上空白有三個論點支持。

首先,星號不應該是方法名稱的一部分。一方面,它不是產生器函數名稱的一部分。另一方面,只有在定義產生器時才會提到星號,而不會在使用時提到。

其次,產生器方法定義是以下語法的縮寫。(為了說明我的觀點,我也會多餘地為函數表達式命名。)

const obj = {
    generatorMethod: function* generatorMethod(x, y) {
        ···
    }
};

如果方法定義是要省略 function 關鍵字,那麼星號後面應該加上空白。

第三,產生器方法定義在語法上類似於 getter 和 setter(ECMAScript 5 中已經有了)

const obj = {
    get foo() {
        ···
    }
    set foo(value) {
        ···
    }
};

關鍵字 getset 可以視為普通方法定義的修改器。可以說,星號也是這樣的修改器。

22.8.3 格式化遞迴 yield

以下是產生器函數遞迴產生其自己的產生值的範例

function* foo(x) {
    ···
    yield* foo(x - 1);
    ···
}

星號標記了不同類型的 yield 算子,這就是上述寫法有意義的原因。

22.8.4 產生器函數和方法的文件化

Kyle Simpson (@getify) 提出了有趣的建議:由於我們在撰寫函數和方法(例如 Math.max())時,通常會加上括號,那麼在撰寫產生器函數和方法時,在前面加上星號是否合理?例如:我們是否應該撰寫 *foo() 來指稱前一個小節中的產生器函數?讓我反對這個說法。

在撰寫會傳回可迭代項目的函數時,產生器只是其中一種選項。我認為最好不要透過標記函數名稱來提供這個實作細節。

此外,在呼叫產生器函數時不會使用星號,但會使用括號。

最後,星號沒有提供有用的資訊 – yield* 也可以用於傳回可迭代項目的函數。但標記傳回可迭代項目的函數和方法(包括產生器)的名稱可能是合理的。例如,透過後綴 Iter

22.9 常見問題:產生器

22.9.1 為什麼對產生器使用關鍵字 function* 而不是 generator

由於向後相容性,使用關鍵字generator並非選項。例如,以下程式碼(一個假設的 ES6 匿名產生器表達式)可以是 ES5 函式呼叫後接程式碼區塊。

generator (a, b, c) {
    ···
}

我發現星號命名方案很好地延伸到yield*

22.9.2 yield是關鍵字嗎?

yield僅在嚴格模式中是保留字。有一個技巧可以將它帶到 ES6 隨意模式:它變成語境關鍵字,僅在產生器內可用。

22.10 結論

我希望本章讓您確信產生器是一個有用且多功能的工具。

我喜歡產生器讓您實作協同多工任務,這些任務在進行非同步函式呼叫時會封鎖。在我看來,那是非同步呼叫的正確心智模式。希望 JavaScript 未來朝這個方向進一步發展。

22.11 進一步閱讀

本章來源

[1] “非同步產生器提案” by Jafar Husain

[2] “協同程序和並發性的好奇課程” by David Beazley

[3] “為什麼協同程序無法在網路上運作” by David Herman

下一篇:V 標準函式庫