[書籍翻譯] 《JavaScript併發編程》第四章 使用Generators實現惰性計算

本文是我翻譯《JavaScript Concurrency》書籍的第四章 使用Generators實現惰性計算,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。

完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation 。由於能力有限,肯定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝。

惰性計算是一種編程技術,它用於當我們希望需要使用值的時候纔去計算的場景。這樣,可以確保我們確實需要它。相反的,直接都去計算,有可能計算了我們不需要的值。這通常沒什麼問題,但當我們的應用程序的大小和複雜性增長到一定水平,這些計算造成的浪費就難以想象了。

Generator是引入到JavaScript中一種新的原生類型並作爲ES6語言規格的一部分。Generator幫助我們在代碼中實現惰性計算技術,進一步說,幫助我們實現保護併發原則。

我們將通過對Generator的一些簡單介紹來開始本章,先讓我們對它們的表現方式有一定了解。之後,我們將進入更高級的惰性計算場景,並通過協程結束本章。現在讓我們開始吧。

調用堆棧和內存分配

內存分配是任何編程語言都必不可少的。如果沒有它,我們就沒有所謂的數據結構,甚至沒有原生類型。現在內存雖然很便宜,一般都有足夠的內存可供使用; 但這並不值得高興。雖然今天在內存中分配更大的數據結構更加可行,但是在10年前,當我們編程時,我們仍然必須釋放分配內存。JavaScript是一種垃圾自動收集語言,這意味着我們的代碼不必顯式地銷燬內存中的對象。但是,垃圾收集器會導致CPU損耗。

所以這裏有兩個因素在起作用。我們想在這裏保存兩個資源,我們將嘗試使用生成器來實現惰性計算。我們不必要多餘的分配內存,如果我們能避免這一點,那麼就可以避開頻繁的調用垃圾收集器。在本節中,我將介紹一些Generator生成器概念。

標記函數上下文

在一個正常的函數調用棧,一個函數返回一個值。在return語句激活一個新的執行上下文並且丟棄舊的上下文,因爲返回就代表已處理完畢了。生成器函數是一個特殊的JavaScript函數語法類型,和return語句相比他們的調用棧不那麼老套。這裏有張圖表示了生成器函數的調用,並在開始生成值時發生的事情:

image076.gif

正如return語句將值傳遞給調用上下文一樣,yield語句也會返回一個值。但是,與普通函數不同的是,生成器函數上下文不會被丟棄。事實上,它們被加上標記,以便在將控制權交還給生成器上下文時,它可以從中斷處繼續執行獲取值,直到完成爲止。這個標記非常容易,因爲它只是指向我們代碼中的位置。

序列而不是數組

在JavaScript中,當我們需要遍歷事物,數字、字符串、對象等列表時,我們會使用數組。數組是通用的,功能也是強大的。在惰性計算的上下文中,數組的挑戰是數組本身就是數據需要分配。所以我們的數組需要在內存中的某個位置分配元素,並且還有關於數組中元素的元數據。

如果我們在使用大數據量的對象,則與數組相關的內存開銷就很大。另外,我們需要以某種方式將這些對象放在數組中。這是額外的步驟會增加CPU消耗。另一種概念是序列。序列不是有形的JavaScript語言結構。它們是一個抽象的概念 - 數組但沒有實際分配數組。序列有助於惰性計算。由於這個原因,沒有什麼需要分配內存,並且沒有初始入口。這是迭代數組所涉及的示圖:

image077.gif

我們可以看到,在我們迭代這三個對象之前,我們首先必須分配一個數組,然後用這些對象填充它。讓我們將這種方法與序列的概念思想進行對比,如下圖所示:

image078.gif

對於序列,我們沒有爲我們感興趣的迭代對象提供明確的容器結構。與序列關聯的唯一開銷是指向當前項的指針。我們可以使用生成器函數作爲在JavaScript中生成序列的機制。正如我們在上一節中看到的那樣,生成器在將值返回給調用者時將其執行上下文加上標記。這是我們目前需要的最小開銷。它使我們能夠惰性地計算對象並將它們作爲序列進行迭代。

創建生成器並生成值

在本節中,將介紹生成器函數語法,並將逐步介紹生成器的值。我們還將研究可以用來迭代生成器生成值的兩種方法。

生成器函數語法

生成器函數的語法幾乎與普通函數相同。不同之處在於function關鍵字的聲明後面跟一個星號。更重要的區別是返回值,它總是返回一個生成器實例。此外,儘管創建了新對象,但不需要new關鍵字。下面讓我們來看看生成器函數是怎樣的:

//生成器函數使用星號來表示返回生成器實例。
//我們可以從生成器返回值,
//然而不是調用者獲得該值,
//他們將永遠獲取生成器實例。
function* gen() {
    return 'hello world';
}

//創建生成器實例。
var generator = gen();

//讓我們看看它是什麼樣的。
console.log('generator', generator);
//→generator Generator

//這是我們獲得返回值的方式。看起來很尷尬,
//因爲我們永遠不會使用生成器函數只返回一個值。
console.log('return', generator.next().value);
//→return hello world

我們不太可能以這種方式使用生成器,但它是說明生成器函數與普通函數一些差別的好方法。例如,return語句在生成器函數中是完全有效的,然而,正如我們所看到的,它們爲調用者產生了完全不同的結果。在實踐中,我們更有可能在生成器中遇到yield語句,所以讓我們接下來看看它們。

生成值

生成器函數的常見情況是產生值並控制返回調用者。將控制權交還給調用者是生成器的一個定義特徵。當我們生成值時,生成器會在代碼中標記我們的位置。這樣做是因爲調用者可能會從生成器請求另一個值,而當它發生時,生成器只是從它停止的地方開始。讓我們來看一下產生幾次值的生成器函數:

//此函數按順序生成值。
//沒有容器結構,就像一個數組。
//相反,每一次調用yield語句,
//控制權交回到調用者,以及函數中的位置加上標記。
function* gen() {
    yield 'first';
    yield 'second';
    yield 'third';
}

var generator = gen();

//每次調用“next()”時,控制權都會被傳回到生成器函數的執行上下文。
//然後,生成器通過標記查找它最近產生值的位置。
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);

前面的代碼纔是序列真正的樣子。我們有三個值,它們是從我們的函數中順序產生的。它們也沒有放入任何類型的容器結構中。第一個調用yield傳遞firstnext(),在它被調用的地方。其他兩個值也是如此。事實上,行爲上是惰性計算的。我們有三次調用console.log()gen()的實現將返回一組值供我們輸出。相反,當我們需要輸出一個值時,我們會從生成器中獲取它。這是懶惰的因素;我們會保留我們的努力,直到他們真正需要,避免分配和計算。

我們之前的示例不太理想之處是我們正在重複調用console.log(),實際上,我們想迭代序列,爲其中的每項調用console.log()。讓我們現在迭代一些生成器序列。

迭代生成器

next()方法對於我們,已不奇怪了,它返回生成器序列接下來的值。它實際返回的值由兩個屬性構成:生成值和是否生成器結束。但是,我們一般不想硬編碼調用next()。取而代之的是,我們想調用它反覆的從生成器生成值。下面是一個使用while循環的例子,來循環遍歷一個生成器:

//基本的生成器函數產生序列值。
function* gen(){
    yield 'first';
    yield 'second';
    yield 'third';
}

//創建生成器。
var generator = gen();

//循環直到序列結束。
while(true) {
    //獲取序列中的下一項。
    let item = generator.next();
    
    //有下一個值,還是結束了?
    if(item.done) {
        break;
    }

    console.log('while', item.value);
}

此循環將一直持續,直到yield返回值的done屬性爲true;在這一點上,我們知道沒有任何東西了,可以停止它。這讓我們遍歷生成值的序列,而無需創建一個數組然後去迭代它。然而,在這個循環中有些重複代碼,它們更多的是在管理生成器迭代而不是實際迭代它。我們來看看另一種方法:

//“for..of”循環消除了需要顯式的調用生成器構造,
//如“next()”,“value”,“done”。
for (let item of generator) {
    console.log('for..of', item);
}

現在要好得多。我們將代碼縮減後並且更加專注於手頭任務。除了for..of語句之外,這段代碼基本上與我們的while循環完全相同,它知道iterable是生成器時要做什麼。迭代生成器是併發JavaScript應用程序中的常見模式,因此在這裏優化代碼和提升可讀性將是明智的決定。

無限序列

一些序列是無限的,素數,斐波納契數,奇數,等等。無限序列不限於數字組合;更抽象的概念可以被認爲是無限的。例如,一組無限重複的字符串,一個無限切換的布爾值,依此類推。在本節中,我們將探討生成器如何使我們能夠使用無限序列。

沒有盡頭

從內存消耗的角度來看,從無限序列中分配項是不實際的。事實上,甚至不可能分配整個序列 - 它是無限的。內存是有限的。因此,最好是簡單地完全迴避整個分配問題,並使用生成器根據需要從序列中產生值。在任何給定的時間點,我們的應用程序只會使用無限序列的一小部分。以下是無限序列中使用的內容與這些序列潛在大小的示意圖:

image079.gif

我們可以看到,在這個序列中有大量的項我們永遠不會用到。讓我們看看一些惰性地從無限斐波納契數列中產生項的生成器代碼:

//生成無限的Fibonacci序列。
function* fib() {
    var seq = [0, 1],
        next;

    //這個循環實際上並沒有無限運行,
    //只當使用“next()”請求序列中的項時。
    while (true) {
        //產生序列中的下一個項。
        yield (next = seq[0] + seq[1]);
        //存儲所需的狀態,
        //以便計算下一次迭代中的項。
        seq[0] = seq[1];
        seq[1] = next;
    }
}

//啓動生成器。這永遠不會“done”生成值。
//然而,它是惰性的 - 它只是在我們需要的時候生成值。
var generator = fib();

//獲取序列的前5項。
for (let i = 0; i < 5; i++) {
    console.log('item', generator.next().value);
}

交替序列

無限序列的變化是循環序列或交替序列。到達終點時,這些類型的序列是循環的; 他們從起點來開始。以下是兩個值之間交替的序列:

image080.gif

這種類型的序列將繼續無限地生成值。當我們有一組規則來確定序列的定義方式和生成的項集合時,這就變得很有用了;然後,我們重新開始這一系列。現在,讓我們看一些代碼,看看如何使用生成器實現這些序列。這是一個通用的生成器函數,我們可以用來在值之間進行交替:

//一個通用生成器將無限迭代
//提供的參數,產生每個項。
function* alternate(...seq) {
    while (true) {
        for (let item of seq) {
            yield item;
        }
    }
}

這是我們第一次聲明一個接受參數的生成器函數。實際上,我們使用spread運算符來迭代傳遞給函數的參數。與參數不同,我們使用spread運算符創建的seq參數是一個真實數組。當我們遍歷這個數組時,我們從生成器中生成每個項。這乍一看起來似乎並不那麼有用,但是這裏的while循環起了真正的作用。由於while循環永遠不會退出,for循環將自己重複。也就是說,它會交替出現。這否定了明確的需要標記代碼(我們到達了序列的末尾嗎?我們如何重置計數器並回到開頭?等等)讓我們看看這個生成器函數是如何工作的:

//通過提供的參數,創建一個交替的生成器。
var alternator = alternate(true, false); 

console.log('true/false', alternator.next().value);
console.log('true/false', alternator.next().value);
console.log('true/false', alternator.next().value); 
console.log('true/false', alternator.next().value);
//→
// true/false true
// true/false false
// true/false true
// true/false false

很酷吧。因此,只要我們繼續獲取值,alternator將繼續生成true/false值。這裏的主要好處是我們不需要知道關於下一個值,alternator爲我們負責完成。讓我們看看這個用不同的序列迭代的生成器函數:

//使用新值創建新的生成器實例
//來迭代每個項。
alternator = alternator('one', 'two', 'three');

//從無限序列中獲取前10個項。
for (let i = 0; i < 10; i++) {
    console.log('one/two/three', `"${alternator.next().value}"`);
}

//→
//one/two/three "one"
//one/two/three "two"
//one/two/three "three"
//one/two/three "one"
//one/two/three "two"
//one/two/three "three"
//one/two/three "one"
//one/two/three "two"
//one/two/three "three"
//one/two/three "one"

正如我們所看到的,alternate()函數在傳遞給它的任何參數之間交替生成項。

傳遞到其他生成器

我們已經看到了yield語句如何能夠暫停一個生成器函數執行上下文,並生成一個值返回到當前調用上下文。在yield語句上有一個變化,它允許我們傳遞到其他generator函數。另一種技術涉及到創建一個組合生成器,它由幾個生成器交織在一起。在本節中,我們將探討這些方法。

選擇一個策略

傳遞到其他生成器使我們的函數能夠在運行時決定將控制從一個生成器切換到另一個生成器。換句話說,它允許基於策略選擇更合適的生成器函數。這有一張圖表示一個生成器函數,決定並傳遞到其他某個生成器函數:

image081.gif

我們在整個應用程序會使用這裏的三個專用生成器。也就是說,他們每一個都有自己獨有的方式。也許,他們有自己特定類型的輸入。然而,這些生成器只是對它們給出的輸入做出假設。它可能不是在用最好的方式在執行任務,所以,我們必須要弄清楚其中的這些生成器再使用。我們希望避免在所有的地方執行這些決策選擇的代碼。如果我們能夠封裝所有這些成爲一個通用的生成器,能處理通常的一些情況,這將會很不錯。

假設我們有以下生成器函數,它們同樣適用在我們的應用程序中:

//映射對象集合到特定的屬性名稱的生成器。
function* iteratePropertyValues(collection, property) {
    for (let object of collection) {
        yield object[property];
    }
}

//生成給定對象的每個值的生成器。
function* iterateObjectValues(collection) {
    for (let key of Object.keys(collection)) {
        yield collection[key];
    }
}

//生成給定數組中每個項的生成器。
function* iterateArrayElements(collection) {
    for (let element of collection) {
        yield element;
    }
}

這些函數簡潔小巧,易於使用。麻煩的是這些函數中的每一個都會對傳入的集合做出判斷。它是一個對象數組,每個對象都有一個特定的屬性嗎?它是一個字符串數組?它是一個對象而不是一個數組?由於這些生成器函數在我們的代碼中通常用於類似的目的,我們可以實現一個更通用的迭代器,它的工作是確定要使用的最適合的生成器函數,然後再用它。讓我們看看這個函數是什麼樣的:

//這個生成器傳遞到其他生成器。
//但首先,它執行一些邏輯來確定最好的生成器函數。
function* iterateNames(collection) {
    //我們正在處理數組嗎?
    if (Array.isArray(collection)) {
        
        //這是一個啓發式的,我們檢查第一個
        //數組中的元素。基於此,我們
        //對剩餘元素做出假設。
        let first = collection[0];

        //這是我們推崇其他更專業的生成器,
        //基於我們從第一個數組元素髮現的內容。
        if (first.hasOwnProperty('name')) {
            yield* iteratePropertyValues(collection, 'name');
        } else if(first.hasOwnProperty('customerName')) {
            yield* iteratePropertyValues(collection, 'customerName');
        } else {
            yield* iterateArrayElements(collection);
        }
    } else {
        yield* iterateObjectValues(collection);
    }
}

可以將iterateNames()函數看作其他三個生成器中的任何一個的簡單代理。它根據輸入,並在一個集合上做出選擇。我們本可以實現一個大型生成器函數,但這將使我們無法直接使用想要使用較小生成器的用例。如果我們想用它們來組合新功能特性怎麼辦?或者另一個複合生成器需要用嗎?保持生成器函數小而專注是一個好主意。該yield* 語法允許我們將控制權移交給更合適的生成器。

現在,讓我們看看這個通用生成器函數如何通過傳遞到最適合處理數據的生成器來使用:

var colection;

//迭代一串字符串名稱。
collection = ['First', 'Second', 'Third'];

for (let name of iterateNames(collection)) {
    console.log('array element', `"${name}"`);
}

//迭代一個對象,其中使用值
//來命名的 - 這裏的鍵不相關。
collection = {
    first: 'First',
    second: 'Second',
    third: 'Third'
};

for (let name of iterateNames(collection)) {
    console.log('object value', `"${name}"`);
}

//在集合中迭代每個對象的“name”屬性。
collection = [
    {name: 'First'},
    {name: 'Second'},
    {name: 'Third'}
];

for (let name of iterateNames(collection)) {
    console.log('property value', `"${name}"`);
}

交錯生成器

當生成器傳遞到另一個生成器時,控制器不會返回第一個生成器,直到第二個生成器全部完成。在前面的例子中,我們的生成器只是尋找一個更好的生成器來完成工作。但是,有時我們會有兩個或更多數據源需要一起使用。因此,而不是將控制權交給一個生成器,然後傳遞到另一個等等,我們會在各種來源之間交替,輪流處理數據。

這裏有一個示圖,說明了交錯多個數據源以創建單個數據源的生成器的方法:

image082.gif

我們的方法是循環數據源,而不是清空一個源,然後清空另一個源,依此類推。這樣的生成器將要處理的,並不是一個大型集合,而是兩個或更多集合。使用這種生成器技術,我們實際上可以將多個數據源視爲一個大數據源,但無需爲大型結構分配內存。我們來看下面的代碼示例:

'use strict';

//將輸入數組轉換爲生成每個值的生成器的實用工具函數。
//如果它不是數組,假定它已經是一個生成器並且傳遞給它。
function* toGen(array) {
    if (Array.isArray(array)) {
        for (let item of array) {
            yield item;
        }
    } else {
        yield* array;
    }
}

//交錯給定的數據源(數組或生成器)到一個生成器源。
function* weave(...sources) {
    //這控制“while”循環。
    //只要有一個產生數據的來源,
    //while循環仍然有效。
    var yielding = true;

    //我們必須確保每一個sources是一個生成器。
    var generators = sources.map(source => toGen(source));

    //啓動主交錯循環。它就是這樣通過每個來源,
    //從每個源產生一個項,然後重新開始,
    //直到每一個來源是空的。
    while(yield) {
        yielding = false;
        for (let origin of generator) {
            let next = source.next();
            
            //只要我們產生數據,“yield”值就是true,
            //而且“while”循環繼續。
            //當每個來源“done”都是true,
            //“yielding”變量保持爲false,
            //那麼“while”循環退出。
            if (!next.done) {
                yielding = true;
                yield next.value;
            }
        }
    }
}

//一個通過迭代給定的源生成值的基本過濾器,
//並且產生項未被禁用。
function* enabled(source) {
    for (let item of source) {
        if (!item.disabled) {
            yield item;
        }
    }
}

//這些是我們要交錯的兩個數據源傳入一個生成器,
//然後可以由另一個生成器過濾。
var enrolled = [
    {name: 'First'},
    {name: 'Sencond'},
    {name: 'Third', disabled: true}
];

var pending = [
    {name: 'Fourth'},
    {name: 'Fifth'},
    {name: 'Sixth', disabled: true}
];

//創建生成器,從兩個數據源生成用戶對象。
var users = enabled(weave(registered, pending));

//實際上執行交錯和過濾。
for (let user of users) {
    console.log('name', `"${user.name}"`);
}

將數據傳遞給生成器

yield語句不只是放棄控制權返回給調用者,它也返回一個值。該值通過next()方法傳遞給生成器函數。這就是我們在創建數據後將數據傳遞給生成器的方法。在本節中,我們將討論生成器的兩面性,以及如何能創建反饋循環產生一些精巧代碼。

複用生成器

有些生成器是通用的,在我們的代碼中經常使用。在這種情況下,不斷創建和銷燬這些生成器實例是否有意義?或者我們可以複用它們嗎?例如,考慮一個主要依賴於初始條件的序列。假設我們想生成一個偶數序列。我們將從2開始,當我們迭代這個生成器時,該值將遞增。下次我們要迭代偶數時,我們必須創建一個新的生成器。

這有點浪費,因爲我們所做的只是重置計數器。如果我們採用不同的方法,允許我們繼續爲這些類型的序列使用相同的生成器實例,該怎麼辦?生成器的next()方法是此功能的可能實現方式。我們可以傳遞一個值,然後重置我們的計數器。因此,每次我們需要迭代偶數時,不必創建新的生成器實例,我們可以簡單地調用next(),傳入的值作爲重置生成器的初始條件。

yield關鍵字實際上會返回一個值 - 傳遞到next()的參數。大多數情況下,這是未定義的,例如當生成器在for..of循環中迭代時。然而,這就是我們在開始運行後能夠將參數傳遞給生成器的方法。這與將參數傳遞給生成器函數不同,這對於執行生成器的初始配置非常方便。傳遞給next()的值是當我們需要爲要生成的下一個值更改某些內容時,我們如何與生成器通信。

讓我們看一下如何使用next()方法創建可重用的偶數序列生成器:

//這個生成器將不斷生成偶數。
function* genEvens() {
    
    //初始值爲2.但這可以通過在傳遞給“next()”的input值進行改變
    var value = 2,
        input;
        
    while (true) {
        //我們產生值,並獲得input值。
        //如果提供input值,這將作爲下一個值。
        input = yield value;
        
        if (input) {
            value = input;
        } else {
            //確保下一個值是偶數。
            //處理奇數值時的情況傳遞給“next()”。
            value += value % 2 ? 1 : 2;
        }
    }
}

//創建“evens”生成器。
var evens = genEvens(),
    even;

//迭代偶數達到10。
while ((even = evens.next().value) <= 10) {
    console.log('even', even);
}

//→
// even 2
// even 4
// even 6
// even 8
// even 10

//重置生成器。我們不需要創建一個新的。
evens.next(999);

//在1000 - 1024之間迭代even值。
while ((even = evens.next().value) <= 1024) {
    console.log('evens from 1000', even);
}

//→
//evens from 1000 1002
//evens from 1000 1004
//evens from 1000 1006
//evens from 1000 1008
//evens from 1000 1010
//evens from 1000 1012
//evens from 1000 1014
如果你想知道爲什麼我們沒有使用for..of循環來支持while循環,那是因爲你使用for..of循環迭代生成器
執行此操作時,只要循環退出,生成器就會標記爲已完成。因此,它將不再可用。

輕量級map/reduce

我們可以用next()方法做的其他事情是將一個值映射到另一個值。例如,假設我們有一個包含七個項的集合。要映射這些項,我們將迭代集合,將每個項傳遞給next()。正如我們在上一節中所見,此方法可以重置生成器的狀態,但它也可以用於提供輸入數據流,就像它提供輸出數據流一樣。

讓我們看看是否可以通過next()將它們傳入生成器來編寫一些執行此映射集合項的代碼:

//這個生成器只要調用“next()”,將繼續迭代。
//這也是期待的結果,以便它可以調用
//“iteratee()”函數就可以生成結果。
function* genMapNext(iteratee) {
    var input = yield null;
    while (true) {
        input = yield iteratee(input);
    }
}

//我們想要映射的數組。
var array = ['a', 'b', 'c', 'b', 'a'];

//一個“mapper”生成器。我們傳遞一個iteratee函數,
//作爲“genMapNext()”的參數。
var mapper = genMapNext(x => x.toUpperCase());

//我們迭代的起點
var reduced = {};

//我們必須調用“next()”來開始生成器。
mapper.next();

//現在我們可以開始迭代數組了。
//“mapped”值來自生成器。
//我們想要映射的值通過將其傳遞給“next()”進入生成器。
for (let item of array) {
    let mapped = mapper.next(item).value;
    
    //我們的簡化邏輯採用映射值,
    //並將其添加到“reduced”對象中,
    //計算重複鍵的數量。
    if (reduced.hasOwnProperty(mapped)) {
        reduced[mapped]++;
    } else {
        reduced[mapped] = 1;
    }
}

console.log('reduced', reduced);
//→reduced {A: 2, B: 2, C: 1}

我們可以看到,這確實是可能的。我們能夠使用這種方法執行輕量級的map/reduce任務。映射生成器具有iteratee函數,該函數應用於集合中的每一項。當我們遍歷數組時,我們可以通過將這些項傳遞給next()方法來將這些項提供給生成器作爲一個參數。

但是,有一些關於前一種方法的東西感覺並不是最好 - 必須像這樣啓動生成器,並且爲每次迭代顯式調用next()都會感覺很笨拙。實際上,我們不能直接應用iteratee函數,而是非得調用next()嗎?在使用生成器時,我們需要注意這些事情;特別是在將數據傳遞給生成器時。僅僅因爲我們能夠實現,並不意味着這是一個好主意。

如果我們像對待所有其他生成器一樣簡單地迭代生成器,mapping和reducing可能會感覺更自然。我們仍然希望生成器爲我們提供的輕量級映射,以避免內存分配。讓我們嘗試一種不同的方法 - 一種不需要next()的方法:

//這個生成器是一個比“genMapNext()”更有用的映射器,
//因爲它不依賴於值通過“next()”進入生成器。

//相反,這個生成器接受一個iterable,
//和一個iteratee函數。iterable是iterated-over,
//以及iteratee的結果是可以生成的。
function* genMap(iterable, iteratee) {
    for (let item of iterable) {
        yield iteratee(item);
    }
}

//使用iterable的數據源創建我們的“mapped”生成器和iteratee函數。
var mapped = genMap(array, x => x.toUpperCase());
var reduced = {};

//現在我們可以簡單地迭代我們的生成器而不是調用“next()”。
//每個循環迭代的工作都是執行reduction邏輯,而不是調用“next()”。
for (let item of mapped) {
    if (reduced.hasOwnProperty(item)) {
        reduced[item]++;
    } else {
        reduced[item] = 1;
    }
}

console.log('reduced', reduced);
//→reduced improved {A: 2, B: 2, C: 1}

這看起來像是一種改進。代碼更少,生成器的流程更容易理解。不同之處在於我們將數組和iteratee函數預先傳遞給生成器。然後,當我們遍歷生成器時,每個項都會被惰性地映射。將此數組迭代爲對象的代碼也更易於閱讀。

我們剛剛實現的這個genMap()函數是通用的,他對我們很有用。在實際應用中,映射比大寫轉換更復雜。更有可能的是,將有多個級別的映射。也就是說,我們映射的集合,映射它N多次。如果我們能對我們的代碼做一個良好的設計,然後,我們要以較小的迭代功能來組合生成器。

但是我們怎樣才能保持這種通用和惰性呢?方法是使用幾個生成器,每個生成器作爲下一個生成器的輸入。這意味着,當我們的reducer代碼遍歷這些生成器時,只有一個項可以通過各種映射層到達代碼。讓我們來實現這個:

//此函數通過iterable組成一個生成器。
//這個方法是爲每個iteratee創造生成器,
//以便每個項來自原始的可迭代,向下傳遞,
//通過每個iteratee,在映射下一個項之前。
function composeGenMap(...iteratees) {

    //我們正在返回一個生成器函數。
    //那樣,可以使用相同的映射組合,
    //可以應用於多個迭代,而不僅僅是一個。
    return function* (iterable) {

        //爲每個iteratee創建生成器傳遞給函數。
        //下一個生成器將前一個生成器作爲“itarable”參數
        for (let iteratee of iteratees) {
            iterable = genMap(iterable, iteratee);
        }

        //簡單地傳遞我們創建的最後一個迭代。
        yield* iterable;
    }
}

//我們的可迭代數據源 
var array = [1, 2, 3];

//使用3個iteratee函數創建“composed”映射生成器。
var composed = composeGenMap(
    x => x + 1,
    x => x * x,
    x => x - 2
);

//現在我們可以迭代組合的生成器,
//傳遞它到我們的迭代和惰性的映射值。
for (let item of composed(array)) {
    console.log('composed', item);
}

//→
// composed 2
// composed 7
// composed 14

協程

協程是一種允許協作式多任務處理的併發技術。這意味着如果我們應用程序的一部分需要執行一些任務,它可以這樣做,然後將控制權移交給應用程序的另一部分。想想一個子程序,或者更接近的,一個函數。這些子程序通常依賴於其他子程序。然而,它們不僅僅是連續運行,而是相互合作。

在JavaScript中,沒有內在的協程機制。生成器不是協程,但它們具有相似的屬性。例如,生成器可以暫停執行一個函數,去控制另一個執行上下文,然後重新獲得控制。這讓我們有些想象空間,但是生成器只是用於生成值,它並不是我們瞭解協程所必須的。在本節中,我們將介紹使用生成器在JavaScript中實現協程的一些方法。

創建協程函數

生成器爲我們提供了在JavaScript中實現協同函數所需的大部分內容; 他們可以暫停並繼續執行。我們只需要在生成器周圍實現一些細微的抽象,這樣我們正在使用的函數實際上就像調用協程函數,而不是迭代生成器。以下大致說明我們希望協程在調用時的行爲:

image086.gif

這個方法是調用協程函數從一個yield語句移動到下一個。我們可以通過傳遞一個參數來爲協程提供輸入,然後由yield語句返回。這需要記住很多,所以讓我們在函數包裝器中概括這些協程概念:

//取自:http://syzygy.st/javascript-coroutines/
//該工具函數接受一個生成器函數,然後返回
//協程函數。任何時候協程被調用,
//它的工作都是在生成器上調用“next()”。
//
//結果是生成器函數可以無限地運行,
//只到當它命中“yield”語句時暫停。
function coroutine(func) {
    //創建生成器,並移動函數
    //在第一個“yield”聲明之前。
    var gen = func();
    gen.next();

    //“val”通過“yield”語句傳遞給生成器函數。
    //然後從那裏恢復,直到它到達另一個yield。
    return function(val) {
        gen.next(val);
    }
}

非常簡單 - 五行代碼,但它也很強大。Harold的包裝器返回的函數只是將生成器推進到下一個yield語句,如果提供了參數,則將參數提供給next()。聲明工具函數是一種方法,但讓我們實際使用它來實現協程函數:

//在調用時創建一個coroutine函數,
//進入到下一個yield語句。
var coFirst = coroutine(function* () {
    var input;
    
    //輸入來自yield語句,
    //而且是傳遞給“coFirst()”的參數值。
    input = yield;
    console.log('step1', input);
    input = yield; 
    console.log('step3', input);
});


//與上面創建的協程一樣工作... 
var coSecond = coroutine(function* () {
    var input;
    input = yield;
    console.log('step2', input);
    input = yield;
    console.log('step4', input);
});

//這兩個協程彼此合作,按預期輸出。
//我們可以看到對每個協程的第二次調用,
//會找到上一個yield語句暫停的位置。
coFirst('the money');
coSecond('the show');
coFirst('get ready');
coSecond('go');
//→
// step1 the money
// step2 the show
// step3 get ready
// step4 go

當完成某項任務涉及一系列步驟時,我們通常需要標記代碼,臨時值等。協程不需要這些,因爲函數只是暫停,任何本地狀態都保持不變。換句話說,當協程爲我們隱藏這些細節時,沒有必要將併發邏輯與我們的應用程序邏輯交織在一起。

處理DOM事件

我們可以使用協程的其他地方是DOM作爲事件處理程序。這通過將相同的coroutine()函數作爲事件偵聽器添加到多個元素來工作。讓我們回想一下,對這些協程函數的每次調用都與單個生成器進行通信。這意味着我們設置爲處理DOM事件的協程將作爲流傳入。這幾乎就像我們在迭代這些事件一樣。

由於這些協程函數使用相同的生成器,因此元素可以使用此技術輕鬆地互相通信。DOM事件的典型方法涉及回調函數,這些函數與元素之間共享的某種中心源進行通信並維護狀態。使用協程,元素通信的狀態隱含在我們的函數代碼中。讓我們在DOM事件處理程序的上下文中使用我們的協程包裝器:

//與mousemove一起使用的協程函數
var onMouseMove = coroutine(function* () {
    var e;

    //這個循環無限地執行。
    //事件對象通過yield語句傳入。
    while (true) {
        e = yield;
        
        //如果元素被禁用,則不執行任何操作。
        //否則,輸出記錄消息。
        if (e.target.disabled) {
            continue;
        }
        console.log('mousemove', e.target.textContent);
    }
});

//與點擊事件一起使用的協程函數。
var onClick = coroutine(function* () {
    //保存對我們兩個按鈕的引用。
    //協程是有狀態的,它們永遠都是可用的。
    var first = document.querySelector('button:first-of-type');
    var second = document.querySelector('button:last-of-type');
    var e;
    
    while (true) {
        e = yield;
        
        //按鈕被單擊後禁用。
        e.target.disabled = true;
        
        //如果單擊了第一個按鈕,
        //則切換第二個按鈕的狀態。
        if(Object.is(e.target, first)) {
            second.disabled = !second.disabled;
            continue;
        }

        //如果單擊了第二個按鈕,
        //則切換第一個按鈕的狀態。
        if(Object.is(e.target, second)) {
            first.disabled = !first.disabled;
        }
    }
});

//設置事件處理程序 - 我們的協程函數。
for (let document of document.querySelectorAll('button')) {
    button.addEventListener('mousemove', onMouseMove);
    button.addEventListener('click', onClick);
}

處理promise的值

在上一節中,我們瞭解瞭如何使用coroutine()函數來處理DOM事件。我們使用相同的coroutine()函數,將事件視爲數據流,而不是隨意添加響應DOM事件的回調函數。DOM事件處理程序更容易相互協作,因爲它們共享相同的生成器上下文。

我們可以將相同的方法應用於promise的then()回調,它的工作方式與DOM協程方法類似。我們將協程傳遞給then(),而不是傳遞普通函數。當promise解析時,協程將進到下一個yield語句以及已解析的值。我們來看看下面的代碼:

//一系列promise的數組。
var promises = [];

//我們的完成回調是一個協程。
//這意味着每次調用它時,都會有新的promise完成值顯示在這裏。
var onFulfilled = coroutine(function* () {
    var data;

    //當他們返回時繼續處理已完成的promise值
    while (true) {
        data = yield;
        console.log('data', data);
    }
});

//在1到5秒之間,創建5個隨機解析的promises。
for (let i = 0; i < 5; i++) {
    promises.push(new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(i);
        }, Math.floor(Math.random() * (5000 - 1000)) + 1000);
    }));
}

//將我們的完成協程附加爲“then()”回調。
for (let promise of promises) {
    promise.then(onFulfilled);
}

這非常有用,因爲它提供了靜態promise方法所不具備的功能。該Promise.all()方法迫使我們等待所有的promise完成,在處理返回promise之前。但是,在已解析的promise值彼此不相關的情況下,我們可以簡單地迭代它們,在它們按任何順序解析時進行響應。

我們可以通過將原生函數附加到then()作爲回調來類似的實現,但是,當它們完成時,我們就不會有共享上下文給promise值來處理。另一種方法是我們可以通過將promises與協程相結合來採用聲明一系列協程響應不同的協程,具體取決於它們響應的數據類型。這些協程將在整個應用程序期間繼續存在,並在創建時傳遞給promise。

小結

這一章向你介紹了生成器的概念,ES6的新結構,這讓我們能夠實現惰性計算。生成器幫助我們實現了併發原則,讓我們能夠避免計算和內存分配的浪費。有一些與生成器關聯的新語法形式。首先,是生成器函數,它總是返回一個生成器實例。這些聲明不同於普通函數。這些函數是用於生成值,依賴於yield關鍵字。

然後,我們探索了更高級的生成器和惰性計算話題,包括傳遞到其他生成器,實現map/reduce工具函數,以及將數據傳遞到生成器。在本章的結尾,我們看了如何使用生成器來實現協程。

在下一章中,我們將介紹Web workers - 第一次看看如何在瀏覽器環境中使用併發。

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