第三章:組織 1

特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS

編寫JS代碼是一回事兒,而合理地組織它是另一回事兒。利用常見的組織和重用模式在很大程度上改善了你代碼的可讀性和可理解性。記住:代碼在與其他開發者交流上起的作用,與在給計算機喂指令上起的作用同樣重要。

ES6擁有幾種重要的特性可以顯著改善這些模式,包括:迭代器,generator,模塊,和類。

迭代器

迭代器(iterator) 是一種結構化的模式,用于從一個信息源中以一次一個的方式抽取信息。這種模式在程序設計中存在很久了。而且不可否認的是,不知從什么時候起JS開發者們就已經特別地設計并實現了迭代器,所以它根本不是什么新的話題。

ES6所做的是,為迭代器引入了一個隱含的標準化接口。許多在JavaScript中內建的數據結構現在都會暴露一個實現了這個標準的迭代器。而且你也可以構建自己的遵循同樣標準的迭代器,來使互用性最大化。

迭代器是一種消費數據的方法,它是組織有順序的,相繼的,基于抽取的。

舉個例子,你可能實現一個工具,它在每次被請求時產生一個新的唯一的標識符。或者你可能循環一個固定的列表以輪流的方式產生一系列無限多的值。或者你可以在一個數據庫查詢的結果上添加一個迭代器來一次抽取一行結果。

雖然在JS中它們不經常以這樣的方式被使用,但是迭代器還可以認為是每次控制行為中的一個步驟。這會在考慮generator時得到相當清楚的展示(參見本章稍后的“Generator”),雖然你當然可以不使用generator而做同樣的事。

接口

在本書寫作的時候,ES6的25.1.1.2部分 (https://people.mozilla.org/~jorendorff/es6-draft.html#sec-iterator-interface) 詳述了Iterator接口,它有如下的要求:

Iterator [必須]
    next() {method}: 取得下一個IteratorResult

有兩個可選成員,有些迭代器用它們進行了擴展:

Iterator [可選]
    return() {method}: 停止迭代并返回IteratorResult
    throw() {method}: 通知錯誤并返回IteratorResult

接口IteratorResult被規定為:

IteratorResult
    value {property}: 當前的迭代值或最終的返回值
        (如果它的值為`undefined`,是可選的)
    done {property}: 布爾值,指示完成的狀態

注意: 我稱這些接口是隱含的,不是因為它們沒有在語言規范中被明確地被說出來 —— 它們被說出來了!—— 而是因為它們沒有作為可以直接訪問的對象暴露給代碼。在ES6中,JavaScript不支持任何“接口”的概念,所以在你自己的代碼中遵循它們純粹是慣例上的。但是,不論JS在何處需要一個迭代器 —— 例如在一個for..of循環中 —— 你提供的東西必須遵循這些接口,否則代碼就會失敗。

還有一個Iterable接口,它描述了一定能夠產生迭代器的對象:

Iterable
    @@iterator() {method}: 產生一個迭代器

如果你回憶一下第二章的“內建Symbol”,@@iterator是一種特殊的內建symbol,表示可以為對象產生迭代器的方法。

IteratorResult

IteratorResult接口規定從任何迭代器操作的返回值都是這樣形式的對象:

{ value: .. , done: true / false }

內建迭代器將總是返回這種形式的值,當然,更多的屬性也允許出現在這個返回值中,如果有必要的話。

例如,一個自定義的迭代器可能會在結果對象中加入額外的元數據(比如,數據是從哪里來的,取得它花了多久,緩存過期的時間長度,下次請求的恰當頻率,等等)。

注意: 從技術上講,在值為undefined的情況下,value是可選的,它將會被認為是不存在或者是沒有被設置。因為不管它是表示的就是這個值還是完全不存在,訪問res.value都將會產生undefined,所以這個屬性的存在/不存在更大程度上是一個實現或者優化(或兩者)的細節,而非一個功能上的問題。

next()迭代

讓我們來看一個數組,它是一個可迭代對象,可以生成一個迭代器來消費它的值:

var arr = [1,2,3];

var it = arr[Symbol.iterator]();

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

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

每一次定位在Symbol.iterator上的方法在值arr上被調用時,它都將生成一個全新的迭代器。大多數的數據結構都會這么做,包括所有內建在JS中的數據結構。

然而,像事件隊列這樣的結構也許只能生成一個單獨的迭代器(單例模式)。或者某種結構可能在同一時間內只允許存在一個唯一的迭代器,要求當前的迭代器必須完成,才能創建一個新的。

前一個代碼段中的it迭代器不會再你得到值3時報告done: true。你必須再次調用next(),實質上越過數組末尾的值,才能得到完成信號done: true。在這一節稍后會清楚地講解這種設計方式的原因,但是它通常被認為是一種最佳實踐。

基本類型的字符串值也默認地是可迭代對象:

var greeting = "hello world";

var it = greeting[Symbol.iterator]();

it.next();      // { value: "h", done: false }
it.next();      // { value: "e", done: false }
..

注意: 從技術上講,這個基本類型值本身不是可迭代對象,但多虧了“封箱”,"hello world"被強制轉換為它的String對象包裝形式, 才是一個可迭代對象。更多信息參見本系列的 類型與文法

ES6還包括幾種新的數據結構,稱為集合(參見第五章)。這些集合不僅本身就是可迭代對象,而且它們還提供API方法來生成一個迭代器,例如:

var m = new Map();
m.set( "foo", 42 );
m.set( { cool: true }, "hello world" );

var it1 = m[Symbol.iterator]();
var it2 = m.entries();

it1.next();     // { value: [ "foo", 42 ], done: false }
it2.next();     // { value: [ "foo", 42 ], done: false }
..

一個迭代器的next(..)方法能夠可選地接受一個或多個參數。大多數內建的迭代器不會實施這種能力,雖然一個generator的迭代器絕對會這么做(參見本章稍后的“Generator”)。

根據一般的慣例,包括所有的內建迭代器,在一個已經被耗盡的迭代器上調用next(..)不是一個錯誤,而是簡單地持續返回結果{ value: undefined, done: true }

可選的return(..)throw(..)

在迭代器接口上的可選方法 —— return(..)throw(..) —— 在大多數內建的迭代器上都沒有被實現。但是,它們在generator的上下文環境中絕對有某些含義,所以更具體的信息可以參看“Generator”。

return(..)被定義為向一個迭代器發送一個信號,告知它消費者代碼已經完成而且不會再從它那里抽取更多的值。這個信號可以用于通知生產者(應答next(..)調用的迭代器)去實施一些可能的清理作業,比如釋放/關閉網絡,數據庫,或者文件引用資源。

如果一個迭代器擁有return(..),而且發生了可以自動被解釋為非正常或者提前終止消費迭代器的任何情況,return(..)就將會被自動調用。你也可以手動調用return(..)

return(..)將會像next(..)一樣返回一個IteratorResult對象。一般來說,你向return(..)發送的可選值將會在這個IteratorResult中作為value發送回來,雖然在一些微妙的情況下這可能不成立。

throw(..)被用于向一個迭代器發送一個異常/錯誤信號,與return(..)隱含的完成信號相比,它可能會被迭代器用于不同的目的。它不一定像return(..)一樣暗示著迭代器的完全停止。

例如,在generator迭代器中,throw(..)實際上會將一個被拋出的異常注射到generator暫停的執行環境中,這個異常可以用try..catch捕獲。一個未捕獲的throw(..)異常將會導致generator的迭代器異常中止。

注意: 根據一般的慣例,在return(..)throw(..)被調用之后,一個迭代器就不應該在產生任何結果了。

迭代器循環

正如我們在第二章的“for..of”一節中講解的,ES6的for..of循環可以直接消費一個規范的可迭代對象。

如果一個迭代器也是一個可迭代對象,那么它就可以直接與for..of循環一起使用。通過給予迭代器一個簡單地返回它自身的Symbol.iterator方法,你就可以使它成為一個可迭代對象:

var it = {
    // 使迭代器`it`成為一個可迭代對象
    [Symbol.iterator]() { return this; },

    next() { .. },
    ..
};

it[Symbol.iterator]() === it;       // true

現在我們就可以用一個for..of循環來消費迭代器it了:

for (var v of it) {
    console.log( v );
}

為了完全理解這樣的循環如何工作,回憶下第二章中的for..of循環的for等價物:

for (var v, res; (res = it.next()) && !res.done; ) {
    v = res.value;
    console.log( v );
}

如果你仔細觀察,你會發現it.next()是在每次迭代之前被調用的,然后res.done才被查詢。如果res.donetrue,那么這個表達式將會求值為false于是這次迭代不會發生。

回憶一下之前我們建議說,迭代器一般不應與最終預期的值一起返回done: true。現在你知道為什么了。

如果一個迭代器返回了{ done: true, value: 42 }for..of循環將完全扔掉值42。因此,假定你的迭代器可能會被for..of循環或它的for等價物這樣的模式消費的話,你可能應當等到你已經返回了所有相關的迭代值之后才返回done: true來表示完成。

警告: 當然,你可以有意地將你的迭代器設計為將某些相關的valuedone: true同時返回。但除非你將此情況在文檔中記錄下來,否則不要這么做,因為這樣會隱含地強制你的迭代器消費者使用一種,與我們剛才描述的for..of或它的手動等價物不同的模式來進行迭代。

自定義迭代器

除了標準的內建迭代器,你還可以制造你自己的迭代器!所有使它們可以與ES6消費設施(例如,for..of循環和...操作符)進行互動的代價就是遵循恰當的接口。

讓我們試著構建一個迭代器,它能夠以斐波那契(Fibonacci)數列的形式產生無限多的數字序列:

var Fib = {
    [Symbol.iterator]() {
        var n1 = 1, n2 = 1;

        return {
            // 使迭代器成為一個可迭代對象
            [Symbol.iterator]() { return this; },

            next() {
                var current = n2;
                n2 = n1;
                n1 = n1 + current;
                return { value: current, done: false };
            },

            return(v) {
                console.log(
                    "Fibonacci sequence abandoned."
                );
                return { value: v, done: true };
            }
        };
    }
};

for (var v of Fib) {
    console.log( v );

    if (v > 50) break;
}
// 1 1 2 3 5 8 13 21 34 55
// Fibonacci sequence abandoned.

警告: 如果我們沒有插入break條件,這個for..of循環將會永遠運行下去,這回破壞你的程序,因此可能不是我們想要的!

方法Fib[Symbol.iterator]()在被調用時返回帶有next()return(..)方法的迭代器對象。它的狀態通過變量n1n2維護在閉包中。

接下來讓我們考慮一個迭代器,它被設計為執行一系列(也叫隊列)動作,一次一個:

var tasks = {
    [Symbol.iterator]() {
        var steps = this.actions.slice();

        return {
            // 使迭代器成為一個可迭代對象
            [Symbol.iterator]() { return this; },

            next(...args) {
                if (steps.length > 0) {
                    let res = steps.shift()( ...args );
                    return { value: res, done: false };
                }
                else {
                    return { done: true }
                }
            },

            return(v) {
                steps.length = 0;
                return { value: v, done: true };
            }
        };
    },
    actions: []
};

tasks上的迭代器步過在數組屬性actions中找到的函數,并每次執行它們中的一個,并傳入你傳遞給next(..)的任何參數值,并在標準的IteratorResult對象中向你返回任何它返回的東西。

這是我們如何使用這個tasks隊列:

tasks.actions.push(
    function step1(x){
        console.log( "step 1:", x );
        return x * 2;
    },
    function step2(x,y){
        console.log( "step 2:", x, y );
        return x + (y * 2);
    },
    function step3(x,y,z){
        console.log( "step 3:", x, y, z );
        return (x * y) + z;
    }
);

var it = tasks[Symbol.iterator]();

it.next( 10 );          // step 1: 10
                        // { value:   20, done: false }

it.next( 20, 50 );      // step 2: 20 50
                        // { value:  120, done: false }

it.next( 20, 50, 120 ); // step 3: 20 50 120
                        // { value: 1120, done: false }

it.next();              // { done: true }

這種特別的用法證實了迭代器可以是一種具有組織功能的模式,不僅僅是數據。這也聯系著我們在下一節關于generator將要看到的東西。

你甚至可以更有創意一些,在一塊數據上定義一個表示元操作的迭代器。例如,我們可以為默認從0開始遞增至(或遞減至,對于負數來說)指定數字的一組數字定義一個迭代器。

考慮如下代碼:

if (!Number.prototype[Symbol.iterator]) {
    Object.defineProperty(
        Number.prototype,
        Symbol.iterator,
        {
            writable: true,
            configurable: true,
            enumerable: false,
            value: function iterator(){
                var i, inc, done = false, top = +this;

                // 正向迭代還是負向迭代?
                inc = 1 * (top < 0 ? -1 : 1);

                return {
                    // 使迭代器本身成為一個可迭代對象!
                    [Symbol.iterator](){ return this; },

                    next() {
                        if (!done) {
                            // 最初的迭代總是0
                            if (i == null) {
                                i = 0;
                            }
                            // 正向迭代
                            else if (top >= 0) {
                                i = Math.min(top,i + inc);
                            }
                            // 負向迭代
                            else {
                                i = Math.max(top,i + inc);
                            }

                            // 這次迭代之后就完了?
                            if (i == top) done = true;

                            return { value: i, done: false };
                        }
                        else {
                            return { done: true };
                        }
                    }
                };
            }
        }
    );
}

現在,這種創意給了我們什么技巧?

for (var i of 3) {
    console.log( i );
}
// 0 1 2 3

[...-3];                // [0,-1,-2,-3]

這是一些有趣的技巧,雖然其實際用途有些值得商榷。但是再一次,有人可能想知道為什么ES6沒有提供如此微小但討喜的特性呢?

如果我連這樣的提醒都沒給過你,那就是我的疏忽:像我在前面的代碼段中做的那樣擴展原生原型,是一件你需要小心并了解潛在的危害后才應該做的事情。

在這樣的情況下,你與其他代碼或者未來的JS特性發生沖突的可能性非常低。但是要小心微小的可能性。并在文檔中為后人詳細記錄下你在做什么。

注意: 如果你想知道更多細節,我在這篇文章(http://blog.getify.com/iterating-es6-numbers/) 中詳細論述了這種特別的技術。而且這段評論(http://blog.getify.com/iterating-es6-numbers/comment-page-1/#comment-535294)甚至為制造一個字符串字符范圍提出了一個相似的技巧。

消費迭代器

我們已經看到了使用for..of循環來一個元素一個元素地消費一個迭代器。但是還有一些其他的ES6結構可以消費迭代器。

讓我們考慮一下附著這個數組上的迭代器(雖然任何我們選擇的迭代器都將擁有如下的行為):

var a = [1,2,3,4,5];

擴散操作符...將完全耗盡一個迭代器。考慮如下代碼:

function foo(x,y,z,w,p) {
    console.log( x + y + z + w + p );
}

foo( ...a );            // 15

...還可以在一個數組內部擴散一個迭代器:

var b = [ 0, ...a, 6 ];
b;                      // [0,1,2,3,4,5,6]

數組解構(參見第二章的“解構”)可以部分地或者完全地(如果與一個...剩余/收集操作符一起使用)消費一個迭代器:

var it = a[Symbol.iterator]();

var [x,y] = it;         // 僅從`it`中取前兩個元素
var [z, ...w] = it;     // 取第三個,然后一次取得剩下所有的

// `it`被完全耗盡了嗎?是的
it.next();              // { value: undefined, done: true }

x;                      // 1
y;                      // 2
z;                      // 3
w;                      // [4,5]

Generator

所有的函數都會運行至完成,對吧?換句話說,一旦一個函數開始運行,在它完成之前沒有任何東西能夠打斷它。

至少對于到目前為止的JavaScript的整個歷史來說是這樣的。在ES6中,引入了一個有些異乎尋常的新形式的函數,稱為generator。一個generator可以在運行期間暫停它自己,還可以立即或者稍后繼續運行。所以顯然它沒有普通函數那樣的運行至完成的保證。

另外,在運行期間的每次暫停/繼續輪回都是一個雙向消息傳遞的好機會,generator可以在這里返回一個值,而使它繼續的控制端代碼可以發回一個值。

就像前一節中的迭代器一樣,有種方式可以考慮generator是什么,或者說它對什么最有用。對此沒有一個正確的答案,但我們將試著從幾個角度考慮。

注意: 關于generator的更多信息參見本系列的 異步與性能,還可以參見本書的第四章。

語法

generator函數使用這種新語法聲明:

function *foo() {
    // ..
}

*的位置在功能上無關緊要。同樣的聲明還可以寫做以下的任意一種:

function *foo()  { .. }
function* foo()  { .. }
function * foo() { .. }
function*foo()   { .. }
..

這里 唯一 的區別就是風格的偏好。大多數其他的文獻似乎喜歡function* foo(..) { .. }。我喜歡function *foo(..) { .. },所以這就是我將在本書剩余部分中表示它們的方法。

我這樣做的理由實質上純粹是為了教學。在這本書中,當我引用一個generator函數時,我將使用*foo(..),與普通函數的foo(..)相對。我發現*foo(..)function *foo(..) { .. }*的位置更加吻合。

另外,就像我們在第二章的簡約方法中看到的,在對象字面量中有一種簡約generator形式:

var a = {
    *foo() { .. }
};

我要說在簡約generator中,*foo() { .. }要比* foo() { .. }更自然。這進一步表明了為何使用*foo()匹配一致性。

一致性使理解與學習更輕松。

執行一個Generator

雖然一個generator使用*進行聲明,但是你依然可以像一個普通函數那樣執行它:

foo();

你依然可以傳給它參數值,就像:

function *foo(x,y) {
    // ..
}

foo( 5, 10 );

主要區別在于,執行一個generator,比如foo(5,10),并不實際運行generator中的代碼。取而代之的是,它生成一個迭代器來控制generator執行它的代碼。

我們將在稍后的“迭代器控制”中回到這個話題,但是簡要地說:

function *foo() {
    // ..
}

var it = foo();

// 要開始/推進`*foo()`,調用
// `it.next(..)`

yield

Generator還有一個你可以在它們內部使用的新關鍵字,用來表示暫停點:yield。考慮如下代碼:

function *foo() {
    var x = 10;
    var y = 20;

    yield;

    var z = x + y;
}

在這個*foo()generator中,前兩行的操作將會在開始時運行,然后yield將會暫停這個generator。如果這個generator被繼續,*foo()的最后一行將運行。在一個generator中yield可以出現任意多次(或者,在技術上講,根本不出現!)。

你甚至可以在一個循環內部放置yield,它可以表示一個重復的暫停點。事實上,一個永不完成的循環就意味著一個永不完成的generator,這是完全合法的,而且有時候完全是你需要的。

yield不只是一個暫停點。它是在暫停generator時發送出一個值的表達式。這里是一個位于generator中的while..true循環,它每次迭代時yield出一個新的隨機數:

function *foo() {
    while (true) {
        yield Math.random();
    }
}

yield ..表達式不僅發送一個值 —— 不帶值的yieldyield undefined相同 —— 它還接收(也就是,被替換為)最終的繼續值。考慮如下代碼:

function *foo() {
    var x = yield 10;
    console.log( x );
}

這個generator在暫停它自己時將首先yield出值10。當你繼續這個generator時 —— 使用我們先前提到的it.next(..) —— 無論你使用什么值繼續它,這個值都將替換/完成整個表達式yield 10,這意味著這個值將被賦值給變量x

一個yield..表達式可以出現在任意普通表達式可能出現的地方。例如:

function *foo() {
    var arr = [ yield 1, yield 2, yield 3 ];
    console.log( arr, yield 4 );
}

這里的*foo()有四個yield ..表達式。其中每個yield都會導致generator暫停以等待一個繼續值,這個繼續值稍后被用于各個表達式環境中。

yield在技術上講不是一個操作符,雖然像yield 1這樣使用時看起來確實很像。因為yield可以像var x = yield這樣完全通過自己被使用,所以將它認為是一個操作符有時令人困惑。

從技術上講,yield ..a = 3這樣的賦值表達式擁有相同的“表達式優先級” —— 概念上和操作符優先級很相似。這意味著yield ..基本上可以出現在任何a = 3可以合法出現的地方。

讓我們展示一下這種對稱性:

var a, b;

a = 3;                  // 合法
b = 2 + a = 3;          // 不合法
b = 2 + (a = 3);        // 合法

yield 3;                // 合法
a = 2 + yield 3;        // 不合法
a = 2 + (yield 3);      // 合法

注意: 如果你好好考慮一下,認為一個yield ..表達式與一個賦值表達式的行為相似在概念上有些道理。當一個被暫停的generator被繼續時,它就以一種與被這個繼續值“賦值”區別不大的方式,被這個值完成/替換。

要點:如果你需要yield ..出現在a = 3這樣的賦值本不被允許出現的位置,那么它就需要被包在一個( )中。

因為yield關鍵字的優先級很低,幾乎任何出現在yield ..之后的表達式都會在被yield發送之前首先被計算。只有擴散操作符...和逗號操作符,擁有更低的優先級,這意味著他們會在yield已經被求值之后才會被處理。

所以正如帶有多個操作符的普通語句一樣,存在另一個可能需要( )來覆蓋(提升)yield的低優先級的情況,就像這些表達式之間的區別:

yield 2 + 3;            // 與`yield (2 + 3)`相同

(yield 2) + 3;          // 首先`yield 2`,然后`+ 3`

=賦值一樣,yield也是“右結合性”的,這意味著多個接連出現的yield表達式被視為從右到左被( .. )分組。所以,yield yield yield 3將被視為yield (yield (yield 3))。像((yield) yield) yield 3這樣的“左結合性”解釋沒有意義。

和其他操作符一樣,yield與其他操作符或yield組合時為了使你的意圖沒有歧義,使用( .. )分組是一個好主意,即使這不是嚴格要求的。

注意: 更多關于操作符優先級和結合性的信息,參見本系列的 類型與文法

yield *

*使一個function聲明成為一個function *generator聲明的方式一樣,一個*使yield成為一個機制非常不同的yield *,稱為 yield委托。從文法上講,yield *..的行為與yield ..相同,就像在前一節討論過的那樣。

yield * ..需要一個可迭代對象;然后它調用這個可迭代對象的迭代器,并將它自己的宿主generator的控制權委托給那個迭代器,直到它被耗盡。考慮如下代碼:

function *foo() {
    yield *[1,2,3];
}

注意: 與generator聲明中*的位置(早先討論過)一樣,在yield *表達式中的*的位置在風格上由你來決定。大多數其他文獻偏好yield* ..,但是我喜歡yield *..,理由和我們已經討論過的相同。

[1,2,3]產生一個將會步過它的值的迭代器,所以generator*foo()將會在被消費時產生這些值。另一種說明這種行為的方式是,yield委托到了另一個generator:

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

function *bar() {
    yield *foo();
}

*bar()調用*foo()產生的迭代器通過yield *受到委托,意味著無論*foo()產生什么值都會被*bar()產生。

yield ..中表達式的完成值來自于使用it.next(..)繼續generator,而yield *..表達式的完成值來自于受到委托的迭代器的返回值(如果有的話)。

內建的迭代器一般沒有返回值,正如我們在本章早先的“迭代器循環”一節的末尾講過的。但是如果你定義你自己的迭代器(或者generator),你就可以將它設計為return一個值,yield *..將會捕獲它:

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

function *bar() {
    var x = yield *foo();
    console.log( "x:", x );
}

for (var v of bar()) {
    console.log( v );
}
// 1 2 3
// x: 4

雖然值12,和3*foo()中被yield出來,然后從*bar()中被yield出來,但是從*foo()中返回的值4是表達式yield *foo()的完成值,然后它被賦值給x

因為yield *可以調用另一個generator(通過委托到它的迭代器的方式),它還可以通過調用自己來實施某種generator遞歸:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

foo( 1 );

取得foo(1)的結果并調用迭代器的next()來使它運行它的遞歸步驟,結果將是24。第一次*foo()運行時x擁有值1,它是x < 3x + 1被遞歸地傳遞到*foo(..),所以之后的x2。再一次遞歸調用導致x3

現在,因為x < 3失敗了,遞歸停止,而且return 3 * 26給回前一個調用的yeild *..表達式,它被賦值給x。另一個return 6 * 2返回12給前一個調用的x。最終12 * 2,即24,從generator*foo(..)運行的完成中被返回。

迭代器控制

早先,我們簡要地介紹了generator是由迭代器控制的概念。現在讓我們完整地深入這個話題。

回憶一下前一節的遞歸*for(..)。這是我們如何運行它:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

var it = foo( 1 );
it.next();              // { value: 24, done: true }

在這種情況下,generator并沒有真正暫停過,因為這里沒有yield ..表達式。而yield *只是通過遞歸調用保持當前的迭代步驟繼續運行下去。所以,僅僅對迭代器的next()函數進行一次調用就完全地運行了generator。

現在讓我們考慮一個有多個步驟并且因此有多個產生值的generator:

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

我們已經知道我們可以是使用一個for..of循環來消費一個迭代器,即便它是一個附著在*foo()這樣的generator上:

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

注意: for..of循環需要一個可迭代對象。一個generator函數引用(比如foo)本身不是一個可迭代對象;你必須使用foo()來執行它以得到迭代器(它也是一個可迭代對象,正如我們在本章早先講解過的)。理論上你可以使用一個實質上僅僅執行return this()Symbol.iterator函數來擴展GeneratorPrototype(所有generator函數的原型)。這將使foo引用本身成為一個可迭代對象,也就意味著for (var v of foo) { .. }(注意在foo上沒有())將可以工作。

讓我們手動迭代這個generator:

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

var it = foo();

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

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

如果你仔細觀察,這里有三個yield語句和四個next()調用。這可能看起來像是一個奇怪的不匹配。事實上,假定所有的東西都被求值并且generator完全運行至完成的話,next()調用將總是比yield表達式多一個。

但是如果你相反的角度觀察(從里向外而不是從外向里),yieldnext()之間的匹配就顯得更有道理。

回憶一下,yield ..表達式將被你用于繼續generator的值完成。這意味著你傳遞給next(..)的參數值將完成任何當前暫停中等待完成的yield ..表達式。

讓我們這樣展示一下這種視角:

function *foo() {
    var x = yield 1;
    var y = yield 2;
    var z = yield 3;
    console.log( x, y, z );
}

在這個代碼段中,每個yield ..都送出一個值(123),但更直接的是,它暫停了generator來等待一個值。換句話說,它就像在問這樣一個問題,“我應當在這里用什么值?我會在這里等你告訴我。”

現在,這是我們如何控制*foo()來啟動它:

var it = foo();

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

這第一個next()調用從generator初始的暫停狀態啟動了它,并運行至第一個yield。在你調用第一個next()的那一刻,并沒有yield ..表達式等待完成。如果你給第一個next()調用傳遞一個值,目前它會被扔掉,因為沒有yield等著接受這樣的一個值。

注意: 一個“ES6之后”時間表中的早期提案 允許你在generator內部通過一個分離的元屬性(見第七章)來訪問一個被傳入初始next(..)調用的值。

現在,讓我們回答那個未解的問題,“我應當給x賦什么值?” 我們將通過給 下一個 next(..)調用發送一個值來回答:

it.next( "foo" );       // { value: 2, done: false }

現在,x將擁有值"foo",但我們也問了一個新的問題,“我應當給y賦什么值?”

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

答案給出了,另一個問題被提出了。最終答案:

it.next( "baz" );       // "foo" "bar" "baz"
                        // { value: undefined, done: true }

現在,每一個yield ..的“問題”是如何被 下一個 next(..)調用回答的,所以我們觀察到的那個“額外的”next()調用總是使一切開始的那一個。

讓我們把這些步驟放在一起:

var it = foo();

// 啟動generator
it.next();              // { value: 1, done: false }

// 回答第一個問題
it.next( "foo" );       // { value: 2, done: false }

// 回答第二個問題
it.next( "bar" );       // { value: 3, done: false }

// 回答第三個問題
it.next( "baz" );       // "foo" "bar" "baz"
                        // { value: undefined, done: true }

在生成器的每次迭代都簡單地為消費者生成一個值的情況下,你可認為一個generator是一個值的生成器。

但是在更一般的意義上,也許將generator認為是一個受控制的,累進的代碼執行過程更恰當,與早先“自定義迭代器”一節中的tasks隊列的例子非常相像。

注意: 這種視角正是我們將如何在第四章中重溫generator的動力。特別是,next(..)沒有理由一定要在前一個next(..)完成之后立即被調用。雖然generator的內部執行環境被暫停了,程序的其他部分仍然沒有被阻塞,這包括控制generator什么時候被繼續的異步動作能力。

提前完成

正如我們在本章早先講過的,連接到一個generator的迭代器支持可選的return(..)throw(..)方法。它們倆都有立即中止一個暫停的的generator的效果。

考慮如下代碼:

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

var it = foo();

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

it.return( 42 );        // { value: 42, done: true }

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

return(x)有點像強制一個return x就在那個時刻被處理,這樣你就立即得到這個指定的值。一旦一個generator完成,無論是正常地還是像展示的那樣提前地,它就不再處理任何代碼或返回任何值了。

return(..)除了可以手動調用,它還在迭代的最后被任何ES6中消費迭代器的結構自動調用,比如for..of循環和...擴散操作符。

這種能力的目的是,在控制端的代碼不再繼續迭代generator時它可以收到通知,這樣它就可能做一些清理工作(釋放資源,復位狀態,等等)。與普通函數的清理模式完全相同,達成這個目的的主要方法是使用一個finally子句:

function *foo() {
    try {
        yield 1;
        yield 2;
        yield 3;
    }
    finally {
        console.log( "cleanup!" );
    }
}

for (var v of foo()) {
    console.log( v );
}
// 1 2 3
// cleanup!

var it = foo();

it.next();              // { value: 1, done: false }
it.return( 42 );        // cleanup!
                        // { value: 42, done: true }

警告: 不要把yield語句放在finally子句內部!它是有效和合法的,但這確實是一個可怕的主意。它在某種意義上推遲了return(..)調用的完成,因為在finally子句中的任何yield ..表達式都被遵循來暫停和發送消息;你不會像期望的那樣立即得到一個完成的generator。基本上沒有任何好的理由去選擇這種瘋狂的 壞的部分,所以避免這么做!

前一個代碼段除了展示return(..)如何在中止generator的同時觸發finally子句,它還展示了一個generator在每次被調用時都產生一個全新的迭代器。事實上,你可以并發地使用連接到相同generator的多個迭代器:

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

var it1 = foo();
it1.next();             // { value: 1, done: false }
it1.next();             // { value: 2, done: false }

var it2 = foo();
it2.next();             // { value: 1, done: false }

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

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

it2.next();             // { value: undefined, done: true }
it1.next();             // { value: undefined, done: true }

提前中止

你可以調用throw(..)來代替return(..)調用。就像return(x)實質上在generator當前的暫停點上注入了一個return x一樣,調用throw(x)實質上就像在暫停點上注入了一個throw x

除了處理異常的行為(我們在下一節講解這對try子句意味著什么),throw(..)產生相同的提前完成 —— 在generator當前的暫停點中止它的運行。例如:

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

var it = foo();

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

try {
    it.throw( "Oops!" );
}
catch (err) {
    console.log( err ); // Exception: Oops!
}

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

因為throw(..)基本上注入了一個throw ..來替換generator的yield 1這一行,而且沒有東西處理這個異常,它立即傳播回外面的調用端代碼,調用端代碼使用了一個try..catch來處理了它。

return(..)不同的是,迭代器的throw(..)方法絕不會被自動調用。

當然,雖然沒有在前面的代碼段中展示,但如果當你調用throw(..)時有一個try..finally子句等在generator內部的話,這個finally子句將會在異常被傳播回調用端代碼之前有機會運行。

錯誤處理

正如我們已經得到的提示,generator中的錯誤處理可以使用try..catch表達,它在上行和下行兩個方向都可以工作。

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "Hello!";
}

var it = foo();

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

try {
    it.throw( "Hi!" );  // Hi!
                        // { value: 2, done: false }
    it.next();

    console.log( "never gets here" );
}
catch (err) {
    console.log( err ); // Hello!
}

錯誤也可以通過yield *委托在兩個方向上傳播:

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "foo: e2";
}

function *bar() {
    try {
        yield *foo();

        console.log( "never gets here" );
    }
    catch (err) {
        console.log( err );
    }
}

var it = bar();

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

    it.throw( "e1" );   // e1
                        // { value: 2, done: false }

    it.next();          // foo: e2
                        // { value: undefined, done: true }
}
catch (err) {
    console.log( "never gets here" );
}

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

*foo()調用yield 1時,值1原封不動地穿過了*bar(),就像我們已經看到過的那樣。

但這個代碼段最有趣的部分是,當*foo()調用throw "foo: e2"時,這個錯誤傳播到了*bar()并立即被*bar()try..catch塊兒捕獲。錯誤沒有像值1那樣穿過*bar()

然后*bar()catcherr普通地輸出("foo: e2")之后*bar()就正常結束了,這就是為什么迭代器結果{ value: undefined, done: true }it.next()中返回。

如果*bar()沒有用try..catch環繞著yield *..表達式,那么錯誤將理所當然地一直傳播出來,而且在它傳播的路徑上依然會完成(中止)*bar()

轉譯一個Generator

有可能在ES6之前的環境中表達generator的能力嗎?事實上是可以的,而且有好幾種了不起的工具在這么做,包括最著名的Facebook的Regenerator工具 (https://facebook.github.io/regenerator/)。

但為了更好地理解generator,讓我們試著手動轉換一下。基本上講,我們將制造一個簡單的基于閉包的狀態機。

我們將使原本的generator非常簡單:

function *foo() {
    var x = yield 42;
    console.log( x );
}

開始之前,我們將需要一個我們能夠執行的稱為foo()的函數,它需要返回一個迭代器:

function foo() {
    // ..

    return {
        next: function(v) {
            // ..
        }

        // 我們將省略`return(..)`和`throw(..)`
    };
}

現在,我們需要一些內部變量來持續跟蹤我們的“generator”的邏輯走到了哪一個步驟。我們稱它為state。我們將有三種狀態:起始狀態的0,等待完成yield表達式的1,和generator完成的2

每次next(..)被調用時,我們需要處理下一個步驟,然后遞增state。為了方便,我們將每個步驟放在一個switch語句的case子句中,并且我們將它放在一個next(..)可以調用的稱為nextState(..)的內部函數中。另外,因為x是一個橫跨整個“generator”作用域的變量,所以它需要存活在nextState(..)函數的外部。

這是將它們放在一起(很明顯,為了使概念的展示更清晰,它經過了某些簡化):

function foo() {
    function nextState(v) {
        switch (state) {
            case 0:
                state++;

                // `yield`表達式
                return 42;
            case 1:
                state++;

                // `yield`表達式完成了
                x = v;
                console.log( x );

                // 隱含的`return`
                return undefined;

            // 無需處理狀態`2`
        }
    }

    var state = 0, x;

    return {
        next: function(v) {
            var ret = nextState( v );

            return { value: ret, done: (state == 2) };
        }

        // 我們將省略`return(..)`和`throw(..)`
    };
}

最后,讓我們測試一下我們的前ES6“generator”:

var it = foo();

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

it.next( 10 );          // 10
                        // { value: undefined, done: true }

不賴吧?希望這個練習能在你的腦中鞏固這個概念:generator實際上只是狀態機邏輯的簡單語法。這使它們可以廣泛地應用。

Generator的使用

我們現在非常深入地理解了generator如何工作,那么,它們在什么地方有用?

我們已經看過了兩種主要模式:

  • 生產一系列值: 這種用法可以很簡單(例如,隨機字符串或者遞增的數字),或者它也可以表達更加結構化的數據訪問(例如,迭代一個數據庫查詢結果的所有行)。

    這兩種方式中,我們使用迭代器來控制generator,這樣就可以為每次next(..)調用執行一些邏輯。在數據解構上的普通迭代器只不過生成值而沒有任何控制邏輯。

  • 串行執行的任務隊列: 這種用法經常用來表達一個算法中步驟的流程控制,其中每一步都要求從某些外部數據源取得數據。對每塊兒數據的請求可能會立即滿足,或者可能會異步延遲地滿足。

    從generator內部代碼的角度來看,在yield的地方,同步或異步的細節是完全不透明的。另外,這些細節被有意地抽象出去,如此就不會讓這樣的實現細節把各個步驟間自然的,順序的表達搞得模糊不清。抽象還意味著實現可以被替換/重構,而根本不用碰generator中的代碼。

當根據這些用法觀察generator時,它們的含義要比僅僅是手動狀態機的一種不同或更好的語法多多了。它們是一種用于組織和控制有序地生產與消費數據的強大工具。

模塊

我覺得這樣說并不夸張:在所有的JavaScript代碼組織模式中最重要的就是,而且一直是,模塊。對于我自己來說,而且我認為對廣大典型的技術社區來說,模塊模式驅動著絕大多數代碼。

過去的方式

傳統的模塊模式基于一個外部函數,它帶有內部變量和函數,以及一個被返回的“公有API”。這個“公有API”帶有對內部變量和功能擁有閉包的方法。它經常這樣表達:

function Hello(name) {
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有API
    return {
        greeting: greeting
    };
}

var me = Hello( "Kyle" );
me.greeting();          // Hello Kyle!

這個Hello(..)模塊通過被后續調用可以產生多個實例。有時,一個模塊為了作為一個單例(也就是,只需要一個實例)而只被調用一次,這樣的情況下常見的是一種前面代碼段的變種,使用IIFE:

var me = (function Hello(name){
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有API
    return {
        greeting: greeting
    };
})( "Kyle" );

me.greeting();          // Hello Kyle!

這種模式是經受過檢驗的。它也足夠靈活,以至于在許多不同的場景下可以有大量的各種變化。

其中一種最常見的是異步模塊定義(AMD),另一種是統一模塊定義(UMD)。我們不會在這里涵蓋這些特定的模式和技術,但是它們在網上的許多地方有大量的講解。

向前邁進

在ES6中,我們不再需要依賴外圍函數和閉包來為我們提供模塊支持了。ES6模塊擁有頭等語法上和功能上的支持。

在我們接觸這些具體語法之前,重要的是要理解ES6模塊與你以前曾經用過的模塊比較起來,在概念上的一些相當顯著的不同之處:

  • ES6使用基于文件的模塊,這意味著一個模塊一個文件。目前,沒有標準的方法將多個模塊組合到一個文件中。

    這意味著如果你要直接把ES6模塊加載到一個瀏覽器web應用中的話,你將個別地加載它們,不是像常見的那樣為了性能優化而作為一個單獨文件中的一個巨大的包加載。

    預計同時期到來的HTTP/2將會大幅緩和這種性能上的顧慮,因為它工作在一個持續的套接字連接上,因而可以用并行的,互相交錯的方式非常高效地加載許多小文件。

  • 一個ES6模塊的API是靜態的。這就是說,你在模塊的公有API上靜態地定義所有被導出的頂層內容,而這些內容導出之后不能被修改。

    有些用法習慣于能夠提供動態API定義,它的方法可以根據運行時的條件被增加/刪除/替換。這些用法要么必須改變以適應ES6靜態API,要么它們就不得不將屬性/方法的動態修改限制在一個內層對象中。

  • ES6模塊都是單例。也就是,模塊只有一個維持它狀態的實例。每次你將這個模塊導入到另一個模塊時,你得到的都是一個指向中央實例的引用。如果你想要能夠產生多個模塊實例,你的模塊將需要提供某種工廠來這么做。

  • 你在模塊的公有API上暴露的屬性和方法不是值和引用的普通賦值。它們是在你內部模塊定義中的標識符的實際綁定(幾乎就是指針)。

    在前ES6的模塊中,如果你將一個持有像數字或者字符串這樣基本類型的屬性放在你的共有API中,那么這個屬性是通過值拷貝賦值的,任何對相應內部變量的更新都將是分離的,不會影響在API對象上的共有拷貝。

    在ES6中,導出一個本地私有變量,即便它當前持有一個基本類型的字符串/數字/等等,導出的都是這個變量的一個綁定。如果這個模塊改變了這個變量的值,外部導入的綁定就會解析為那個新的值。

  • 導入一個模塊和靜態地請求它被加載是同一件事情(如果它還沒被加載的話)。如果你在瀏覽器中,這意味著通過網絡的阻塞加載。如果你在服務器中,它是一個通過文件系統的阻塞加載。

    但是,不要對它在性能的影響上驚慌。因為ES6模塊是靜態定義的,導入的請求可以被靜態地掃描,并提前加載,甚至是在你使用這個模塊之前。

    ES6并沒有實際規定或操縱這些加載請求如何工作的機制。有一個模塊加載器的分離概念,它讓每一個宿主環境(瀏覽器,Node.js,等等)為該環境提供合適的默認加載器。一個模塊的導入使用一個字符串值來表示從哪里去取得模塊(URL,文件路徑,等等),但是這個值在你的程序中是不透明的,它僅對加載器自身有意義。

    如果你想要比默認加載器提供的更細致的控制能力,你可以定義你自己的加載器 —— 默認加載器基本上不提供任何控制,它對于你的程序代碼是完全隱藏的。

如你所見,ES6模塊將通過封裝,控制共有API,以及應用依賴導入來服務于所有的代碼組織需求。但是它們用一種非常特別的方式來這樣做,這可能與你已經使用多年的模塊方式十分接近,也肯能差得很遠。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容