ES6 Generators系列:
在JavaScript ES6提供的諸多令人興奮的新特性中,有一個新函數類型,叫generator。名字聽起來很怪(我們姑且將它稱之為生成器函數),而且行為更加讓人覺得怪異。本文旨在解釋generator函數的一些基本知識,用來說明它是如何工作的,并幫助你了解為什么它會讓未來的JS變得如此強大。
運行-完成(Run-To-Completion)
首先我們要討論的是generator函數和普通函數在運行方式上有什么區別。
不論你是否已經意識到了,對于函數而言,你總是會假定一個原則:一旦函數開始運行,它就會在其它JS代碼運行之前運行到結束。這句話怎么理解呢?看下面的代碼:
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()函數運行過程中,上面的setTimeout函數不會被運行直到foo()函數運行結束。
那如果事情不是這樣的會怎么樣?如果foo()函數的運行會被setTimeout打斷呢?是不是我們的程序將會變得不穩定?
在多線程運行的程序中,這的確會給你帶來噩夢,好在JavaScript是單線程運行的(同一時間只有一條命令或函數會被運行),因此這一點你不必擔心。
注意,Web開發允許JS程序的一部分在一個獨立的線程里運行,該線程可以與JS主線程并行運行。但這并不意味著我們可以在JS程序中引入多線程操作,因為在多線程操作中兩個獨立的線程之間是可以通過異步事件相互通信的,它們彼此之間通過事件輪詢機制(event-loop)一次一個地來運行。
運行-停止-運行(Run-Stop-Run)
ES6的generator函數允許在運行的過程中暫停一次或多次,隨后再恢復運行。暫停的過程中允許其它的代碼執行。
如果你曾經讀過有關并發或者線程編程方面的文章,你也許見到過"cooperative"(協作)一詞,它說明了一個進程(這里可以將它理解為一個function)本身可以選擇何時被中斷以便與其它代碼進行協作。這個概念與"preemptive"(搶占式。進程調度的一種方式。當前進程在運行過程中,如果有重要或緊迫的進程到達(其狀態必須為就緒),則該進程將被迫放棄處理機,系統將處理機立刻分配給新到達的進程。)正好相反,它表明了一個進程或function可以被其自身的意愿打斷。
在ES6中,generator函數使用的都是cooperative類型的并發方式。在generator函數體內,通過使用新的yield關鍵字從內部將函數的運行打斷。除了generator函數內部的yield關鍵字,你不可能從任何地方(包括函數外部)中斷函數的運行。
不過,一旦generator函數被中斷,它不可能自行恢復運行,除非通過外部的控制來重新啟動這個generator函數。稍后我會介紹如何實現這一點。
基本上,按照需要,一個generator函數在運行中可以被停止和重新啟動多次。事實上,你完全可以指定一個無限循環的generator函數(就像while(true){...}語句一樣),它永遠也不會被執行完。不過在一個正常的JS程序中,我們通常不會這樣做,除非代碼寫錯了。Generator函數足夠理性,有時候它恰恰就是你想要的!
而更重要的是,這種停止和啟動不僅僅控制著generator函數的執行,它還允許信息的雙向傳遞。普通函數在開始的時候獲取參數,在結束的時候return一個值,而generator函數可以在每次yield的時候返回值,并且在下一次重新啟動的時候再傳入值。
語法
是時候介紹一下generator函數的語法了:
function *foo() { // .. }
注意這里的*了嗎?這是一個新引入的運算符,對于學習C語言系的同學而言,可能會想到函數指針。不過這里千萬不要把它和指針的概念混淆了,*運算符在這里只是用來標識generator函數的類型。
你可能在其它的文章或文檔中看到這種寫法function* foo(){},而本文中我們使用這種寫法function *foo(){}(區別僅僅是*的位置)。這兩種寫法都是正確的,不過我們推薦使用后者。
我們來看看generator函數的內容。Generator函數在大多數方面就是普通的JS函數,因此我們需要學習的新語法不會很多。
在generator函數體內部主要是yield關鍵字的應用,前面我們已經提到過它。注意這里的yield ___被稱之為yield表達式而不是語句,這是因為當我們重新啟動generator函數時,我們會傳入一個值,而不管這個值是什么,都會作為yield ___表達式計算的結果。
一個例子:
function *foo() { var x = 1 + (yield "foo"); console.log(x); }
這里的yield "foo"表達式會在generator函數暫停時返回字符串"foo",當下一次generator函數重新啟動時,不管傳入的值是什么,都會作為yield表達式計算的結果。這里會將表達式1 + 傳入值的結果賦值給變量x。
從這個意義上來說,generator函數具有雙向通信的功能。Generator函數暫停的時候返回了字符串"foo",稍后(可能是立即,也可能是從現在開始一段很長的時間)重新啟動的時候它會請求一個新值并將最終計算的結果返回。這里的yield關鍵字起到了請求新值的作用。
在任何表達式中,你可以只用yield關鍵字而不帶其它內容,此時yield返回的值是undefined。看下面的例子:
// 注意,這里的函數foo(..)不是一個generator函數!! function foo(x) { console.log("x: " + x); } function *bar() { yield; // 暫停執行,返回值是undefined foo( yield ); // 暫停執行,稍后將獲取到的值作為函數foo(..)的參數傳入 }
Generator遍歷器
“Generator遍歷器”!乍一看,好像很難懂!
遍歷器是一種特殊的行為,實際上是一種設計模式,我們通過調用next()方法來遍歷一組有序的值。想象一下,例如使用遍歷器對數組[1,2,3,4,5]進行遍歷。第一次調用next()方法返回1,第二次調用next()方法返回2,以此類推。當數組中的所有值都返回后,調用next()方法將返回null或false或其它可能的值用來表示數組中的所有元素都已遍歷完畢。
我們唯一可以從外部控制generator函數的方式就是構造和通過遍歷器進行遍歷。這聽起來好像有點復雜,考慮下面這個簡單的例子:
function *foo() { yield 1; yield 2; yield 3; yield 4; yield 5; }
為了遍歷generator函數*foo(),首先我們需要構造一個遍歷器。怎么做?很簡單!
var it = foo();
事實上,通過普通的方式調用一個generator函數并不會真正地執行它。
這有點讓人難以理解。你可能在想,為什么不是var it = new foo(). 背后的原理已經超出了我們的范圍,這里我們不展開討論。
然后,我們通過下面的方法對generator函數進行遍歷:
var message = it.next();
這會執行yield 1表達式并返回值1,但不僅限于此。
console.log(message); // { value:1, done:false }
事實上每次調用next()方法都會返回一個object對象,其中的value屬性就是yield表達式返回的值,而屬性done是一個boolean類型,用來表示對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 }
有趣的是,當value的值是5時done仍然是false。這是因為從技術上來說,generator函數還沒有執行完,我們必須再調用一次next()方法,如果此時傳入一個值(如果未傳入值,則默認為undefined),它會被設置為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函數中return語句返回的值并不值得提倡,因為當使用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 ); // 注意這里在調用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 }
你可以看到我們在構造generator函數遍歷器的時候仍然可以傳遞參數,這和普通的函數調用一樣,通過語句foo(5),我們將參數x的值設置為5。
第一次調用next()方法時,沒有傳入任何值。為什么呢?因為此時沒有yield表達式來接收我們傳入的值。
如果在第一次調用next()方法時傳入一個值,也不會有任何影響,該值會被拋棄掉。按照ES6標準的規定,此時generator函數會直接忽略掉該值(注意:在撰寫本文時,Chrome和FireFox瀏覽器都能很好地符合該規定,但其它瀏覽器可能并不完全符合,而且可能會拋出異常)。
表達式yield(x + 1)的返回值是6,然后第二個next(12)將12作為參數傳入,用來代替表達式yield(x + 1),因此變量y的值就是12 × 2,即24。隨后的yield(y / 3)(即yield(24 / 3))返回值8。然后第三個next(13)將13作為參數傳入,用來代替表達式yield(y / 3),所以變量z的值是13。
最后,語句return (x + y + z)即return (5 + 24 + 13),所以最終的返回值是42。
多重溫幾次上面的代碼,開始的時候你會覺得很難懂,只要理解了generator函數執行的過程,掌握起來并不難。
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,循環遍歷就停止(而且不會包含函數的返回值,如果有的話。所以此處的return 6不包括在for..of循環中)。
如上所述,可以看到for..of循環忽略并拋棄了返回值6,這是因為此處沒有對應的next()方法被調用,for..of循環不支持將值傳遞給generator函數迭代的情況,如在for..of循環中使用next(v)。事實上,在使用for..of循環時不需要使用next方法。
總結
以上就是generator函數的基本概念。如果你仍然覺得有點難以理解,也不用太擔心,任何人剛開始接觸generator函數時都會有這種感覺!
你應該會很自然地想到generator函數能在自己的代碼中起到什么樣的作用,盡管我們會在很多地方用到它。我們剛剛只是接觸到了一些皮毛,還有很多需要了解的,所以我們必須深入研究,才能發現它是如此的強大。
嘗試在Chrome nightly/canary或FireFox nightly或node 0.11+(使用--harmony參數)環境中運行本文的示例代碼,并思考下面的問題:
- 如何處理異常?
- 在一個generator函數中可以調用另一個generator函數嗎?
- 如何在generator函數中進行異步編程?
接下來的文章會解答上述問題,并繼續深入探討有關ES6 generator函數的內容,敬請關注!
文章列表