Ajax 出現的時候,刮來了一陣異步之風,現在 Nodejs 火爆,又一陣異步狂風刮了過來。需求是越來越苛刻,用戶對性能的要求也是越來越高,隨之而來的是頁面異步操作指數般增長,如果不能恰當的控制代碼邏輯,我們就會陷入無窮的回調地獄中。
ECMAScript 6 已經將異步操作納入了規范,現代瀏覽器也內置了 Promise 對象供我們進行異步編程,那么此刻,還在等啥?趕緊學習學習 Promise 的內部原理吧!
第一章 了解 Promise
一、場景再現
由於 javascript 的單線程性質,我們必須等待上一個事件執行完成才能處理下一步,如下:
// DOM ready之后執行 $(document).ready(function(){ // 獲取模板 $.get(url, function(tpl){ // 獲取數據 $.get(url2, function(data){ // 構建 DOMString makeHtml(tpl, data, function(str){ // 插入到 DOM 中 $(obj).html(str); }); }); }); });
為了減少首屏數據的加載,我們將一些模板和所有數據都放在服務器端,當用戶操作某個按鈕時,需要將模板和數據拼接起來插入到 DOM 中,這個過程還必須在 DOMReady 之后才能執行。這種情況是十分常見的,如果異步操作再多一些,整個代碼的縮進讓人看着很不舒服,為了優雅地處理這個問題,ECMAScript 6 引入了 Promise 的概念,目前一些現代瀏覽器已經支持這些新東西了!
二、模型
為了讓代碼流程更加清晰,我們假想着能夠按照下面的流程來跑程序:
new Promise(ready).then(getTpl).then(getData).then(makeHtml).resolve();
先將要事務按照執行順序依次 push 到事務隊列中,push 完了之后再通過 resolve 函數啟動整個流程。
整個流程的操作模型如下:
promise(ok).then(ok_1).then(ok_2).then(ok_3).reslove(value)------+
| | | | |
| | | | +=======+ |
| | | | | | |
| | | | | | |
+---------|----------|----------|--------→ ok() ←------+
| | | | ↓ |
| | | | ↓ |
+----------|----------|--------→ ok_1()|
| | | ↓ |
| | | ↓ |
+----------|--------→ ok_2()|
| | ↓ |
| | ↓ |
+--------→ ok_3()-----+
| | |
| | ↓
@ Created By Barret Lee +=======+ exit
在 resolve 之前,promise 的每一個 then 都會將回調函數壓入隊列,resolve 后,將 resolve 的值送給隊列的第一個函數,第一個函數執行完畢后,將執行結果再送入下一個函數,依次執行完隊列。一連串下來,一氣呵成,沒有絲毫間斷。
三、簡單的封裝
如果了解 Promise,可以移步下方,看看對 Promise 的封裝:
Github: https://github.com/barretlee/myPromise
DEMO: http://barretlee.github.io/myPromise/index.html
如果還不是很了解,可以往下閱讀全文,了解一二。
第二章 Promise 原理
一、什么是 Promise ?
那么,什么是 Promise ?
Promise 可以簡單理解為一個事務,這個事務存在三種狀態:
- 已經完成了 resolved
- 因為某種原因被中斷了 rejected
- 還在等待上一個事務結束 pending
上文中我們舉了一個栗子,獲取模板和數據之后再將拼合的數據插入到 DOM 中,這里我們將整個程序分解成多個事務:
事務一: 獲取模板
↓
事務二: 獲取數據
↓
事務三: 拼合之后插入到 DOM
在事務一結束之前,也就是模板代碼從服務器拉取過來之前,事務二和事務三都處於 pending 狀態,他們必須等待上一個事務結束。而事務一結束之后會將自身狀態標記為 resolved,並把該事務中處理的結果移交給事務二繼續處理(當然,這里如果沒有數據返回,事務二就不會獲得上一個事務的數據),依次類推,直到最后一個事務操作結束。
在事務操作的過程中,若遇到錯誤,比如事務一獲取數據存在跨域問題,那事務就會操作失敗,此時它會將自身的狀態標記為 rejected,由於后續事務都是承接前一事務的,前一事務已經宣告工程已經玩不成了,那么后續的所有事務都會將自己標記為 rejected,其標記理由(reason)就是出錯事務的報錯信息(這個報錯信息可以使用 try…catch 來捕獲,也可以通過程序自身來捕獲,如 ajax 的 onerror 事件、ajax 返回的狀態碼為 404 等)。
小結:Promise 就是一個事務的管理器。他的作用就是將各種內嵌回調的事務用流水形式表達,其目的是為了簡化編程,讓代碼邏輯更加清晰。
由於整個程序的實現比較難理解,對於 Promise,我們將分為兩部分闡述:
- 無錯誤傳遞的 Promise,也就是事務不會因為任何原因中斷,事務隊列中的事項都會被依次處理,此過程中 Promise 只有 pending 和 resolved 兩種狀態,沒有 rejected 狀態。
- 包含錯誤的 Promise,每個事務的處理都必須使用容錯機制來獲取結果,一旦出錯,就會將錯誤信息傳遞給下一個事務,如果錯誤信息會影響下一個事務,則下一個事務也會 rejected,如果不會,下一個事務可以正常執行,依次類推。
二、無錯誤傳遞的 Promise(簡化版的 Promise)
首先,我們需要用一個變量(status)來標記事務的狀態,然后將事務(affair)也保存到 Promise 對象中。
var Promise = function(affair){ this.state = “pending”; this.affair = affair || function(o) { return o; }; this.allAffairs = []; };
Promise 有兩個重要的方法,一個是 then,另一個是 resolve:
- then,將事務添加到事務隊列(allAffairs)中
- resolve,開啟流程,讓整個操作從第一個事務開始執行
在操作事務之前,我們會先把各種事務依次放入事務隊列中,這里會用到 then 方法:
Promise.prototype.then = function (nextAffair){ var promise = new Promise(); if (this.state == ‘resloved’){ // 如果當前狀態是已完成,則這個事務將會被立即執行 return this._fire(promise, nextAffair); }else{ // 否則將會被加入隊列中 return this._push(promise, nextAffair); } };
如果整個操作已經完成了,那 then 方法送進的事務會被立即執行,
Promise.prototype._fire = function (nextPromise, nextAffair){ var nextResult = nextAffair(this.result); if (nextResult instanceof Promise){ nextResult.then(function(obj){ nextPromise.resolve(obj); }); }else{ nextPromise.resolve(nextResult); } return nextPromise; };
被立即執行之后會返回一個結果,這個結果會被傳遞到下一個事務中作為原料,但是這里需要考慮兩種情況:
- 異步,如果這個結果也是一個 Promise,則需要等待這個 Promise 執行完畢再將最終的結果傳到下一個事務中。
- 同步,如果這個結果不是 Promise,則直接將結果傳遞給下一個事務。
第一種情況還是比較常見的,比如我們在一個事務中有一個子事務隊列需要處理,此時必須等待子事務完成才能回到主事務隊列中。
Promise.prototype.resolve = function (obj){ if (this.state != ‘pending’) { throw ‘流程已完成,不能再次開啟流程!’; } this.state = ‘resloved’; // 執行該事務,並將執行結果寄存到 Promise 管理器上 this.result = this.affair(obj); for (var i = 0, len = this.allAffairs.length; i < len; ++i){ // 往后執行事務 var affair = this.allAffairs[i]; this._fire(affair.promise, affair.affair); } return this; };
resolve 接受一個參數,這個數據是交給第一個事務來處理的,因為第一個事務的啟動可能需要點原料,這個數據就是原料,它也可以是空。該事物處理完畢之后,將操作結果(result)寄存在 Promise 對象上,方便引用,然后將結果(result)作為原料送入下一個事務。依次類推。
我們看到 then 方法中還調用了一個 _push ,這個方法的作用是將事務推進事務管理器(Promise)。
Promise.prototype._push = function (nextPromise, nextAffair){ this.allAffairs.push({ promise: nextPromise, affair: nextAffair }); return nextPromise; };
以上操作,我們就實現了一個簡單的事務管理器,可以測試下下面的代碼:
// 初始化事務管理器 var promise = new Promise(function(data){ console.log(data); return 1; }); // 添加事務 promise.then(function(data){ console.log(data); return 2; }).then(function(data){ console.log(data); return 3; }).then(function(data){ console.log(data); console.log(“end”); }); // 啟動事務 promise.resolve(“start”);
可以看到依次輸出的結果為:
> start
> 1
> 2
> 3
> end
由於上述實現十分簡陋,鏈式調用沒做太好的處理,請讀者自行完善:)
下面是一個異步操作演示:
var promise = new Promise(function(data){ console.log(data); return “end”; }); promise.then(function(data){ // 這里需要返回一個 Promise,讓主事務切換到子事務處理 return (function(data){ // 創建一個子事務 var promise = new Promise(); setTimeout(function(){ console.log(data); // 一秒之后才啟動子事務,模擬異步延時 promise.resolve(); }, 1000); return promise; })(data); }); promise.resolve(“start”);
可以看到依次輸出的結果為:
> start
> end (1s之后輸出)
將函數寫的稍微好看點:
function delay(data){ // 創建一個子事務 var promise = new Promise(); setTimeout(function(){ console.log(data); // 一秒之后才啟動子事務,模擬異步延時 promise.resolve(); }, 1000); return promise; } // 主事務 var promise = new Promise(function(data){ console.log(data); return “end”; }); promise.then(delay); promise.resolve(“start”);
三、包含錯誤傳遞的 Promise
真的很羡慕你能看到這么詳細的文章,當然,后面會更加精彩!
沒有錯誤處理的 Promise 只能算是一個半成品,雖說可以通過在最外層加一個 try..catch 來捕獲錯誤,但沒法具體定位是哪個事務發生的錯誤。並且這里的錯誤不僅僅包含 JavaScript Error,還有諸如 ajax 返回的 data code 不是 200 的情況等。
先看一個瀏覽器內置 Promise 的實例(該代碼可在現代瀏覽器下運行):
new Promise(function(resolve, reject){ resolve(“start”); }).then(function(data){ console.log(data); throw “error”; }).catch(function(err){ console.log(err); return “end”; }).then(function(data){ console.log(data) });
Promise 的回調和 then 方法都是接受兩個參數:
new Promise(function(resolve, reject){ // … }); promise.then( function(value){/* code here */}, function(reason){/* code here */} );
事務處理過程中,如果有值返回,則作為 value,傳入到 resolve 函數中,若有錯誤產生,則作為 reason 傳入到 reject 函數中處理。
在初始化 Promise 對象時,若傳入的回調中沒有執行 resolve 或者 reject,這需要我們主動去啟動事務隊列。
promise.resolve();
promise.reject();
上面兩種都是可以啟動一個隊列的。這里跟第二章第二節的 resolve 函數用法類似。Promise 對象還提供了 catch 函數,起用法等價於下面所示:
promise.catch(); // 等價於 promise.then(null, function(reason){});
還有兩個 API:
promise.all();
promise.race();
后續再講。先看看這個有錯誤處理的 Promise 是如何實現的。
function Promise(resolver){ this.status = “pending”; this.value = null; this.handlers = []; this._doPromise.call(this, resolver); }
_doPromise 方法在實例化 Promise 函數時就執行。如果送入的回調函數 resolver 中已經 resolve 或者 reject 了,程序就已經啟動了,所以在實例化的時候就開始判斷。
_doPromise: function(resolver){ var called = false, self = this; try{ resolver(function(value){ // 如果沒有 call 則繼續,並標記 called 為 true !called && (called = !0, self.resolve(value)); }, function(reason){ // 同上 !called && (called = !0, self.reject(reason)); }); } catch(e) { // 同上,捕獲錯誤,傳遞錯誤到下一個 then 事務 !called && (called = !0, self.reject(e)); } },
只要 resolve 或者 reject 就會標記程序 called 為 true,表示程序已經啟動了。
resolve: function(value) { try{ if(this === value){ throw new TypeError(‘流程已完成,不能再次開啟流程!’); } else { // 如果還有子事務隊列,繼續執行 value && value.then && this._doPromise(value.then); } // 執行完了之后標記為完成 this.status = “fulfilled”; this.value = value; this._dequeue(); } catch(e) { this.reject(e); } }, reject: function(reason) { // 標記狀態為出錯 this.status = “rejected”; this.value = reason; this._dequeue(); },
可以看到,每次 resolve 的時候都會用一個 try..catch 包裹來捕獲未知錯誤。
_dequeue: function(){ var handler; // 執行事務,直到隊列為空 while (this.handlers.length) { handler = this.handlers.shift(); this._handle(handler.thenPromise, handler.onFulfilled, handler.onRejected); } },
無論是 resolve 還是 reject 都會讓程序往后奔流,直到結束所有事務,所以這兩個方法中都有 _dequeue 函數。
_handle: function(thenPromise, onFulfilled, onRejected){ var self = this; setTimeout(function() { // 判斷下次操作采用哪個函數,reject 還是 resolve var callback = self.status == “fulfilled” ? onFulfilled : onRejected; // 只有是函數才會繼續回調 if (typeof callback === ‘function’) { try { self.resolve.call(thenPromise, callback(self.value)); } catch(e) { self.reject.call(thenPromise, e); } return; } // 否則就將 value 傳遞給下一個事務了 self.status == “fulfilled” ? self.resolve.call(thenPromise, self.value) : self.reject.call(thenPromise, self.value); }, 1); },
這個函數跟上一節提到的 _fire 類似,如果 callback 是 function,就會進入子事務隊列,處理完了之后退回到主事務隊列。最后一個 then 方法,將事務推進隊列。
then: function(onFulfilled, onRejected){ var thenPromise = new Promise(function() {}); if (this.status == “pending”) { this.handlers.push({ thenPromise: thenPromise, onFulfilled: onFulfilled, onRejected: onRejected }); } else { this._handle(thenPromise, onFulfilled, onRejected); } return thenPromise; }
如果第二節沒有理解清楚,這一節也會讓人頭疼,這一部分講的比較粗糙。
第三章 異步編程
一、jQuery 中的 Defferred 對象
或許你在面試的時候,有面試官問你:
$.ajax()
執行后返回的結果是什么?
在 jQuery1.5 版本就已經引入了 Defferred 對象,當時為了引入這個東西,整個 jQuery 都被重構了。Defferred 跟 Promise 類似,它表示一個還未完成任務的對象,而 Promise 確切的說,是一個代表未知值的對象。
$.ajax({ url: url }).done(function(data, status, xhr){ //… }).fail(function(){ //… });
回憶下第二章第一節中的 Promise,是不是如出一轍,只是 jQuery 還提供了更多的語法糖:
$.ajax({ url: url, success: function(data){ //… }, error: funtion(){ //… } });
他允許將 done 和 fail 兩個函數的回調放在 ajax 初始化的參數 success 和 fail 上,其原理還是一樣的,同樣,還有這樣的東西:
$.when(taskOne, taskTwo).done(function () { console.log(“都執行完畢后才會輸出我!”); }).fail(function(){ console.log(“只要有一個失敗,就會輸出我!”) });
當 taskOne 和 taskTwo 都完成之后才執行 done 回調,這個瀏覽器內置的 Promise 也有對應的函數:
Promise.all([true, Promise.resolve(1), …]).then(function(value){ //.... });
瀏覽器內置的 Promise 還提供了一個 API:
Promise.race([true, Promise.resolve(1), …]).then(function(value){ //.... }, function(reason){ //… });
只要 race 參數中有一個 resolve 或者 reject,then 回調就會出發。
二、基於事件響應的異步模型
@朴靈 寫的 EventProxy 就是基於事件響應的異步模型,按理說,這個實現的邏輯是最清晰的,不過代碼量稍微多一點。
function taskA(){ setTimeout(function(){ var result = “A”; E.emit(“taskA”, result); }, 1000); } function taskB(){ setTimeout(function(){ var result = “B”; E.emit(“taskB”, result); }, 1000); } E.all([“taskA”, “taskB”], function(A, B){ return A + B; });
我沒有看他的源碼,但是想想,應該是這個邏輯。只需要在消息中心管理各個 emit 以及消息注冊。這里的錯誤處理值得思考下。
在半年前,也寫過一篇關於異步編程的文章:JavaScript異步編程原理,感興趣的可以去讀一讀。
第四章 小結
一、小結
文章比較長,閱讀了好幾天別人寫的東西,自己提筆還是比較輕松的,本文大概花費了 6 個小時撰寫。
本文主要解說了 Promise 的應用場景和實現原理,如果你能夠順暢的讀完全文並且之處文中的一些錯誤,說明你已經悟到了:)
Promise 使用起來不難,但是理解其原理還是有點偏頭痛的,所以下面列舉的幾篇相關閱讀也建議讀者點進去看看。
二、相關閱讀
- JavaScript Promises
- Promise 初探
- JavaScript中的異步梳理(0)
- JavaScript中的異步梳理(2)——使用Promises/A
- jQuery的Deferred對象
- JavaScript中的Promise和Deferred對象 第二部分:實戰
- MDN
- JavaScript異步編程原理