使用Jscex改善JavaScript異步編程體驗
JavaScript是互聯(lián)網時代編程語言的霸主,統(tǒng)領瀏覽器至今已有許多年頭,而這股風潮很可能隨著HTML 5的興起而愈演愈烈。如今JavaScript更是在Node.js的幫助下進軍服務器編程領域。“單線程”和“無阻塞”是JavaScript的天性,因此任何需要“耗時”的操作,例如等待、網絡通信、磁盤IO都只能提供“異步”的編程接口。盡管這對服務器的伸縮性和客戶端的響應能力都大有脾益,但是異步接口在使用上要比傳統(tǒng)的線性編程困難許多,因此也誕生了如jQuery Deferred這樣的輔助類庫。Jscex的主要目的也是簡化異步編程,但它使用了一種與傳統(tǒng)輔助類庫截然不同的方式,盡可能地將異步編程體驗帶領到新的高度。
開放平臺之實踐——來自百度、360、騰訊、盛大的案例分享JavaScript編程幾乎總是伴隨著異步操作,傳統(tǒng)的異步操作會在操作完成之后,使用回調函數(shù)傳回結果,而回調函數(shù)中則包含了后續(xù)的工作。這也是造成異步編程困難的主要原因:我們一直習慣于“線性”地編寫代碼邏輯,但是大量異步操作所帶來的回調函數(shù),會把我們的算法分解地支離破碎。此時我們不能用if來實現(xiàn)邏輯分支,也不能用while/for/do來實現(xiàn)循環(huán),更不用提異步操作之間的組合、錯誤處理以及取消操作了。
快速入門:排序動畫
我們先來看一個簡單的例子?!懊芭菖判颉笔亲畛R姷呐判蛩惴ㄖ?,它的JavaScript實現(xiàn)如下:
- var compare = function (x, y) {
 - return x - y;
 - }
 - var swap = function (array, i, j) {
 - var t = array[i];
 - array[i] = array[j];
 - array[j] = t;
 - }
 - var bubbleSort = function (array) {
 - for (var i = 0; i < array.length; i++) {
 - for (var j = 0; j < array.length - i; j++) {
 - if (compare(array[j], array[j + 1]) > 0) {
 - swap(array, j, j + 1);
 - }
 - }
 - }
 - }
 
由于某些原因——例如教學所需,我們希望能夠通過動畫來直觀地感受不同排序算法之間的差異。將一個排序算法改寫為動畫效果的“基本策略”十分簡單:
◆ 在每次元素“交換”和“比較”操作時暫停一小會兒(因為它們是排序算法的主要耗時所在)。
◆ 在元素“交換”過后重繪圖像。
只需增加這樣兩個“簡單”的功能,便可以形成算法的動畫效果。但實際上,實現(xiàn)這個策略并沒有聽上去那么容易。在其它許多語言或是運行環(huán)境中,我們可以使用sleep方法來暫停當前線程。這對代碼的邏輯結構的影響極小。但是在JavaScript中,我們只有setTimeout可以做到“延遲”執(zhí)行某個操作。setTimeout需要與回調函數(shù)配合使用,但這會嚴重破壞算法的邏輯結構,例如,我們再也無法使用for來實現(xiàn)哪怕是最最簡單的循環(huán)操作了。因此,排序算法的動畫似乎只能這么寫:
- // 異步操作簽名約定:
 - // function (arg1, arg2, ..., callback) {
 - // 異步操作完成后使用callback回傳結果
 - // }
 - var compareAsync = function (x, y, callback) {
 - // 延遲10毫秒返回結果
 - setTimeout(10, function () {
 - callback(x - y);
 - });
 - }
 - var swapAsync = function (a, i, j, callback) {
 - // 交換元素
 - var t = a[i]; a[i] = a[j]; a[j] = t;
 - // 重繪
 - repaint(a);
 - // 延遲20毫秒才返回
 - setTimeout(20, callback);
 - }
 - // 外部循環(huán),從下標為i的元素開始處理
 - var outerLoopAsync = function (array, i, callback) {
 - // 如果i還在數(shù)組長度范圍內
 - if (i < array.length) {
 - // 則進入內部循環(huán),與下標為i的元素進行比較
 - innerLoopAsync(array, i, 0, function () {
 - // 內部循環(huán)結束以后,在外部循環(huán)中處理i的下一個元素
 - outerLoopAsync(array, i + 1, callback);
 - });
 - } else {
 - // i超出數(shù)組長度,表示外層循環(huán)結束
 - callback();
 - }
 - }
 - // 內部循環(huán),從下標j開始,與下標為i的元素進行比較
 - var innerLoopAsync = function (array, i, j, callback) {
 - // 如果j在合適范圍內
 - if (j < array.length - i) {
 - // 則比較下標j及其相鄰元素
 - compareAsync(array[j], array[j + 1], function (r) {
 - // 如果次序不對
 - if (r > 0) {
 - // 則交換及其相鄰元素
 - swapAsync(array, j, j + 1, function () {
 - // 交換之后,則重復內層循環(huán)比較下標j的下一個元a素
 - innerLoopAsync(array, i, j + 1, callback);
 - });
 - } else {
 - // 假如次序已經正確·,則直接重復內存循環(huán)比較下標j的下一個元a素
 - innerLoopAsync(array, i, j + 1, callback);
 - }
 - });
 - } else {
 - // j已經超出范圍,一個元素已經處于合適的位置,內層循環(huán)結束
 - callback();
 - }
 - }
 - // 冒泡排序主方法
 - var bubbleSortAsync = function (array, callback) {
 - // 從***個元素開始執(zhí)行外部循環(huán),
 - // 外部循環(huán)結束則意味著排序完畢
 - outerLoop(array, 0, callback || function () { });
 - }
 - // 調用
 - var array = ...; // 初始化數(shù)組
 - bubbleSortAsync(array);
 
相信您也可以看得出來,如果使用傳統(tǒng)回調的方式來實現(xiàn)一個冒泡排序動畫會有多么麻煩。而“支離破碎”所導致的更嚴重的問題,是代碼“語義”方面的損失。例如,新來一位開發(fā)人員想要維護這段代碼,他能夠看出上面這段代碼是“冒泡排序”嗎?如果您給出“冒泡排序”的動畫,又能輕易地將算法“說明”給別人理解嗎?如果需要簡單補充一些功能,又該將新代碼添加在何處?使用傳統(tǒng)線性編程的優(yōu)勢之一,在于容易快速編寫出邏輯清晰而“內聚”的實現(xiàn),即使需要補充一些功能,則可以通過局部變量將狀態(tài)修改控制至極小。我們幾乎可以這么說,基于回調函數(shù)的異步編程,讓許多傳統(tǒng)程序設計中總結出來的實踐與模式付諸東流。
不過有了Jscex以后世界便大不一樣了,它將編程體驗變得“如初見般美好”:
- // 異步的比較操作
 - var compareAsync = eval(Jscex.compile("async", function (x, y) {
 - $await(Jscex.Async.sleep(10)); // 等待10毫秒
 - return x - y;
 - }));
 - // 異步的交換操作
 - var swapAsync = eval(Jscex.compile("async", function (array, i, j) {
 - var t = array[i];
 - array[i] = array[j];
 - array[j] = t;
 - repaint(array); // 重繪
 - $await(Jscex.Async.sleep(20)); // 等待20毫秒
 - }));
 - // 異步的冒泡排序
 - var bubbleSortAsync = eval(Jscex.compile("async", function (array) {
 - for (var i = 0; i < array.length; i++) {
 - for (var j = 0; j < array.length - i; j++) {
 - // 執(zhí)行異步的比較操作
 - var r = $await(compareAsync(array[j], array[j + 1]));
 - if (r > 0) {
 - // 執(zhí)行異步的交換操作
 - $await(swapAsync(array, j, j + 1));
 - }
 - }
 - }
 - }));
 - // 調用
 - var array = ...; // 初始化數(shù)組
 - bubbleSortAsync(array).start();
 
以上這段代碼幾乎不用做任何解釋,因為它完全便是在標準的“冒泡排序”算法之上,增加了之前所提到的“基本策略”。這便是Jscex改進異步編程體驗的手段:程序員編寫最自然的代碼,并使用$await來執(zhí)行其中的異步操作。Jscex提供的編譯器(即compile方法)會將一個普通的JavaScript函數(shù)編譯為“回調函數(shù)”組織起來的異步實現(xiàn),做到“線性編碼,異步執(zhí)行”的效果。
您可以在此觀察冒泡排序的動畫效果(需要IE9,Chrome,F(xiàn)irefox等支持Canvas的瀏覽器)。這張頁面里還實現(xiàn)了選擇排序和快速排序算法的動畫,都是基于Jscex的優(yōu)雅實現(xiàn)。如果您感興趣,也可以使用傳統(tǒng)的、基于回調的方式來編寫這些算法動畫,然后跟頁面中的代碼實現(xiàn)進行對比,便可以更好地了解Jscex的優(yōu)勢。
使用Jscex開發(fā)異步程序
Jscex可以在任何支持JavaScript(ECMAScript 3)的運行環(huán)境里執(zhí)行,例如,包括IE 6在內的現(xiàn)代瀏覽器,服務器端的Node.js,以及如Rhino一樣的JavaScript引擎等等,它們的區(qū)別僅僅在于“引入Jscex腳本文件”的方式不同而已。Jscex模塊化十分細致,在使用時需要引入不少文件,部分原因也是由于JavaScript環(huán)境至今還缺少一個包管理機制所造成的:
◆ lib/json2.js:由Douglas Crockfod編寫的JSON生成器,對于原生不支持JSON.stringify方法的JavaScript環(huán)境(例如早期版本的IE),則需要引入該文件。
◆ lib/uglifyjs-parser.js:UglifyJS項目(jQuery項目官方使用的壓縮工具)所使用的的JavaScript解析器,這是LISP項目parse-js的 JavaScript 移植,它負責Jscex中的語法解析工作。
◆ src/jscex.js:JIT編譯器實現(xiàn),負責在運行時生成代碼。這也是Jscex.compile方法的具體實現(xiàn)所在。
以上三個文件構成了Jscex的編譯器核心,它們只需在開發(fā)環(huán)境中使用(例如在頁面引用它們),目的只是為了提供近乎原生JavaScript的開發(fā)體驗。對于Jscex來說,它的首要原則(沒有之一)便是“保證JavaScript程序員的傳統(tǒng)開發(fā)體驗”。而對于開發(fā)和生產環(huán)境都必不可少的只有以下兩個文件:
◆ src/jscex.builderBase.js:Jscex中“構造器”的公用部分。
◆ src/jscex.async.js:Jscex的“異步構造器”,用于支持異步程序開發(fā)。
這兩個文件在精簡和gzip之后,只有3KB左右大小,幾乎不會給應用程序帶來什么影響。
如果您要編寫一個Jscex異步函數(shù),則只需要將一個普通的函數(shù)定義放到一段“架子”代碼中即可:
- // 普通函數(shù)
 - var giveMeFive = function (arg0, arg1, ..., argN) {
 - // 實現(xiàn)
 - return 5;
 - };
 - // Jscex異步函數(shù)
 - var giveMeFiveAsync = eval(Jscex.compile("async", function (arg0, arg1, ..., argN) {
 - // 實現(xiàn)
 - return 5;
 - }));
 
Jscex.compile方法會根據它獲得的“構造器名稱(即async)”和“函數(shù)對象”生成其對應的“新函數(shù)”的代碼,而這段代碼會立即被eval執(zhí)行。這段“架子代碼”看上去略顯冗余,如果您覺得輸入麻煩也可以將其保存為編輯器的“代碼片段(Code Snippet)”,因為它在Jscex使用過程中幾乎不會有任何變化,我們也無需過于關注其含義。
“架子代碼”的另一個作用是“區(qū)分”普通函數(shù)和異步函數(shù)。例如上面的代碼中,giveMeFive會返回5,但giveMeFiveAsync在執(zhí)行后返回的其實是一個“將會返回5”的Future對象——在Jscex中我們將其稱為“任務”。除非我們通過start方法啟動這個任務(Jscex異步函數(shù)中使用$await操作在需要時會調用start方法),則函數(shù)里的代碼永遠不會執(zhí)行。因此,普通函數(shù)和異步函數(shù)在功能、含義和表現(xiàn)上都有不同,而通過“架子代碼”的便能很方便地把它們區(qū)分開來。
在一個Jscex異步函數(shù)中,我們用$await操作來表示“等待任務返回結果(或出錯),如果它還未執(zhí)行,則同時啟動這個任務”。$await的參數(shù)是一個Jscex任務對象,我們可以把任意的異步操作輕松地封裝為一個Jscex任務。例如在Jscex的異步類庫中就內置了Jscex.Async.sleep函數(shù),它封裝了setTimeout函數(shù)。顯然,執(zhí)行任何一個Jscex異步函數(shù),您都可以得到這樣一個標準的異步任務對象。
除了在Jscex異步函數(shù)中通過$await來操作之外,我們也可以手動調用任務的start方法來啟動一個任務。Jscex異步任務模型雖然簡單,但它是Jscex異步編程的基石,它讓“編譯器”的核心功能變得小巧、簡單和緊湊,許多功能以及使用模式都能在“類庫”層面擴展出來。在今后的文章中,我們也會了解如何將一個異步操作封裝為Jscex任務,以及圍繞這個任務模型進行開發(fā)和擴展。
平易近人的編譯器和eval
從我之前的經驗來看,一些朋友可能會被“編譯器”的字樣嚇到,認為Jscex是一個“重型”的解決方案。還有一些朋友在腦海里深深印有“eval很邪惡”的印象,于是同樣望而卻步。其實這些都是對Jscex的誤解,這里我打算著重解釋一下這方面的問題。
如今“編譯器”其實并不是什么特別神秘的東西,事實上可能您早就在使用針對JavaScript的編譯器了。例如,Google的Closure Compiler便是這樣一個東西。Closure Compiler會接受一段JavaScript代碼,并輸出其“等價”并“精簡”后的代碼。Closure Compiler的作用是“減小文件體積”,而Jscex的作用便是將一個JavaScript函數(shù)轉化成一個新的函數(shù),以符合某些場景(如異步編程)的需要而已。另一方面,Jscex的轉換操作也涉及代碼解析,語法樹的優(yōu)化以及新代碼的輸出,因此無論從功能還是從實現(xiàn)角度來說,Jscex的核心都是一個標準的“編譯器”。
傳統(tǒng)的編譯器往往會給開發(fā)人員在代碼執(zhí)行之前增加一個額外步驟(編譯),這對編程體驗是一種損害。JavaScript程序員往往習慣于“修改后刷新頁面”便能立即看到結果,但是如某些將C#或Java語言轉化為JavaScript的解決方案,往往都需要開發(fā)人員在“刷新頁面”之前重新生成一遍JavaScript代碼。Jscex則不然,正如之前提到的那樣,Jscex的首要原則是“盡可能保證JavaScript程序員的傳統(tǒng)開發(fā)體驗”。Jscex編譯器的一大特色,便是“在運行時生成代碼”。Jscex只是JavaScript開發(fā)中所使用的類庫,它幾乎不會對“JavaScript編程”本身有任何改變。換句話說,開發(fā)人員編寫的就是JavaScript代碼,它的載體就是普通的JavaScript文件,文件加載也好,代碼執(zhí)行行為也罷,都和普通的JavaScript開發(fā)一樣。當您修改了Jscex異步函數(shù)的實現(xiàn)之后,Jscex.compile方法在代碼執(zhí)行時自然會生成新的函數(shù)代碼,因此并不會給開發(fā)人員增加任何額外負擔。
Jscex.compile生成的代碼會由eval執(zhí)行,有朋友會認為這么做會影響性能或是安全性。但事實上,無論是eval還是Jscex.compile,都只是為了保證開發(fā)過程中的體驗(修改后立即生效)。真正在生產環(huán)境里執(zhí)行的代碼,是不會出現(xiàn)eval和Jscex.compile的,因為Jscex還提供了一個AOT編譯器(相對于在運行時生成代碼的JIT編譯器而言)。
AOT編譯器也是一段JavaScript代碼,使用Node.js執(zhí)行。使用方法為:
- node scripts/jscexc.js --input input_file --output output_file
 
AOT編譯器會靜態(tài)分析輸入的腳本文件,找出其中的eval與Jscex.compile函數(shù)調用,直接將“動態(tài)編譯”的結果寫入eval處。例如compareAsync的原始代碼:
- var compareAsync = eval(Jscex.compile("async", function (x, y) {
 - $await(Jscex.Async.sleep(10));
 - return x - y;
 - }));
 
編譯后的代碼便會成為如下形式,目前您無需理解這段代碼的含義。Jscex對最終編譯輸出的代碼經過精心設計,盡可能地讓其保留可讀性及可調式性,這點在今后的文章中也會加以說明和演示。
- var compareAsync = (function (x, y) {
 - var $_b = Jscex.builders["async"];
 - return $_b.Start(this,
 - $_b.Delay(function () {
 - return $_b.Bind(Jscex.Async.sleep(10), function () {
 - return $_b.Return(x - y);
 - });
 - })
 - );
 - });
 
原始代碼在經過AOT編譯之后,不僅在運行性能方面有所提高(節(jié)省了編譯和動態(tài)執(zhí)行的開銷,并可以在ECMAScript 5的Strict Mode下運行),還能讓代碼擺脫Jscex編譯器執(zhí)行。在排除了編譯器代碼之后,Jscex的異步類庫在精簡和壓縮后只有3KB左右大小,十分適合互聯(lián)網產品使用。
總結
異步編程的困難有目共睹,因此為了輔助異步程序開發(fā)出現(xiàn)過許多嘗試。在JavaScript編程領域,大部分解決方案都是設法通過提供一些API來更好地組織錯綜復雜的回調函數(shù),但Jscex走的是另外一條道路。Jscex的目的,是希望盡可能保留JavaScript程序員原有的編程習慣及邏輯組織方式,讓“編譯器”來生成那些包含了回調函數(shù)的代碼。類似的功能已經在F#和Scala中獲得了成功,也即將在下個版本的C#里出現(xiàn),而Jscex則是將其引入至JavaScript編程中。
Jscex基于BSD授權協(xié)議開源,代碼庫放置在GitHub上,并同步至SNDA Code。
原文:http://www.infoq.com/cn/articles/jscex-javascript-asynchronous-programming
【編輯推薦】















 
 
 






 
 
 
 