簡介
JavaScript是一種單線程執行的腳本語言,為了不讓一段JavaScript代碼執行時間過久,阻塞UI的渲染或者是鼠標事件處理,通常會采用一種異步的編程模式。這里就跟大家一起了解一下JavaScript的異步編程模式。
一、JavaScript的異步編程模式
1.1 為什么要異步編程
一開始就說過,JavaScript是一種單線程執行的腳本語言(這可能是由于歷史原因或為了簡單而采取的設計)。它的單線程表現在任何一個函數都要從頭到尾執行完畢之后,才會執行另一個函數,界面的更新、鼠標事件的處理、計時器(setTimeout、setInterval等)的執行也需要先排隊,后串行執行。假如有一段JavaScript從頭到尾執行時間比較長,那么在執行期間任何UI更新都會被阻塞,界面事件處理也會停止響應。這種情況下就需要異步編程模式,目的就是把代碼的運行打散或者讓IO調用(例如AJAX)在后臺運行,讓界面更新和事件處理能夠及時地運行。
下面是一個同步與異步執行的例子(在線測試鏈接http://jsfiddle.net/ghostoy/RPQgj/):
01 |
<div id= "output" ></div> |
03 |
<button onclick= "updateSync ()" >Run Sync</button> |
05 |
<button onclick= "updateAsync ()" >Run Async</button> |
09 |
function updateSync() { |
10 |
for ( var i = 0; i < 1000; i++) { |
11 |
document.getElementById( 'output' ).innerHTML = i; |
15 |
function updateAsync() { |
18 |
function updateLater() { |
19 |
document.getElementById( 'output' ).innerHTML = (i++); |
21 |
setTimeout(updateLater, 0); |
點擊"Run Sync"按鈕會調用updateSync的同步函數,邏輯非常簡單,循環體內每次更新output結點的內容為i。如果在其他多線程模型下的語言,你可能會看到界面上以非常快的速度顯示從0到999后停止。但是在JavaScript中,你會感覺按鈕按下去的時候卡了一下,然后看到一個最終結果999,而沒有中間過程,這就是因為在updateSync函數運行過程中UI更新被阻塞,只有當它結束退出后才會更新UI。如果你讓這個函數的運行時間增加一下(例如把上限改為1 000 000),你會看到更明顯的停頓,在停頓期間點擊另一個按鈕是沒有任何反應的,只有結束之后才會處理另一個按鈕的點擊事件。
另一個按鈕"Run Async"會調用updateAsync函數,它是一個異步函數,乍一看邏輯比較復雜,函數里先聲明了一個局部變量i和嵌套函數updateLater(關于內嵌函數的介紹請看JavaScript世界的一等公民-函數),然后調用了updateLater,在這個函數中先是更新output結點的內容為i,然后通過setTimeout讓updateLater函數異步執行。這個函數的運行后,你會看到UI界面上從0到999快速地更新過程,這就是異步執行的結果。
可見,在JavaScript中異步編程甚至是一種必要的編程模式。
1.2 異步編程的優缺點
異步編程的優點是顯而易見的,異步編程你可以實現前面例子中一邊運行一邊更新的效果;或是利用異步IO讓UI運行更加流暢,比如通過 XMLHTTPRequest的異步接口獲取網絡數據,在獲取完成后再更新界面,在異步獲取數據的時候不會阻礙UI的更新。在眾多HTML5設備API的設計中都充分采用了異步編程模式,例如W3C的File System API、File API、Indexed Database API,Windows 8 API,PhoneGap API,服務端腳本Node JS API等等。
異步編程也有一些缺點,造成深度嵌套的函數調用,破壞了原有的簡單邏輯,讓代碼難以讀懂。
二、異步編程接口設計
2.1 W3C原生接口
W3C原生接口的設計經常采用回調函數和事件觸發形式,前者在調用異步函數時直接傳入回調函數作為參數,后者在原始對象上綁定事件處理函數,異步函數出錯時一般不會拋出異常,而是通過調用錯誤回調函數或觸發錯誤事件。從語義上看,回調函數形式是為了獲取某一個函數的運行結果,而事件觸發形式通常會用于表示某些狀態變化(加載、出錯、進度變化、收到消息等等)。個人或團隊開發小型項目時可以參考這兩種形式的接口設計。
回調函數:例如W3C的File System API中,在請求虛擬文件系統實例、讀寫文件等接口中,都采用了回調函數的形式:
01 |
requestFileSystem(TEMPORARY, 1024 * 1024, function (fs) { |
05 |
fs.root.getFile( "already_there.txt" , null , function (f) { |
事件觸發:例如W3C的XMLHTTPRequest(AJAX)就是一種通過事件觸發這種形式實現,當AJAX請求成功或失敗時觸發onload、onerror事件:
01 |
var xhr = new XMLHTTPRequest(); |
03 |
xhr.onload = function () { |
09 |
xhr.onerror = function () { |
15 |
xhr.open(‘GET ', ‘/get-ajax' , true ); |
2.2 第三方異步接口設計
采用回調函數形式的接口寫代碼,會帶來比較嚴重的函數嵌套問題,就像著名的LISP一樣,引入大量有爭議性的括號,讓本來是前后順序執行的代碼段形式上變成了一層套一層的結構,影響了JavaScript代碼邏輯的清晰性。解決這個問題,要讓邏輯上的先后順序執行的代碼,在形式上也是順序的,而不是嵌套的,這就需要更好的異步接口設計方案。
CommonJS是一個著名的JavaScript的開源組織,目標是設計與JS環境無關的標準接口,并提供像Ruby、Python類似的標準庫函數。在CommonJS中有三個異步編程模式相關的接口提案:Promises/A、Promises/B和Promises/D。Promise,中文意思為承諾,意思就是說承諾完成一個任務,在完成時告之是否執行成功,并返回結果。
這里我們只介紹最簡單的異步接口Promises/A,在使用這種接口的函數時,函數的返回值是一個Promise對象,它有三種狀態:不滿足條件(unfulfilled)、滿足條件(fulfilled)、失敗(failed),顧名思義不滿足條件狀態就是異步函數剛剛調用,尚未真正執行時的狀態,滿足條件就是執行成功時的狀態,失敗就是執行失敗的狀態。它的接口函數也只有一個:
then(fulfilledHandler, errorHandler, progressHandler)
這三個參數分別是滿足條件、失敗以及進度有變化時的回調函數,他們的參數分別對應異步調用的結果,而then的返回值仍然是一個Promise對象,這個對象包含了上一步異步調用回調函數的返回值,因此可以鏈式地寫下去,表現上成為順序執行的邏輯。例如,假如W3C的File System API采用Promises/A的接口設計,2.1節的例子可以寫作:
01 |
requestFileSystem(TEMPORARY, 1024 * 1024) |
07 |
return fs.root.getFile( "already_there.txt" , null ); |
看是不是清楚多了?
實現Promises/A接口的JS庫有很多,比如when.js、node-promise、promised-io等,微軟的Windows 8 Metro應用的接口設計也采用了相同的接口設計,詳見Asynchronious Programming in JavaScript with "Promises"。
2.3 異步同步化
第三方的異步接口一定程度上解決了代碼邏輯與執行順序不一致的問題,但是仍然有些情況下,讓代碼難以讀懂。我們還以1.1節中的代碼為例,updateAsync即使采用Promises API并不會更好理解,而代碼實現的功能其實就是一個很簡單的循環+更新的功能。這時候就需要一些異步同步化來幫助實現。
所謂異步同步化顧名思義就是采用同步形式的語法實現異步調用。這里簡單地介紹一下老趙的Jscex,它是一個純JavaScript實現的庫,可以在任何瀏覽器或JavaScript環境中運行,不僅支持異步同步化的編程語法,還支持并行執行等特性。用Jscex來重寫1.1節中的代碼,將是這樣(在線測試鏈接http://jsfiddle.net/ghostoy/ugxJJ/):
01 |
function updateAsync() { |
02 |
var update = eval(Jscex.compile( 'async' , function () { |
04 |
for ( var i = 0; i < 1000; i++) { |
05 |
document.getElementById( 'output' ).innerHTML = i; |
06 |
$await(Jscex.Async.sleep(0)); |
其中update是用Jscex編譯生成的函數,它會返回一個Jscex的Task對象,通過調用它的start方法來執行這個Task。Update函數的邏輯跟updateSync幾乎一樣,$await是Jscex增加的關鍵字,用于等待一個異步任務的調用結果,Jscex.Async.sleep 是Jscex內建的一個異步任務,用于顯式地等待幾毫秒,加入這行語句之后會被Jscex編譯器生成異步的代碼,實現一邊計算一邊更新UI的效果,代碼結構保持簡潔清楚。
小結
JavaScript的異步編程模式不僅是一種趨勢,而且是一種必要,因此作為HTML5開發者是非常有必要掌握的。采用第三方的異步編程庫和異步同步化的方法,會讓代碼結構相對簡潔,便于維護,推薦開發人員掌握一二,提高團隊開發效率。