很久以來,JavaScript 開發者們習慣用回撥函式的方式來執行一些任務。最常見的例子就是利用 addEventListener()
函式來新增一個回撥函式, 用來在指定的事件(如 click
或 keypress
)被觸發時,執行一系列的操作。回撥函式簡單有效——在邏輯並不複雜的時候。遺憾的是,一旦頁面的複雜度增加,而你因此需要執行很多並行或序列的非同步操作時,這些回撥函式會讓你的程式碼難以維護。
ECMAScript 2015(又名 ECMAScript 6) 引入了一個原生的方法來解決這類問題:promises。如果你還不清楚 promise 是什麼,可以閱讀這篇文章《Javascript Promise概述》。jQuery 則提供了獨具一格的另一種 promises,叫做 Deferred 物件。而且 Deferred 物件的引入時間要比 ECMAScript 引入 promise 早了好幾年。在這篇文章裡,我會介紹 Deferred
物件和它試圖解決的問題是什麼。
Deferred物件簡史
Deferred
物件是在 jQuery 1.5 中引入的,該物件提供了一系列的方法,可以將多個回撥函式註冊進一個回撥佇列裡、呼叫回撥佇列,以及將同步或非同步函式執行結果的成功還是失敗傳遞給對應的處理函式。從那以後,Deferred 物件就成了討論的話題, 其中不乏批評意見,這些觀點也一直在變化。一些典型的批評的觀點如《你並沒有理解 Promise 》和《論 Javascript 中的 Promise 以及 jQuery 是如何把它搞砸的》。
Promise 物件 是和 Deferred 物件一起作為 jQuery 對 Promise 的一種實現。在 jQuery1.x 和 2.x 版本中, Deferred 物件遵守的是《CommonJS Promises 提案》中的約定,而 ECMAScript 原生 promises 方法的建立基礎《Promises/A+ 提案》也是以這一提案書為根基衍生而來。所以就像我們一開始提到的,之所以 Deferred 物件沒有遵循《Promises/A+ 提案》,是因為那時後者根本還沒被構想出來。
由於 jQuery 扮演的先驅者的角色以及後向相容性問題,jQuery1.x 和 2.x 裡 promises 的使用方式和原生 Javascript 的用法並不一致。此外,由於 jQuery 自己在 promises 方面遵循了另外一套提案,這導致它無法相容其他實現 promises 的庫,比如 Q library。
不過即將到來的 jQuery 3 改進了 同原生 promises(在 ECMAScript2015 中實現)的互操作性。雖然為了向後相容,Deferred 物件的主要方法之一(then()
)的方法簽名仍然會有些不同,但行為方面它已經同 ECMAScript 2015 標準更加一致。
jQuery中的回撥函式
舉一個例子來理解為什麼我們需要用到 Deferred
物件。使用 jQuery 時,經常會用到它的 ajax 方法執行非同步的資料請求操作。我們不妨假設你在開發一個頁面,它能夠傳送 ajax 請求給 GitHub API,目的是讀取一個使用者的 Repository 列表、定位到最近更新一個 Repository,然後找到第一個名為“README.md”的檔案並獲取該檔案的內容。所以根據以上描述,每一個請求只有在前一步完成後才能開始。換言之,這些請求必須依次執行。
上面的描述可以轉換成虛擬碼如下(注意我用的並不是真正的 Github API):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var username = 'testuser'; var fileToSearch = 'README.md'; $.getJSON('https://api.github.com/user/' + username + '/repositories', function(repositories) { var lastUpdatedRepository = repositories[0].name; $.getJSON('https://api.github.com/user/' + username + '/repository/' + lastUpdatedRepository + '/files', function(files) { var README = null; for (var i = 0; i < files.length; i++) { if (files[i].name.indexOf(fileToSearch) >= 0) { README = files[i].path; break; } } $.getJSON('https://api.github.com/user/' + username + '/repository/' + lastUpdatedRepository + '/file/' + README + '/content', function(content) { console.log('The content of the file is: ' + content); }); }); }); |
如你所見,使用回撥函式的話,我們需要反覆巢狀來讓 ajax 請求按照我們希望的順序執行。當程式碼裡出現許多巢狀的回撥函式,或者有很多彼此獨立但需要將它們同步的回撥時,我們往往把這種情形稱作“回撥地獄 ( callback hell )“。
為了稍微改善一下,你可以從我建立的匿名函式中提取出命名函式。但這幫助並不大,因為我們還是在回撥的地獄中,依舊面對著回撥巢狀和同步的難題。這時是 Deferred
和 Promise
物件上場的時候了。
Deferred和Promise物件
Deferred 物件可以被用來執行非同步操作,例如 Ajax 請求和動畫的實現。在 jQuery 中,Promise
物件是隻能由Deferred
物件或 jQuery 物件建立。它擁有 Deferred 物件的一部分方法:always()
,done()
, fail()
, state()
和then()
。我們在下一節會講到這些方法和其他細節。
如果你來自於原生 Javascript 的世界,你可能會對這兩個物件的存在感到迷惑:為什麼 jQuery 有兩個物件(Deferred
和 Promise
)而原生JS 只有一個(Promise
)? 在我著作的書《jQuery 實踐(第三版)》裡有一個類比,可以用來解釋這個問題。
Deferred
物件通常用在從非同步操作返回結果的函式裡(返回結果可能是 error,也可能為空)——即結果的生產者函式裡。而返回結果後,你不想讓讀取結果的函式改變 Deferred 物件的狀態(譯者注:包括 Resolved 解析態,Rejected 拒絕態),這時就會用到 promise 物件——即 Promise 物件總在非同步操作結果的消費者函式裡被使用。
為了理清這個概念,我們假設你需要實現一個基於 promise 的timeout()
函式(在本文稍後會展示這個例子的程式碼)。你的函式會等待指定的一段時間後返回(這裡沒有返回值),即一個生產者函式。而這個函式的對應消費者們並不在乎操作的結果是成功(解析態 resolved)還是失敗(拒絕態 rejected),而只關心他們需要在 Deferred 物件的操作成功、失敗,或者收到進展通知後緊接著執行一些其他函式。此外,你還希望能確保消費者函式不會自行解析或拒絕 Deferred物件。為了達到這一目標,你必須在生產者函式timeout()
中建立 Deferred 物件,並只返回它的 Promise 物件,而不是 Deferred物件本身。這樣一來,除了timeout()
函式之外就沒有人能夠呼叫到resolve()
和reject()
進而改變 Deferred 物件的狀態了。
在這個 StackOverflow 問題 裡你可以瞭解到更多關於 jQuery 中 Deferred 和 Promise 物件的不同。
既然你已經瞭解裡這兩個物件,讓我們來看一下它們都包含哪些方法。
Deferred物件的方法
Deferred
物件相當靈活並提供了你可能需要的所有方法,你可以通過呼叫 jQuery.Deferred(
) 像下面一樣建立它:
1 |
var deferred = jQuery.Deferred(); |
或者,使用 $
作為 jQuery 的簡寫:
1 |
var deferred = $.Deferred(); |
建立完 Deferred
物件後,就可以使用它的一系列方法。處了已經被廢棄的 removed 方法外,它們是:
always(callbacks[, callbacks, ..., callbacks])
: 新增在該 Deferred 物件被解析或被拒絕時呼叫的處理函式done(callbacks[, callbacks, ..., callbacks])
: 新增在該 Deferred 物件被解析時呼叫的處理函式fail(callbacks[, callbacks, ..., callbacks])
: 新增在該 Deferred 物件被拒絕時呼叫的處理函式notify([argument, ..., argument])
:呼叫 Deferred 物件上的 progressCallbacks 處理函式並傳遞制定的引數notifyWith(context[, argument, ..., argument])
: 在制定的上下文中呼叫 progressCallbacks 處理函式並傳遞制定的引數。progress(callbacks[, callbacks, ..., callbacks])
: 新增在該 Deferred 物件產生進展通知時被呼叫的處理函式。promise([target])
: 返回 Deferred 物件的 promise 物件。reject([argument, ..., argument])
: 拒絕一個 Deferred 物件並以指定的引數呼叫所有的failCallbacks處理函式。rejectWith(context[, argument, ..., argument])
: 拒絕一個 Deferred 物件並在指定的上下文中以指定引數呼叫所有的failCallbacks處理函式。resolve([argument, ..., argument])
: 解析一個 Deferred 物件並以指定的引數呼叫所有的 doneCallbackswith 處理函式。resolveWith(context[, argument, ..., argument])
: 解析一個 Deferred 物件並在指定的上下文中以指定引數呼叫所有的doneCallbacks處理函式。state()
: 返回當前 Deferred 物件的狀態。then(resolvedCallback[, rejectedCallback[, progressCallback]])
: 新增在該 Deferred 物件被解析、拒絕或收到進展通知時被呼叫的處理函式
從以上這寫方法的描述中,我想突出強調一下 jQuery 文件和 ECMAScript 標準在術語上的不同。在 ECMAScript 中, 不論一個 promise 被完成 (fulfilled) 還是被拒絕 (rejected),我們都說它被解析 (resolved) 了。然而在 jQuery 的文件中,被解析這個詞指的是 ECMAScript 標準中的完成 (fulfilled) 狀態。
由於上面列出的方法太多, 這裡無法一一詳述。不過在下一節會有幾個展示 Deferred
和 Promise
用法的示例。第一個例子中我們會利用Deferred 物件重寫“ jQuery 的回撥函式”這一節的程式碼。第二個例子裡我會闡明之前討論的生產者–消費者這個比喻。
利用 Deferred 依次執行 Ajax 請求
這一節我會利用Deferred
物件和它提供的方法使“jQuery 的回撥函式”這一節的程式碼更具有可讀性。但在一頭扎進程式碼之前,讓我們先搞清楚一件事:在 Deferred 物件現有的方法中,我們需要的是哪些。
根據我們的需求及上文的方法列表,很明顯我們既可以用 done()
也可以通過 then()
來處理操作成功的情況,考慮到很多人已經習慣了使用JS 的原生 Promise
物件,這個示例裡我會用 then()
方法來實現。要注意 then()
和 done()
這兩者之間的一個重要區別是 then()
能夠把接收到的值通過引數傳遞給後續的 then()
,done()
,fail()
或 progress()
呼叫。
所以最後我們的程式碼應該像下面這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
var username = 'testuser'; var fileToSearch = 'README.md'; $.getJSON('https://api.github.com/user/' + username + '/repositories') .then(function(repositories) { return repositories[0].name; }) .then(function(lastUpdatedRepository) { return $.getJSON('https://api.github.com/user/' + username + '/repository/' + lastUpdatedRepository + '/files'); }) .then(function(files) { var README = null; for (var i = 0; i < files.length; i++) { if (files[i].name.indexOf(fileToSearch) >= 0) { README = files[i].path; break; } } return README; }) .then(function(README) { return $.getJSON('https://api.github.com/user/' + username + '/repository/' + lastUpdatedRepository + '/file/' + README + '/content'); }) .then(function(content) { console.log(content); }); |
如你所見,由於我們能夠把整個操作拆分成同在一個縮排層級的各個步驟,這段程式碼的可讀性已經顯著提高了。
建立一個基於 Promise 的 setTimeout 函式
你可能已經知道 setTimeout() 函式可以在延遲一個給定的時間後執行某個回撥函式,只要你把時間和回撥函式作為引數傳給它。假設你想要在一秒鐘後在控制檯列印一條日誌資訊,你可以用它這樣寫:
1 2 3 4 5 6 |
setTimeout( function() { console.log('等待了1秒鐘!'); }, 1000 ); |
如你所見,setTimeout
的第一個引數是要執行的回撥函式,第二個引數是以毫秒為單位的等待時間。這個函式數年以來運轉良好,但如果現在你需要在 Deferred
物件的方法鏈中引入一段時間的延時該怎麼做呢?
下面的程式碼展示瞭如何用 jQuery 提供的 Promise
物件建立一個基於 promise 的 setTimeout()
. 為了達到我們的目的,這裡用到了 Deferred物件的 promise()
方法。
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function timeout(milliseconds) { //建立一個新Deferred var deferred = $.Deferred(); // 在指定的毫秒數之後解析Deferred物件 setTimeout(deferred.resolve, milliseconds); // 返回Deferred物件的Promise物件 return deferred.promise(); } timeout(1000).then(function() { console.log('等待了1秒鐘!'); }); |
這段程式碼裡定義了一個名為 timeout()
的函式,它包裹在 JS 原生的 setTimeout()
函式之外。
在 timeout()
裡, 建立了一個 Deferred
物件來實現在延遲指定的毫秒數之後將 Deferred 物件解析(Resolve)的功能。這裡 timeout()
函式是值的生產者,因此它負責建立 Deferred
物件並返回 Promise
物件。這樣一來呼叫者(消費者)就不能再隨意解析或拒絕 Deferred 物件。事實上,呼叫者只能通過 done()
和 fail()
這樣的方法來增加值返回時要執行的函式。
jQuery 1.x/2.x同 jQuery3 的區別
在第一個例子裡,我們使用 Deferred
物件來查詢名字包含“README.md”的檔案, 但並沒有考慮檔案找不到的情況。這種情形可以被看成是操作失敗,而當操作失敗時,我們可能需要中斷呼叫鏈的執行並直接跳到程式結尾。很自然地,為了實現這個目的,我們應該在找不到檔案時丟擲一個異常,並用 fail()
函式來捕獲它,就像 Javascriopt 的 catch()
的用法一樣。
在遵守 Promises/A 和 Promises/A+ 的庫裡(例如jQuery 3.x),丟擲的異常會被轉換成一個拒絕操作 (rejection),進而通過 fail()
方法新增的失敗條件回撥函式會被執行,且丟擲的異常會作為引數傳給這些函式。
在 jQuery 1.x 和 2.x中, 沒有被捕獲的異常會中斷程式的執行。這兩個版本允許丟擲的異常向上冒泡,一般最終會到達 window.onerror
。而如果沒有定義異常的處理程式,異常資訊就會被顯示,同時程式也會停止執行。
為了更好的理解這一行為上的區別,讓我們看一下從我書裡摘出來的這一段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
var deferred = $.Deferred(); deferred .then(function() { throw new Error('一條錯誤資訊'); }) .then( function() { console.log('第一個成功條件函式'); }, function() { console.log('第一個失敗條件函式'); } ) .then( function() { console.log('第二個成功條件函式'); }, function() { console.log('第二個失敗條件函式'); } ); deferred.resolve(); |
jQuery 3.x 中, 這段程式碼會在控制檯輸出“第一個失敗條件函式” 和 “第二個成功條件函式”。原因就像我前面提到的,丟擲異常後的狀態會被轉換成拒絕操作進而失敗條件回撥函式一定會被執行。此外,一旦異常被處理(在這個例子裡被失敗條件回撥函式傳給了第二個then()),後面的成功條件函式就會被執行(這裡是第三個 then()
裡的成功條件函式)。
在 jQuery 1.x 和 2.x 中,除了第一個函式(丟擲錯誤異常的那個)之外沒有其他函式會被執行,所以你只會在控制檯裡看到“未處理的異常:一條錯誤資訊。”
你可以到下面兩個JSBin連結中檢視它們的執行結果的不同:
為了更好的改善它同 ECMAScript2015 的相容性,jQuery3.x 還給 Deferred
和 Promise
物件增加了一個叫做 catch()
的新方法。它可以用來定義當 Deferred
物件被拒絕或 Promise
物件處於拒絕態時的處理函式。它的函式簽名如下:
1 |
deferred.catch(rejectedCallback) |
可以看出,這個方法不過是 then(null, rejectedCallback)
的一個快捷方式罷了。
總結
這篇文章裡我介紹了 jQuery 實現的 promises。Promises 讓我們能夠擺脫那些用來同步非同步函式的令人抓狂的技巧,同時避免我們陷入深層次的回撥巢狀之中。
除了展示一些示例,我還介紹了 jQuery 3 在同原生 promises 互操作性上所做的改進。儘管我們強調了 jQuery 的老版本同ECMAScript2015 在 Promises 實現上有許多不同,Deferred
物件仍然是你工具箱裡一件強有力的工具。作為一個職業開發人員,當專案的複雜度增加時,你會發現它總能派上用場。
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式