ES6 Generator 基礎指南

Jocs發表於2017-08-07

本文翻譯自:The Basics Of ES6 Generators

由於個人能力有限,翻譯中難免有紕漏和錯誤,望不吝指正issue

JavaScript ES6(譯者注:ECMAScript 2015)中最令人興奮的特性之一莫過於Generator函式,它是一種全新的函式型別。它的名字有些奇怪,初見其功能時甚至更會有些陌生。本篇文章旨在解釋其基本工作原理,並幫助你理解為什麼Generator將在未來JS中發揮強大作用。

Generator從執行到完成的工作方式

但我們談論Generator函式時,我們首先應該注意到的是,從“執行到完成”其和普通的函式表現有什麼不同之處。

不論你是否已經意識到,你已經潛意識得認為函式具有一些非常基礎的特性:函式一旦開始執行,那麼在其結束之前,不會執行其他JavaScript程式碼。

例如:

setTimeout(function(){
    console.log("Hello World");
},1);

function foo() {
    // NOTE: don`t ever do crazy long-running loops like this
    for (var i=0; i<=1E10; i++) {
        console.log(i);
    }
}

foo();
// 0..1E10
// "Hello World"

上面的程式碼中,for迴圈會執行相當長的時間,長於1秒鐘,但是在foo()函式執行的過程中,我們帶有console.log(...)的定時器並不能夠中斷foo()函式的執行。因此程式碼被阻塞,定時器被推入事件迴圈的最後,耐心等待foo函式執行完成。

倘若foo()可以被中斷執行?它不會給我們的帶來前所未有的浩劫嗎?

函式可以被中斷對於多執行緒程式設計來說確實是一個挑戰,但是值得慶幸的是,在JavaScript的世界中我們沒必要為此而擔心,因為JS總是單執行緒的(在任何時間只有一條命令/函式被執行)。

注意: Web Workers是JavaScript中實現與JS主執行緒分離的獨立執行緒機制,總的說來,Web Workers是與JS主執行緒平行的另外一個執行緒。在這兒我們並不介紹多執行緒併發的一個原因是,主執行緒和Web Workers執行緒只能夠通過非同步事件進行通訊,因此每個執行緒內部從執行到結束依然遵循一個接一個的事件迴圈機制。

執行-停止-執行

由於ES6Generators的到來,我們擁有了另外一種型別的函式,這種函式可以在執行的過程中暫停一次或多次,在將來的某個時間繼續執行,並且允許在Generator函式暫停的過程中執行其他程式碼。

如果你曾經閱讀過關於併發或者多執行緒程式設計的資料,那你一定熟悉“協程”這一概念,“協程”的意思就是一個程式(就是一個函式)其可以自行選擇終止執行,以便可以和其他程式碼“協作”完成一些功能。這一概念和“preemptive”相對,preemptive認為可以在程式/函式外部對其終止執行。

根據ES6 Generator函式的併發行為,我們可以認為其是一種“協程”。在Generator函式體內部,你可以使用yield關鍵字在函式內部暫停函式的執行,在Generator函式外部是無法暫停一個Generator函式執行的;每當Generator函式遇到一個yield關鍵字就將暫停執行。

然後,一旦一個Generator函式通過yield暫停執行,其不能夠自行恢復執行,需要通過外部的控制來重新啟動generator函式,我們將在文章後面部分介紹這是怎麼發生的。

基本上,只要你願意,一個Generator函式可以暫停執行/重新啟動任意多次。實際上,你可以再Generator函式內部使用無限迴圈(比如非著名的while (true) { .. })來使得函式可以無盡的暫停/重新啟動。然後這在普通的JS程式中卻是瘋狂的行徑,甚至會丟擲錯誤。但是Generator函式卻能夠表現的非常明智,有些時候你確實想利用Generator函式這種無盡機制。

更為重要的是,暫停/重新啟動不僅僅用於控制Generator函式執行,它也可以在generator函式內部和外部進行雙向的通訊。在普通的JavaScript函式中,你可以通過傳參的形式將資料傳入函式內容,在函式內部通過return語句將函式的返回值傳遞到函式外部。在generator函式中,我們通過yield表示式將資訊傳遞到外部,然後通過每次重啟generator函式將其他資訊傳遞給generator。

Generator 函式的語法

然我們看看新奇並且令人興奮的generator函式的語法是怎樣書寫的。

首先,新的函式宣告語法:

function *foo() {
    // ..
}

發現*符號沒?顯得有些陌生且有些奇怪。對於從其他語言轉向JavaScript的人來說,它看起來很像函式返回值指標。但是不要被迷惑到了,*只是用於標識generator函式而已。

你可能會在其他的文章/文件中看到如下形式書寫generator函式function* foo(){},而不是這樣function *foo() {}(*號的位置有所不同)。其實兩種形式都是合法的,但是最近我認為後面一種形式更為準確,因此在本篇文章中都是使用後面一種形式。

現在,讓我們來討論下generator函式的內部構成吧。在很多方面,generator函式和普通函式無異,只有在generator函式內部有一些新的語法。

正如上面已經提及,我們最先需要了解的就是yield關鍵字,yield__被視為“yield表示式”(並不是一條語句),因為當我們重新啟動generator函式的時候,我們可以傳遞資訊到generator函式內部,不論我們傳遞什麼進去,都將被視為yield__表示式的執行結果。

例如:

function *foo() {
    var x = 1 + (yield "foo");
    console.log(x);
}

yield "foo"表示式會在generator函式暫停時把“foo”字串傳遞到外部。同時,當generator函式恢復執行的時候,其他的值又會通過其他表示式傳入到函式裡面作為yield表示式的返回值加1最後再將結果賦值給x變數。

看到generator函式的雙向通訊了嗎?generator函式將‘’foo‘’字串傳遞到外部,暫停函式執行,在將來的某個時間點(可能是立即也可能是很長一段時間後),generator會被重啟,並且會傳遞一個值給generator函式,就好像yield關鍵字就是某種傳送請求獲取值的請求形式。

在任意表示式中,你可以僅使用yield關鍵字,後面不跟任何表示式或值。在這種情況下,就相當於將undefined通過yield傳遞出去。如下程式碼:

// note: `foo(..)` here is NOT a generator!!
function foo(x) {
    console.log("x: " + x);
}

function *bar() {
    yield; // just pause
    foo( yield ); // pause waiting for a parameter to pass into `foo(..)`
}

Generator 迭代器

“Generator 迭代器”,是不是相當晦澀難懂?

迭代器是一種特殊的行為,準確說是一種設計模式,當我們通過呼叫next()方法去遍歷一組值的集合時,例如,我們通過在長度為5的陣列[1, 2, 3, 4, 5]上面實現了迭代器。當我們第一次呼叫next()的時候,會返回1。第二次呼叫next()返回2,如此下去,當所有的值都返回後,再次呼叫next()將返回null或者false或其他值,這意味著你已經遍歷完真個陣列中的值了。

我們是通過和generator迭代器進行互動來在generator函式外部控制generator函式,這聽起來比起實際上有些複雜,考慮下面這個愚蠢的(簡單的)例子:

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

為了遍歷*foo()generator函式中的所有值,我們首先需要構建一個迭代器,我們怎麼去構建這個迭代器呢?非常簡單!

var it = foo();

如此之簡單,我們僅僅想執行普通函式一樣執行generator函式,其將返回一個迭代器,但是generator函式中的程式碼並不會執行。

這似乎有些奇怪,並且增加了你的理解難度。你甚至會停下來思考,問為什麼不通過var it = new foo()的形式來執行generator函式呢,這語法後面的原因可能相當複雜並超出了我們的討論範疇。

好的,現在讓我們開始迭代我們的generator函式,如下:

var message = it.next();

通過上面的語句,yield表示式將1返回到函式外部,但是返回的值可能比想象中會多一些。

console.log(message); // { value:1, done:false }

在每一呼叫next()後,我們實際上從yield表示式的返回值中獲取到了一個物件,這個物件中有value欄位,就是yield返回的值,同時還有一個布林型別的done欄位,其用來表示generator函式是否已經執行完畢。

然我們把迭代執行完成。

console.log( it.next() ); // { value:2, done:false }
console.log( it.next() ); // { value:3, done:false }
console.log( it.next() ); // { value:4, done:false }
console.log( it.next() ); // { value:5, done:false }

有趣的是,當我們獲取到值為5的時候,done欄位依然是false。這因為,實際上generator函式還麼有執行完全,我們還可以再次呼叫next()。如果我們向函式內部傳遞一個值,其將被設定為yield 5表示式的返回值,只有在這時候,generator函式才執行完全。

程式碼如下:

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

所以最終結果是,我們迭代執行完我們的generator函式,但是最終卻沒有結果(由於我們已經執行完所有的yield__表示式)。

你可能會想,我能不能在generator函式中使用return語句,如果我這樣這,返回值會不會在最終的value欄位裡面呢?

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

var it = foo();

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

不是.

依賴於generator函式的最終返回值也許並不是一個最佳實踐,因為當我們通過for--of迴圈來迭代generator函式的時候(如下),最終return的返回值將被丟棄(無視)。

為了完整,讓我們來看一個同時有雙向資料通訊的generator函式的例子:

function *foo(x) {
    var y = 2 * (yield (x + 1));
    var z = yield (y / 3);
    return (x + y + z);
}

var it = foo( 5 );

// note: not sending anything into `next()` here
console.log( it.next() );       // { value:6, done:false }
console.log( it.next( 12 ) );   // { value:8, done:false }
console.log( it.next( 13 ) );   // { value:42, done:true }

你可以看到,我們依然可以通過foo(5)傳遞引數(在例子中是x)給generator函式,就像普通函式一樣,是的引數x5.

在第一次執行next(..)的時候,我們並沒有傳遞任何值,為什麼?因為在generator內部並沒有yield表示式來接收我們傳遞的值。

假如我們真的在第一次呼叫next(..)的時候傳遞了值進去,也不會帶來什麼壞處,它只是將這個傳入的值拋棄而已。ES6表明,generator函式在這種情況只是忽略了這些沒有被用到的值。(注意:在寫這篇文章的時候,Chrome和FF的每夜版支援這一特性,但是其他瀏覽有可能沒有完全支援這一特性甚至可能會丟擲錯誤)(譯者注:文章釋出於2014年)

yield(x + 1)表示式將傳遞值6到外部,在第二次呼叫next(12)時候,傳遞12到generator函式內部作為yield(x + 1)表示式的值,因此y被賦值為12 * 2,值為24。接下來,下一條yield(y / 3)(yield (24 / 3))將向外傳遞值8。第三次呼叫next(13)傳遞13到generator函式內部,給yield(y / 3)。是的z被設定為13.

最後,return (x + y + z)就是return (5 + 24 + 13),也就是42將會作為最終的值返回出去。

重新閱讀幾遍上面的例項。最開始有些難以理解。

for..of迴圈

ES6在語法層面上大力擁抱迭代器模式,提供了for..of迴圈來直接支援迭代器的遍歷。

例如:

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

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

console.log( v ); // still `5`, not `6` :(

正如你所見,通過呼叫foo()生成的迭代器通過for..of迴圈來迭代,迴圈自動幫你對迭代器進行遍歷迭代,每次迭代返回一個值,直到done: true,只要done: false,每次迴圈都將從value屬性上獲取到值賦值給迭代的變數(例子中的v)。一旦當donetrue。迴圈迭代結束。(for..of迴圈不會對generator函式最終的return值進行處理)

正如你所看到的,for..of迴圈忽略了generator最後的return 6的值,同時,迴圈沒有暴露next()出來,因此我們也不能夠向generator函式內傳遞資料。

總結

OK,上面是關於generator函式的基本用法,如果你依然對generator函式感到費解,不要擔心,我們所有人在一開始感覺都是那樣的。

我們很自然的想到這一外來的語法對我們實際程式碼有什麼作用呢?generator函式有很多作用,我們只是挖掘了其非常粗淺的一部分。在我們發現generator函式如此強大之前我們應該更加深入的瞭解它。

在你練習上面程式碼片段之後(在Chrome或者FF每夜版本,或者0.11+帶有--harmony的node環境下),下面的問題也許會浮出水面:(譯者注:現代瀏覽器最新版本都已支援Generator函式)

  1. 怎樣處理generator內部錯誤?

  2. 在generator函式內部怎麼呼叫其他generator函式?

  3. 非同步程式碼怎麼和generator函式協同工作?

這些問題,或者其他的問題都將在隨後的文章中覆蓋,敬請期待。

相關文章