前言
當初,JavaScript
引入非同步(Asynchonrous)主要是為了解決瀏覽器端同步IO
造成的UI假死現象,但是主流的程式語言和Web伺服器都採取同步IO
的模式,原因無非是:
- 採用
同步IO
編寫的程式碼符合人和直覺,程式碼容易編寫和維護。 - 對於
同步IO
造成的執行緒阻塞可以通過建立多執行緒(程式)的方式,通過增加伺服器數量進行橫向擴充套件來解決。
但是,在很多情況下,這種方式並不能很好地解決問題。比如對於靜態資源伺服器(CDN伺服器)來說,每時每刻要處理大量的檔案請求,如果對於每個請求都新開一個執行緒(程式),可想而知,效能開銷是很大的,而且有種殺雞用牛刀的感覺。所以Nginx
採用了和JavaScript
相同的策略來解決這個問題——單執行緒、非阻塞、非同步IO。這樣,當一個 IO 操作開始的時候,Nginx
不會等待操作完成就會去處理下一個請求,等到某個 IO 操作完成後,Nginx
再回過頭去處理(回撥)這次 IO 的後續工作。
然後2009年NodeJS
的釋出,又極大的推進了非同步IO
在服務端的應用,據聞,NodeJS
在處理阿里巴巴“雙十一”的海量請求高併發中發揮了很大的作用。
所以,非同步IO
真是個好東西,但是,編寫非同步程式碼卻有一個無法迴避的問題——回撥函式巢狀太多、過多的回撥層級造成閱讀和維護上的困難——俗稱“回撥地獄(callback hell)”。
為了解決這個難題,出現了各種各樣的解決方案。最先出來的方案是利用任務佇列控制非同步流程,著名的代表有Async
。然後Promises/A+
規範出來了,人們根據這個規範實現了Q
。那時候Async
和Q
各佔邊壁江山,兩邊都有不少忠實的擁躉, 雖然它們解決問題的思路不同,但是都很好的解決了地獄回撥的問題。隨著ES6(ECMA Script 6)
將Promise
標準納入旗下,Promise
成了真正意義上解決地獄回撥的最佳解決方案(在支援ES6
的環境中,開箱即用,不用引入第三方庫)。
但是,雖然Async
和 Promise
之流都在程式碼層面避免了地獄回撥,但是程式碼組織結構上並沒有完全擺脫非同步的影子,和純同步的寫法相比,還是有很大的不同,寫起來還是略麻煩。幸好,ES6
引入了Generator
的概念,利用Generator
就可以很好的解決這個問題了。
可以看到,經過Generator重寫後,程式碼形式上和我們熟悉的同步程式碼沒什麼二樣了。?
下面我們就來介紹這種神奇的黑魔法!
Generator 簡介
Generator
是ES6
新引進的關鍵字,它用來定義一個Generator
,用法和定義一個普通的函式(function)幾乎一樣,只是在function
關鍵字和函式名之前加入了星號 *
。Generator
最大的特點就是定義的函式可以被暫停執行,很類似我們打斷點除錯程式碼:點Run
按鈕程式碼就自動執行當前語句直到遇到下一個斷點並暫停,不同的是Generator
的這種暫停態和執行態是由程式碼來定義和控制的。
在Generator
裡,yield
關鍵字用來定義程式碼暫停的地方,類似於給程式碼打斷點(但不是真的打斷點,不要和debugger
關鍵字混淆),而generator.next(value)
則用來控制程式碼的執行並處理輸入輸出。下面用程式碼來說明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** *@description 獲取自然數 */ function *getNaturalNumber(){ var seed = 0; while(true) { yield seed ++; } } var gen = getNaturalNumber();// 例項化一個Generator /* 啟動Generator */ console.log(gen.next()) //{value: 0, done: false} console.log(gen.next()) //{value: 1, done: false} console.log(gen.next()) //{value: 2, done: false} |
這是一個利用Generator
實現的自然數生成器。通過例項化一個Generator
,然後每次通過gen.next()
取得一個自然數,此過程可以無限進行下去。不過值得注意的是,通過gen.next()
取得的輸出是一個物件,包含value
和done
兩個屬性,其中value
是真正返回的值,而done
則用來標識Generator
是否已經執行完畢。因為自然數生成器是一個無限迴圈,所以不存在done: true
的情況。
這個例子比較簡單,下面來個稍微複雜點的例子(涉及到輸入和輸出)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * @description 處理輸入和輸出 */ function * input(){ let array = []; while(i) { array.push(yield array); } } var gen = input(); console.log(gen.next("西")) // { value: [], done: false } console.log(gen.next("部")) // { value: [ '部' ], done: false } console.log(gen.next("世")) // { value: [ '部', '世' ], done: false } console.log(gen.next("界")) // { value: [ '部', '世', '界' ], done: false } |
有人可能會對執行結果有疑問,不清楚外部的資料是如何傳到Generator
內部的,可能會猜想是通過gen.next("西")
這句話傳進去的,但是問題又來了,為什麼'部', '世', '界'
都傳進去了,'西'
去哪了?別急,且聽我慢慢道來。
首先我們要明白yield
其實由兩個動作組成,輸入 + 輸出(輸入在輸出前面),每次執行next
,程式碼會暫停在yield
輸出執行後,其它的語句不再執行(很重要)。其次對於上面的例子來說,兩次next()
才真正執行完一次while迴圈。比如上面的例子裡,為什麼第一次輸出的是[]
,而不是['西']
呢?那是因為第一次執行gen.next("西")
的時候,首先會將'西'
傳進去,但是並沒有接受的物件,雖然西
確實是被傳進來了,但是最後被丟棄了;然後程式碼執行完yield array
輸出之後就暫停。然後第二次執行gen.next("部")
的時候,會先執行輸入操作,執行array.push('部')
, 然後進行第二次迴圈,執行輸出操作。
現在總結一下:
- 每個
yield
將程式碼分割成兩個部分,需要執行兩次next
才能執行完。 yield
其實由兩個動作組成,輸入 + 輸出(輸入在輸出前面),每次執行next
,程式碼會暫停在yield
輸出執行後,其它的語句不再執行(很重要)。
如何利用Generator進行非同步流程控制?
利用Generaotr
可以暫停程式碼執行的特性,我們通過將非同步操作用yield
關鍵字進行修飾,每當執行非同步操作的時候,程式碼便在此暫停執行了。非同步操作結束後,通過在回撥函式裡利用next(data)
來控制Generator
的執行流程,並順便將非同步操作的結果data
回傳給Generator
,執行下一步。到此,整個非同步流程得到了完美的控制,我們可以看一個小例子
可以看到,Generator確實可以幫助我們來控制非同步流程,但是上面的代比較很raw,存在以下兩個問題:
- 不能自動執行,需要手動啟動。
- 不能流程控制的程式碼需要自己寫在非同步回撥函式裡,且沒有通用性。
所以我們需要構造一個執行器,自動處理上面提到的兩個問題。
TJ大神的co就是用來解決這個問題的。
下面我來詳細說一下解決此問題的兩種方法:利用Thunk
和Promise
利用Thunk來構造generator自動執行器
這裡引入了一個新的概念——thunk( 讀音 [θʌŋk] ),為了幫助理解,下面單獨來介紹一下thunk。
Thunk
維基百科上的介紹如下:
In computer programming, a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine. Thunks are primarily used to represent an additional calculation that a subroutine needs to execute, or to call a routine that does not support the usual calling mechanism. They have a variety of other applications to compiler code generation) and in modular programming.
可以簡單理解為,thunk就是為了滿足函式(子程式)呼叫的特殊需要,對原函式(子程式)進行了特殊的改造,主要用在編譯器的程式碼生成(傳名呼叫)和模組化程式設計中。
在JavaScript
中的thunk
化指的是將多引數函式,將其替換成單引數的版本,且只接受回撥函式作為引數,比如NodeJs
的fs.readFile
函式,tnunk
化為:
1 2 3 4 5 6 |
var fs = require("fs"); var readFile = function(filename){ // 包裝為高階函式 return function(cb){ fs.readFile(filename, cb); } } |
為了接下來的方便,我們在這裡先構造一個thunkify
函式,專門對函式進行thunk
化:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function thunkify(fn){ return function(){ var args = [].slice.call(arguments); var pass; args.push(function(){ if(pass) pass.apply(null, arguments); }); // 植入回撥函式,裡面包含控制邏輯 fn.apply(null, args); return function(fn) { pass = fn; // 外部可注入的控制邏輯 } } } |
執行器
現在開始構造我們的執行器。思路也很簡單,執行器接受一個Generator
函式,例項化一個Generator
物件,然後啟動任務,通過next()
取得返回值,這個返回值其實是一個函式,提供了一個入口可以讓我們可以方便的注入控住邏輯,包括:控制Generator
向下執行、將非同步執行的結果返回給Generator
。
1 2 3 4 5 6 7 8 9 10 11 12 |
function run(generator){ var gen = generator(); function next(data) { var ret = gen.next(data); // 將資料傳回Generator if(ret.done) return; ret.value(function(err, data){ if(err) throw(err); next(data); }); next(); // 啟動任務 } } |
測試
下面我們來測試一下程式碼是否按照我們的預期執行。
可以看到,完全符合我們的預期!
缺陷
雖然以上Thunk函式能完美實現我們對非同步流程的控制,但是對於同步任務卻不能正確的做出反應,比如我寫一個同步版的readFileSync
:
1 2 3 |
function _readFileSync(filename, cb) { cb(null, file[filename]); } |
然後將var readFile = thunkify(_readFile); // 將_readFile thunk化
改為var readFile = thunkify(_readFileSync)
其餘均保持不變,執行程式碼會發現執行不成功。什麼原因造成的呢?其實只要仔細分析就會發現,主要問題主要出現在thunkify
函式上面,在流程控制函式注入之前,任務函式就已經執行了,如果這個任務是非同步的,那沒問題,因為非同步任務回撥函式只會等主執行緒空閒了才會執行,所以非同步任務能確保控制函式能夠被成功注入。但是如果這個任務是同步的,那就不一樣了。傳給同步任務的回撥函式會被立刻執行,之後給它注入控制邏輯已經沒用了,因為同步任務早已執行完。
改進
為了改進thunkify
函式,讓它能適應同步的情況,可以考慮將任務函式的執行延後到控制邏輯注入後執行,這樣就能確保無論任務函式非同步也好,同步也罷,都能注入控制邏輯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
/** * 重寫thunkify函式,使其能相容同步任務 */ function thunkify(fn) { return function(){ var args = [].slice.call(arguments); var ctx = this; return function(done) { var called; args.push(function(){ if(called) return ; called = true; done.apply(null, arguments); }) try{ fn.apply(ctx, args); // 將任務函式置後執行 } catch(ex) { done(ex); } } } } |
改進後的:
利用Promise來構造generator自動執行器
有了上面的基礎,基於Promise就會容易理解多了。
toPromise
首先,我們應該將非同步任務改造成Promsie的形式,為了相容同步任務,我們先對任務進行thunkify統一化,然後再轉化為Promise。
1 2 3 4 5 6 7 8 9 10 11 |
function toPromise(fn) { return function() { var thunkify_fn = thunkify(fn).apply(this, arguments); // 先thunkify化 return new Promise(function(resolve, reject) { // 返回Promise thunkify_fn(function(err, data) { if (err) reject(err); resolve(data); }); }); } } |
執行器
因為Promise是標準化的,所以構造Promise的執行器比較簡單,我就直接show code了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function run(generator) { var gen = generator(); function next(data) { var ret = gen.next(data); if(ret.done) return Promise.resolve("done"); return Promise.resolve(ret.value) .then(data => next(data)) .catch(ex => gen.throw(ex)); } try{ return next(); } catch(ex) { return Promise.reject(ex); } } |
測試
經測試,完全符合要求。
小結
非同步流程的控制一直是JavaScript
比較令人頭疼的一點,Generator
的出現無疑是一件囍事,相信隨著ES6的普及以及ES7的推進(ES 7的async
,await
),非同步程式碼那反直覺的編寫方式將一去不復返,編寫和維護非同步程式碼將會越來越容易,JavaScript也將會越來越成熟,受到越來越多人的喜愛。
參考文獻
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式