當前位置:才華齋>設計>網頁設計>

關於非同步JavaScript程式設計中的Promise使用方法

網頁設計 閱讀(1.54W)

非同步?

關於非同步JavaScript程式設計中的Promise使用方法

我在很多地方都看到過非同步(Asynchronous)這個詞,但在我還不是很理解這個概念的時候,卻發現自己常常會被當做“已經很清楚”(* ̄? ̄)。

如果你也有類似的情況,沒關係,搜尋一下這個詞,就可以得到大致的說明。在這裡,我會對JavaScript的非同步做一點額外解釋。

看一下這段程式碼:

var start = new Date();setTimeout(function(){ var end = new Date(); ("Time elapsed: ", end - start, "ms");}, 500);while (new Date - start < 1000) {};

這段程式碼執行後會得到類似Time elapsed: 1013ms這樣的結果。 setTimeout()所設定的在未來500ms時執行的函式,實際等了比1000ms更多的時間後才執行。

要如何解釋呢?呼叫setTimeout()時,一個延時事件被排入佇列。然後,繼續執行這之後的程式碼,以及更後邊的程式碼,直到沒有任何程式碼。沒有任何程式碼後,JavaScript執行緒進入空閒,此時JavaScript執行引擎才去翻看佇列,在佇列中找到“應該觸發”的事件,然後呼叫這個事件的處理器(函式)。處理器執行完成後,又再返回到佇列,然後檢視下一個事件。

單執行緒的JavaScript,就是這樣通過佇列,以事件迴圈的形式工作的。所以,前面的程式碼中,是用while將執行引擎拖在程式碼執行期間長達1000ms,而在全部程式碼執行完回到佇列前,任何事件都不會觸發。這就是JavaScript的非同步機制。

JavaScript的非同步難題

JavaScript中的非同步操作可能不總是簡單易行的。

Ajax也許是我們用得最多的非同步操作。以jQuery為例,發起一個Ajax請求的程式碼一般是這樣的:

// Ajax請求示意程式碼$({ url: url, data: dataObject, success: function(){}, error: function(){}});

這樣的寫法有什麼問題嗎?簡單來說,不夠輕便。為什麼一定要在發起請求的地方,就要把success和error這些回撥給寫好呢?假如我的回撥要做很多很多的事情,是要我想起一件事情就跑回這裡新增程式碼嗎?

再比如,我們要完成這樣一件事:有4個供Ajax訪問的url地址,需要先Ajax訪問第1個,在第1個訪問完成後,用拿到的返回資料作為引數再訪問第2個,第2個訪問完成後再第3個...以此到4個全部訪問完成。按照這樣的寫法,似乎會變成這樣:

$({ url: url1, success: function(data){ $({ url: url2, data: data, success: function(data){ $({ //... }); } }); }})

你一定會覺得這種稱為Pyramid of Doom(金字塔厄運)的程式碼看起來很糟糕。習慣了直接附加回調的寫法,就可能會對這種一個傳遞到下一個的非同步事件感到無從入手。為這些回撥函式分別命名並分離存放可以在形式上減少巢狀,使程式碼清晰,但仍然不能解決問題。

另一個常見的難點是,同時傳送兩個Ajax請求,然後要在兩個請求都成功返回後再做一件接下來的事,想一想如果只按前面的方式在各自的呼叫位置去附加回調,這是不是好像也有點難辦?

適於應對這些非同步操作,可以讓你寫出更優雅程式碼的就是Promise。

Promise上場

Promise是什麼呢?先繼續以前面jQuery的Ajax請求示意程式碼為例,那段程式碼其實可以寫成這個樣子:

var promise = $({ url: url, data: dataObject});(function(){});(function(){});

這和前面的Ajax請求示意程式碼是等效的。可以看到,Promise的加入使得程式碼形式發生了變化。Ajax請求就好像變數賦值一樣,被“儲存”了起來。這就是封裝,封裝將真正意義上讓非同步事件變得容易起來。

封裝是有用的

Promise物件就像是一個封裝好的對非同步事件的引用。想要在這個非同步事件完成後做點事情?給它附加回調就可以了,不管附加多少個也沒問題!

jQuery的Ajax方法會返回一個Promise物件(這是jQuery1.5重點增加的特性)。如果我有do1()、do2()兩個函式要在非同步事件成功完成後執行,只需要這樣做:

(do1);// Other code (do2);

這樣可要自由多了,我只要儲存這個Promise物件,就在寫程式碼的任何時候,給它附加任意數量的回撥,而不用管這個非同步事件是在哪裡發起的。這就是Promise的優勢。

正式的介紹

Promise應對非同步操作是如此有用,以至於發展為了CommonJS的一個規範,叫做Promises/A。Promise代表的是某一操作結束後的返回值,它有3種狀態:

肯定(fulfilled或resolved),表明該Promise的操作成功了。 否定(rejected或failed),表明該Promise的操作失敗了。 等待(pending),還沒有得到肯定或者否定的結果,進行中。

此外,還有1種名義上的狀態用來表示Promise的操作已經成功或失敗,也就是肯定和否定狀態的集合,叫做結束(settled)。Promise還具有以下重要的`特性:

一個Promise只能從等待狀態轉變為肯定或否定狀態一次,一旦轉變為肯定或否定狀態,就再也不會改變狀態。 如果在一個Promise結束(成功或失敗,同前面的說明)後,新增針對成功或失敗的回撥,則回撥函式會立即執行。

想想Ajax操作,發起一個請求後,等待著,然後成功收到返回或出現錯誤(失敗)。這是否和Promise相當一致?

進一步解釋Promise的特性還有一個很好的例子:jQuery的$(document)y(onReady)。其中onReady回撥函式會在DOM就緒後執行,但有趣的是,如果在執行到這句程式碼之前,DOM就已經就緒了,那麼onReady會立即執行,沒有任何延遲(也就是說,是同步的)。

Promise示例

生成Promise

Promises/A裡列出了一系列實現了Promise的JavaScript庫,jQuery也在其中。下面是用jQuery生成Promise的程式碼:

var deferred = $rred();(function(message){("Done: " + message)});lve("morin"); // Done: morin

jQuery自己特意定義了名為Deferred的類,它實際上就是Promise。$rred()方法會返回一個新生成的Promise例項。一方面,使用()、()等為它附加回調,另一方面,呼叫lve()或ct()來肯定或否定這個Promise,且可以向回撥傳遞任意資料。

合併Promise

還記得我前文說的同時傳送2個Ajax請求的難題嗎?繼續以jQuery為例,Promise將可以這樣解決它:

var promise1 = $(url1),promise2 = $(url2),promiseCombined = $(promise1, promise2);(onDone);

$()方法可以合併多個Promise得到一個新的Promise,相當於在原多個Promise之間建立了AND(邏輯與)的關係,如果所有組成Promise都已成功,則令合併後的Promise也成功,如果有任意一個組成Promise失敗,則立即令合併後的Promise失敗。

級聯Promise

再繼續我前文的依次執行一系列非同步任務的問題。它將用到Promise最為重要的()方法(在Promises/A規範中,也是用“有then()方法的物件”來定義Promise的)。程式碼如下:

var promise = $(url1);promise = (function(data){ return $(url2, data);});promise = (function(data){ return $(url3, data);});// ...

Promise的()方法的完整形式是(onDone, onFail, onProgress),這樣看上去,它像是一個一次性就可以把各種回撥都附加上去的簡便方法(()、()可以不用了)。沒錯,你的確可以這樣使用,這是等效的。

但()方法還有它更為有用的功能。如同then這個單詞本身的意義那樣,它用來清晰地指明非同步事件的前後關係:“先這個,然後(then)再那個”。這稱為Promise的級聯。

要級聯Promise,需要注意的是,在傳遞給then()的回撥函式中,一定要返回你想要的代表下一步任務的Promise(如上面程式碼的$(url2, data))。這樣,前面被賦值的那個變數才會變成新的Promise。而如果then()的回撥函式返回的不是Promise,則then()方法會返回最初的那個Promise。

應該會覺得有些難理解?從程式碼執行的角度上說,上面這段帶有多個then()的程式碼其實還是被JavaScript引擎執行一遍就結束。但它就像是寫好的舞臺劇的劇本一樣,讀過一遍後,JavaScript引擎就會在未來的時刻,依次安排演員按照劇本來演出,而演出都是非同步的。then()方法就是讓你能寫出非同步劇本的筆。

將Promise用在基於回撥函式的API

前文反覆用到的$()方法會返回一個Promise物件,這其實只是jQuery特意提供的福利。實際情況是,大多數JavaScript API,包括中的原生函式,都基於回撥函式,而不是基於Promise。這種情況下使用Promise會需要自行做一些加工。

這個加工其實比較簡單和直接,下面是例子:

var deferred = $rred();setTimeout(lve, 1000);(onDone);

這樣,將Promise的肯定或否定的觸發器,作為API的回撥傳入,就變成了Promise的處理模式了。

Promise是怎麼實現出來的?

本文寫Promise寫到這裡,你發現了全都是基於已有的實現了Promise的庫。那麼,如果要自行構築一個Promise的話呢?

位列於Promises/A的庫列表第一位的Q可以算是最符合Promises/A規範且相當直觀的實現。如果你想了解如何做出一個Promise,可以參考Q提供的設計模式解析。

限於篇幅,本文只介紹Promise的應用。我會在以後單獨開一篇文章來詳述Promise的實現細節。

作為JavaScript後續版本的ECMAScript 6將原生提供Promise,如果你想知道它的用法,推薦閱讀JavaScript Promises: There and back again。

結語

Promise這個詞頑強到不適合翻譯,一眼之下都會覺得意義不明。不過,在JavaScript裡做比較複雜的非同步任務時,它的確可以提供相當多的幫助。