原文地址:https://davidwalsh.name/es6-generators
作者 Kyle Simpson,發布于 2014年7月21日
生成器(generator),作為一種新的函數,是JavaScript ES6 帶來的最令人興奮的新特性之一。名字或許有點陌生,不過初步了解之后你會發現,它的行為更加陌生。本文的目的是幫你了解生成器,并且讓你認識到為什么對于 JS 的未來而言它是如此重要。
執行-結束
首先讓我們來看下它相較于普通函數“執行-結束”模式的不同之處。
不知道你是否注意過,對于函數你一直以來的看法就是:一旦函數開始執行,它就會一直執行下去直到結束,這個過程中其他的 JS 代碼無法執行。
示例:
setTimeout(function(){
console.log("Hello World");
},1);
function foo() {
// 注意:不要做這樣瘋狂的長時間運行的循環
for (var i=0; i<=1E10; i++) {
console.log(i);
}
}
foo();
// 0..1E10
// "Hello World"
在上面例子中,for
循環會執行相當長的時間才會結束,至少超過1毫秒,但是定時器回調函數中的 console.log(..)
語句并不能在 foo()
函數執行過程中打斷它,所以它會一直等在后面(在事件循環隊列上),直到函數執行結束。
如果 foo()
的執行可以被打斷呢?那豈不給我們的程序帶來了災難?
這就是多線程編程帶來的挑戰(噩夢),不過還好,在 JavaScript 領域我們不用擔心這種事情,因為 JS 始終是單線程的(同時只會有一個指令或函數在執行)。
注意:Web Worker 機制可以將 JS 程序的一部分在一個單獨的線程中執行,與 JS 主程序并行。之所以說這不會帶來多線程的問題,是因為這兩個線程可以通過普通的異步事件來彼此通信,仍然在事件循環的一次執行一個的行為模式下。
執行-停止-執行
ES6 生成器(generator)是一種不同類型的函數,可以在執行過程中暫停若干次,并在稍后繼續執行,使得其他代碼可以在其暫停過程中得到執行。
如果你對并發或線程編程有所了解,你可能聽過“協作(cooperative)”這個詞,意思是一個進程(這里指函數)可以自主選擇什么時間進行中斷,從而可以與其他代碼協作。與之相對的是“搶占”,意思是一個進程/函數可以在外部被打斷。
從并發行為上來說,ES6 生成器是“協作的”。在生成器函數內部,可以通過 yield
關鍵字來暫停函數的執行。不能在生成器外部停止其執行;只能是生成器內部在遇到 yield
時主動停止。
不過,在生成器通過 yield
暫停后,它不能自己繼續執行。需要通過外部控制來讓生成器重新執行。我們會花一點時間來闡述這個過程。
所以,基本上,一個生成器函數可以停止執行和被重新啟動任意多次。實際上,可以通過無限循環(如臭名昭著的 while (true) { .. }
)來使得一個生成器函數永遠不終止。盡管在通常的 JS 編程中這是瘋了或者出錯了,但對于生成器函數這卻會是非常合理的,并且有時候就是你需要的!
更重要的是,生成器函數執行過程中的控制并不僅僅是停止和啟動,在這個過程中還實現了生成器函數內外的雙向消息傳遞。對于普通函數,是在最開始執行時獲得參數,最后通過 return
返回值。而在生成器函數中,可以在每個 yield 處向外發送消息,在每次重新啟動時得到外部返回的消息。
語法!
讓我們開始深入分析這全新和令人興奮的生成器函數的語法。
首先,新的聲明語法:
function *foo() {
// ..
}
注意到 *
了沒?看起來有點陌生和奇怪吧。對于了解其他語言的人來說,這看起來很像是一個函數的指針。但是別被迷惑了!這里只是用于標記特殊的生成器函數類型。
你可能看過其他文章/文檔使用了 function* foo() { }
而不是 function *f00() { }
(*
的位置有所不同)。兩種都是合法的,不過最近我認為 function *foo() { }
更準確些,所以我后面會使用這種形式。
下面,我們來討論下生成器函數的內容。大多數情況下,生成器函數就像是普通的 JS 函數。在生成器的 內部 只有很少的新的語法需要學習。
我們主要的新玩具,前面也提到過,就是 yield
關鍵字。yield __
被稱為“yield 表達式”(而非語句),因為生成器重新執行時,會得到一個返回給生成器的值,這個值會作為 yield __
表達式的值使用。
示例:
function *foo() {
var x = 1 + (yield "foo");
console.log(x);
}
在執行到 yield "foo"
這里時,生成器函數暫停執行,"foo"
會被發送到外部,而(如果)等到生成器重新執行時,不管被傳入了什么值,都會作為這個表達式的結果值,進而與 1
相加后賦值給變量 x
。
看出來雙向通信了嗎?生成器將 "foo"
發送到外部,暫停自身的執行,然后在未來某一時間點(可能是馬上,也可能是很久之后!),生成器被重新啟動并傳回來一個值。這看起來就像是 yield
關鍵字產生了一個數據請求。
在任何使用表達式的位置,都可以在表達式/語句中只使用 yield
,這就像是對外發送了 undefinded
值。如:
// 注意:這里的 `foo(..)` 不是生成器函數!!
function foo(x) {
console.log("x: " + x);
}
function *bar() {
yield; // 只是暫停
foo( yield ); // 暫停,等待傳入一個參數給 `foo(..)`
}
生成器迭代器
“生成器迭代器”,很拗口是不是?
迭代器是一種特殊的行為,或者說設計模式,指的是我們從一個有序的值的集合中通過調用 next()
每次取出一個值。想象一個迭代器,對應一個有五個值的數組:[1,2,3,4,5]
。第一次調用 next()
返回 1
,第二次調用 next()
返回 2
,以此類推。在所有的值返回后,next()
返回 null
或 false
或其他可以讓你知道數據容器中的所有值已被遍歷的信號。
我們在外部控制生成器函數的方式,就是構造一個 生成器迭代器 并與之交互。這聽起來比實際情況要復雜。來看下面的例子:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
為了獲得生成器函數 *foo()
的值,我們需要構造一個迭代器。怎么做呢?很簡單!
var it = foo();
噢!所以,像一般函數那樣調用生成器函數,其實并沒有執行其內部。
這有點奇怪是吧。你可能還在想,為什么不是 var it = new foo();
。不過很遺憾,語法背后的原因有點復雜,超出了我們這里討論的范圍。
現在,為了遍歷我們的構造器函數,只需要:
var message = it.next();
這會從 yield 1
語句那里得到 1
,但這并不是唯一返回的東西。
console.log(message); // { value:1, done:false }
實際上每次調用 next()
會返回一個對象,返回對象包含一個對應 yield
返回值的 value
屬性,以及一個表示生成器函數是否已經完全執行完畢的布爾型的 done
屬性。
繼續迭代過程:
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 }
有意思的是,done
屬性在獲取到 5
這個值時仍為 false
。這是因為從 技術上 講,生成器函數的執行還未結束。我們還需要最后一次調用 next()
,這時如果我們傳入一個值,它會被用作表達式 yield 5
的結果。然后 生成器函數才會結束。
所以,現在:
console.log( it.next() ); // { value:undefined, done:true }
生成器函數的最后一個返回結果表示函數執行結束,但沒有值返回(因為所有的 yield
語句都已執行)。
你可能會想,如果在生成器函數中使用 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 }
...也不是
依賴生成器的 return
值不是個好主意,因為當生成器函數在 for .. of
循環(見下文)中進行迭代時,最后的 return
值會被丟棄。
下面,我們來完整地看下生成器函數在迭代時的數據傳入和傳出:
function *foo(x) {
var y = 2 * (yield (x + 1));
var z = yield (y / 3);
return (x + y + z);
}
var it = foo( 5 );
// 注意:這里沒有向 `next()` 傳入任何值
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
),這和普通函數相同,會使 x
的值為 5
。
第一個 next(..)
調用,沒有傳入任何值。為什么?因為沒有對應的 yield
表達式來接收傳入的值。
不過即使第一次調用時傳入了值,也不會有什么壞事發生。傳入的值只是被丟棄了而已。ES6 規定這種情況下生成器函數要忽略沒有用到的值。(注意:在實際寫代碼的時候,最新版的 Chrome 和 FF 應該沒問題,不過其他瀏覽器可能不是完全兼容的,或許會在這種情況下拋出異常。)
語句 yield (x + 1)
向外發送 6
。第二個調用 next(12)
向正在等待狀態的 yield (x + 1)
表達式發送了 12
,所以 y
的值為 12 * 2
,也就是 24
。然后 yield (y / 3)
(yield (24 / 3)
)向外發送值 8
。第三個調用 next(13)
向表達式 yield (y / 3)
發送了 13
,使得 z
的值為 13
。
最終,return (x + y + z)
是 return (5 + 24 + 13)
,也就是說返回的最后的 value
是 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 ); // 仍舊是 `5`,而不是 `6` :(
可以看到,foo()
創建的迭代器會被 for..of
循環自動捕獲,然后被自動進行遍歷,每次返回一個值,直到 done:true
返回。done
為 false
時,會自動提取 value
屬性賦值給迭代變量(上例中為 v
)。一旦 done
是 true
,循環迭代終止(也不會處理最后返回的 value
,如果有的話)。
就像上文提到過的那樣,for..of
循環忽略并丟棄了最后的 return 6
的值。所以,由于沒有暴露 next()
調用,還是不要在像上面那種情況下使用for..of
循環。
總結
OK,以上就是生成器的基礎知識了。如果還是有點懵,不用擔心。所有人一開始都是這樣的!
很自然地,你會想這個外來的新玩具在自己的代碼中實際會怎么使用。其實,有關生成器還有很多的東西。我們只是翻開了封面而已。所以,在發現生成器是/將會多么強大之前,我們還得更進一步學習。
在你試著玩過上面的代碼片段之后(試試 Chrome 最新版或 FF 最新版,或者帶有 --harmony
標記的 node 0.11+ 環境),可能會思考下面的問題:
- 異常如何處理?
- 一個生成器能夠調用另一個嗎?
- 異步代碼怎么應用生成器?
這些問題,以及其他更多的問題,將會在該系列文章中討論,所以,請繼續關注!
該系列文章共有4篇,這是第一篇,如有時間,其他3篇也會在近期陸續翻譯出來。
ES6 Generators: Complete Series
- The Basics Of ES6 Generators
- Diving Deeper With ES6 Generators
- Going Async With ES6 Generators
- Getting Concurrent With ES6 Generators
另外,有關 for..of
的部分,其實有個細節文章沒有解釋。for..if
接收的并不是迭代器(實現了 iterator 接口,也就是有 next()
方法),而應該是實現了 iterable 接口的對象。
之所以生成器函數調用后的返回值可以用于 for..of
,是由于得到的生成器對象同時支持了 iterator 接口和 iterable 接口。
iterable 接口對應一個特殊的方法,調用后返回一個迭代器,對于生成器對象而言,這個接口方法返回的其實就是對象自身。
由于同時支持了兩個接口,所以生成器函數返回的生成器對象既能直接調用 next()
,也可以用于 for..in
循環中。