
初探 ES6(3)Generator
ES6 新增的諸多功能中,Generator受到許多矚目。什麼是Generator呢?簡單地說,他有點像是為函數增加額外的進入點,函數在執行到yield時會暫停執行,返回到呼叫函數的程式中繼續執行,直到Generator物件的next方法再次被呼叫,讓程式回到函數中。 Generator的語法與基本概念 先來看看基本的語法與觀念。ES6 的Generator函數,需要用* modifier標注: function * gen() { console.log('start'); yield "called"; } 然後呼叫這個函數時,他不會直接執行,而是返回一個Generator物件: var g = gen(); //nothing happened 接著要執行Generator物件的next方法,函數才會開始執行,到yield時會暫停執行並返回,返回值是一個物件,他的value屬性是yield右側的expression的執行結果: var a = g.next(); //顯示start console.log(a.value); //顯示called 要判斷Generator是否執行完畢,可以用返回物件的done屬性來判斷: console.log(a.done); //顯示false 上面的Generator function只會執行一次yield,所以再次執行next()時,所取得物件的done屬性會變成true: var b = g.next(); console.log(b.done); //顯示true 由於Generator物件的迭代已經結束了,如果再呼叫他的next(),就會拋出例外: g.next(); //在node.js環境中會拋出 Error: Generator has already finished 基本的使用方式就是這樣。(範例原始碼:http://gist.github.com/fillano/9866611) 除了返回值,Generator也可以接收外部傳來的東西: function * gen() { console.log('start'); var got = yield 'called'; console.log(got); } 這時,got變數就會取得送給他的東西。要送東西給Generator也很簡單,只要next(東西)就可以: var g = gen(); var a = g.next(); //顯示start var b = g.next('hello generator'); //顯示hello generator (範例原始碼:http://gist.github.com/fillano/9866649) 除了next(),generator物件還有一個throw方法,在做複雜的流程控制時,可以用他來拋出例外: function * gen(){} var g = gen(); g.throw('got an error.'); //拋出一個例外,錯誤訊息是 'got an error.' (範例原始碼:http://gist.github.com/fillano/9866678) Generator的用途 知道了Generator的基本用法與概念,再來看看可以怎樣使用Generator。 首先,結合一個無窮迴圈跟Generator,就可以讓一個無窮的序列變成可以用迭代的方式來操作。例如: function * serial() { var c = 0; while(true) { yield c; c++; } } var s = serial(); console.log(s.next().value); //顯示 0 console.log(s.next().value); //顯示 1 console.log(s.next().value); //顯示 2 (範例原始碼:http://gist.github.com/fillano/9866706) 由於Generator會中斷函數的執行,所以這個執行過程可以是非同步的: function * gen() { var c = 0; while(true) { yield c; c++; } } function run() { var g = gen(); setInterval(function() {console.log(g.next().value)}, 3000); setInterval(function() {console.log(new Date().getTime())}, 1000); } run(); (範例原始碼:http://gist.github.com/fillano/9866723) 執行的結果大概會像這樣: 1395456932177 1395456933192 0 1395456934196 1395456935196 1395456936197 1 1395456937198 1395456938199 1395456939201 2 1395456940203 1395456941204 1395456942206 3 1395456943205 1395456944206 1395456945207 4 1395456946209 1395456947211 1395456948212 所以使用Generator,不會因為阻擋其他函數執行而產生效能的問題。 更重要的應用,則是解決在node.js常碰到的callback hell問題,讓我們可以用類似sync的語法來執行一串有先後順序的非同步操作。在node.js中,大多數IO操作都是非同步的(當然有許多也有同步的版本,但是這樣執行效率就不好了),我們需要用callback來取得執行的結果。當這些結果有先後的相依性,就必須在callback中呼叫另一個非同步操作,再傳callback給他,這樣程式會變得很難閱讀。下面用setTimeout來模擬非同步的操作,讓程式在指定的秒數顯示timestamp: function withoutYield() { var a = new Date().getTime(); console.log('[without yield]\tcurrent timestamp: '+a); setTimeout(function() { var a = new Date().getTime(); console.log('[without yield]\tcurrent timestamp: '+a); setTimeout(function() { var a = new Date().getTime(); console.log('[without yield]\tcurrent timestamp: '+a); setTimeout(function() { var a = new Date().getTime(); console.log('[without yield]\tcurrent timestamp: '+a); }, 1000); }, 2000); }, 1000); } withoutYield(); 執行結果看起來會像是這樣: [without yield] current timestamp: 1395459066682 [without yield] current timestamp: 1395459067701 [without yield] current timestamp: 1395459069705 [without yield] current timestamp: 1395459070706 但是隨著有先後順序的非同步操作越來越多,callback深度會越來越深,最後會很恐怖。過去的解決方式是使用flow control或是promise,讓這個過程比較清楚。不過有了Generator之後,我們可以利用他來管理callback,每次非同步執行完畢後,讓他通知使用Generator來管理流程的函數來回傳結果並且迭代到下一次非同步操作,這樣就可以用類似同步的方式來執行非同步操作。首先要包裝一下非同步操作,讓流程管理的函數可以收到執行結果: function timeout(sec) { return function(notify) { setTimeout(function() { notify(null, new Date().getTime()); }, sec*1000); }; } 然後寫一個簡單的流程管理函數: function co(gen) { var g = gen(); function next(err, data) { var res; if(err) { return g.throw(err); } else { res = g.next(data); } if(!res.done) { res.value(next) } } next(); } 最後就可以用類似同步操作的方式來執行一串有先後順序的非同步操作: function * withYield() { var a = new Date().getTime(); console.log('[with yield]\tcurrent timestamp: '+a); a = yield timeout(1); console.log('[with yield]\tcurrent timestamp: '+a); a = yield timeout(2); console.log('[with yield]\tcurrent timestamp: '+a); a = yield timeout(1); console.log('[with yield]\tcurrent timestamp: '+a); } co(withYield); (範例原始碼:http://gist.github.com/fillano/9866779,這裡會把withYield跟withoutYield同時執行,想看個別結果,需要把co(withYield)或是withoutYield()註解掉) 上面這個co函數,取自toby ho文章中的範例: What Is This Thing Called Generators? 不過我稍微改過例子,以setTimeout取代file I/O,來進行非同步操作。另外,改成用setTimeout的話,在支援Generator的瀏覽器中也可以執行。再來稍微看一下程式執行的過程。
利用這個方式,結合Generator與遞迴,就可以在withYield中,使用sync的語法來執行async的操作,避開層層的callback了。 Generator目前已經開始有實際的應用。TJ Holowaychuck(node.js環境中最多人使用的網頁伺服器框架Express的作者)開發了一個co套件,就是利用這個方式來做流程控制,並且會用他來取代原本的connect middleware架構。他也寫了一些文章討論Generator:Callbacks vs Coroutines – A look at callbacks vs generators vs coroutines,範例中的thread函數,就跟前面co函數是一樣的結構。(不如說,toby ho的範例就是取自TJ的文章,我再拿來用XD)對使用Generator有興趣,也可以直接看一下co套件,不過co為了支援多種結構(例如Generator函數的組合),程式會複雜許多,想要了解用他來作流程控制的原理,還是看他的文章比較快。 總結 過去為了解決Javascript的callback hell問題,已經許多人實作各種方法,包括一些基本的流程控制以及Promise。不過有了Generator,就可以用更簡潔的方式來解決這個問題。 另外,預計在ES7,還會增加await語法(類似目前C#在非同步操作中使用的方式),把目前使用yield的方式替換掉。這樣就避開額外使用輔助的library、包裝async函數、利用yield接收執行結果等等較為複雜的過程,來使用sync的語法呼叫async函數。只是…這可能還需要幾年…ECMA-262推進的過程還蠻緩慢的XD |