瀏覽器事件模型和回調機制
JavaScript作為單線程運行於瀏覽器之中,這是每本JavaScript教科書中都會被提到的。同時出於對UI線程操作的安全性考慮,JavaScript和UI線程也處於同一個線程中。因此對於長時間的耗時操作,將會阻塞UI的響應。為了更好的UI體驗,應該盡量的避免JavaScript中執行較長耗時的操作(如大量for循環的對象diff等)或者是長時間I/O阻塞的任務。所以在瀏覽器中的大多數任務都是異步(無阻塞)執行的,例如:鼠標點擊事件、窗口大小拖拉事件、定時器觸發事件、Ajax完成回調事件等。當每一個異步事件完成時,它都將被放入一個叫做”瀏覽器事件隊列“中的事件池中去。而這些被放在事件池中的任務,將會被javascript引擎單線程處理的一個一個的處理,當在此次處理中再次遇見的異步任務,它們也會被放到事件池中去,等待下一次的tick被處理。另外在HTML5中引入了新的組件-Web Worker,它可以在JavaScript線程以外執行這些任務,而不阻塞當前UI線程。
瀏覽器中的事件循環模型如下圖所示:
由於瀏覽器的這種內部事件循環機制,所以JavaScript一直以callback回調的方式來處理事件任務。因此無所避免的對於多個的JavaScript異步任務的處理,將會遇見”callback hell“(回調地獄),使得這類代碼及其不可讀和難易維護。
asyncTask1(data, function (data1){
asyncTask2(data1, function (data2){
asyncTask3(data2, function (data3){
// .... 魔鬼式的金字塔還在繼續
});
});
});
Promise的橫空出世
因此很多JavaScript牛人開始尋找解決這回調地獄的模式設計,隨后Promise(jQuery的deferred
也屬於Promise范疇)便被引入到了JavaScript的世界。Promise在英語中語義為:”承諾“,它表示如A調用一個長時間任務B的時候,B將返回一個”承諾“給A,A不用關心整個實施的過程,繼續做自己的任務;當B實施完成的時候,會通過A,並將執行A之間的預先約定的回調。而deferred在英語中語義為:”延遲“,這也說明promise解決的問題是一種帶有延遲的事件,這個事件會被延遲到未來某個合適點再執行。
- Promise 對象有三種狀態: Pending – Promise對象的初始狀態,等到任務的完成或者被拒絕;Fulfilled – 任務執行完成並且成功的狀態;Rejected – 任務執行完成並且失敗的狀態;
- Promise的狀態只可能從“Pending”狀態轉到“Fulfilled”狀態或者“Rejected”狀態,而且不能逆向轉換,同時“Fulfilled”狀態和“Rejected”狀態也不能相互轉換;
- Promise對象必須實現then方法,then是promise規范的核心,而且then方法也必須返回一個Promise對象,同一個Promise對象可以注冊多個then方法,並且回調的執行順序跟它們的注冊順序一致;
- then方法接受兩個回調函數,它們分別為:成功時的回調和失敗時的回調;並且它們分別在:Promise由“Pending”狀態轉換到“Fulfilled”狀態時被調用和在Promise由“Pending”狀態轉換到“Rejected”狀態時被調用。
如下面所示:
根據Promise/A+規范,我們在文章開始的Promise偽代碼就可以轉換為如下代碼:
asyncTask1(data)
.then(function(data1){
return asyncTask2(data1);
})
.then(function(data2){
return asyncTask3(data2);
})
// 仍然可以繼續then方法
Promise將原來回調地獄中的回調函數,從橫向式增加巧妙的變為了縱向增長。以鏈式的風格,縱向的書寫,使得代碼更加的可讀和易於維護。
Promise在JavaScript的世界中逐漸的被大家所接受,所以在ES6的標准版中已經引入了Promise的規范了。現在通過Babel,可以完全放心的引入產品環境之中了。
另外,對於解決這類異步任務的方式,在ES7中將會引入async、await
兩個關鍵字,以同步的方式來書寫異步的任務,它被譽為”JavaScript異步處理的終極方案“。這兩個關鍵字是ES6標准中生成器(generator
)和Promise的組合新語法,內置generator
的執行器的一種方式。當然async、await
的講解並不會在本文中展開,有興趣的讀者可以參見MDN資料。
Promise的妙用
如上所說Promise在處理異步回調或者是延遲執行任務時候,是一個不錯的選擇方案。下面我們將介紹一些Promise的使用技巧(下面將利用Angular的$q
和$http
為例,當然對於jQuery的deferred
,ES6的Promise仍然實用):
多個異步任務的串行處理
在上文中提到的回調地獄案例,就是一種試圖去將多個異步的任務串行處理的結果,使得代碼不斷的橫向延伸,可讀性和維護性急劇下降。當然我們也提到了Promise利用鏈式和延遲執行模型,將代碼從橫向延伸拉回了縱向增長。使用Angular中$http
的實現如下:
$http.get('/demo1')
.then(function(data){
console.log('demo1', data);
return $http.get('/demo2', {params: data.result});
})
.then(function(data){
console.log('demo2', data);
return $http.get('/demo3', {params: data.result});
})
.then(function(data){
console.log('demo3', data.result);
});
因為Promise是可以傳遞的,可以繼續then
方法延續下去,也可以在縱向擴展的途中改變為其他Promise或者數據。所以在例子中的$http也可以換為其他的Promise(如$timeout
,$resource
…)。
多個異步任務的並行處理
在有些場景下,我們所要處理的多個異步任務之間並沒有像上例中的那么強的依賴關系,只需要在這一系列的異步任務全部完成的時候執行一些特定邏輯。這個時候為了性能的考慮等,我們不需要將它們都串行起來執行,並行執行它們將是一個最優的選擇。如果仍然采用回調函數,則這是一個非常惱人的問題。利用Promise則同樣可以優雅的解決它:
$q.all([$http.get('/demo1'),
$http.get('/demo2'),
$http.get('/demo3')
])
.then(function(results){
console.log('result 1', results[0]);
console.log('result 2', results[1]);
console.log('result 3', results[2]);
});
這樣就可以等到一堆異步的任務完成后,在執行特定的業務回調了。在Angular中的路由機制ngRoute
、uiRoute
的resolve機制也是采用同樣的原理:在路由執行的時候,會將獲取模板的Promise、獲取所有resolve數據的Promise都拼接在一起,同時並行的獲取它們,然后等待它們都結束的時候,才開始初始化ng-view
、ui-view
指令的scope對象,以及compile模板節點,並插入頁面DOM中,完成一次路由的跳轉並且切換了View,將靜態的HTML模板變為動態的網頁展示出來。
Angular路由機制的偽代碼如下:
var getTemplatePromise = function(options) {
// ... 拼接所有template或者templateUrl
};
var getResolvePromises = function(resolves) {
// ... 拼接所有resolve
};
var controllerLoader = function(options, currentScope, tplAndVars, initLocals) {
// ...
ctrlInstance = $controller(options.controller, ctrlLocals);
if (options.controllerAs) {
currentScope[options.controllerAs] = ctrlInstance;
}
// ...
return currentScope;
};
var templateAndResolvePromise = $q.all([getTemplatePromise(options)].concat(getResolvePromises(options.resolve || {})));
return templateAndResolvePromise.then(function resolveSuccess(tplAndVars) {
var currentScope = currentScope || $rootScope.$new();
controllerLoader(options, currentScope, tplAndVars, initLocals);
// ... compile & append dom
});
對於這類路由機制的使用,在博主上篇博文《自定義Angular插件 – 網站用戶引導》中的ng-trainning插件中也采用了它。關於這段代碼的具體分析和應用將在后續單獨的文章中,敬請大家期待。
對於同步數據的Promise處理,統一調用接口
有了Promise的處理,因為在前端代碼中最多的異步處理就是Ajax,它們都被包裝為了Promise .then的風格。那么對於一部分同步的非異步處理呢?如localStorage、setTimeout、setInterval之類的方法。在大多數情況下,博主仍然推薦使用Promise的方式包裝,使得項目Service的返回接口統一。這樣也便於像上例中的多個異步任務的串行、並行處理。在Angular路由中對於只設置template的情況,也是這么處理的。
對於setTimeout、setInterval在Angular中都已經為我們內置了$timeout和$interval服務,它們就是一種Promise的封裝。對於localStorage呢?可以采用$q.when方法來直接包裝localStorage的返回值的為Promise接口,如下所示:
var data = $window.localStorage.getItem('data-api-key');
return $q.when(data);
整個項目的Service層的返回值都可以被封裝為統一的風格使用了,項目變得更加的一致和統一。在需要多個Service同時並行或者串行處理的時候,也變得簡單了,一致的使用方式。
對於延遲任務的Promise DSL語義化封裝
在前面已經提到Promise是延遲到未來執行某些特定任務,在調用時候則給消費者返回一個”承諾“,消費者線程並不會被阻塞。在消費者接受到”承諾“之后,消費者就不用再關心這些任務是如何完成的,以及督促生產者的任務執行狀態等。直到任務完成后,消費者手中的這個”承諾“就被兌現了。
對於這類延遲機制,在前端的UI交互中也是極其常見的。比如模態窗口的顯示,對於用戶在模態窗口中的交互結果並不可提前預知的,用戶是點擊”ok“按鈕,或者是”cancel“按鈕,這是一個未來將會發生的延遲事件。對於這類場景的處理,也是Promise所擅長的領域。在Angular-UI的Bootstrap的modal的實現也是基於Promise的封裝。
$modal.open({
templateUrl: '/templates/modal.html',
controller: 'ModalController',
controllerAs: 'modal',
resolve: {
}
})
.result
.then(function ok(data) {
// 用戶點擊ok按鈕事件
}, function cancel(){
// 用戶點擊cancel按鈕事件
});
這是因為modal在open方法的返回值中給了我們一個Promise的result對象(承諾)。等到用戶在模態窗口中點擊了ok按鈕,則Bootstrap會使用$q
的defer
來resolve
來執行ok事件;相反,如果用戶點擊了cancel按鈕,則會使用$q
的defer
來reject
執行cancel事件。
這樣就很好的解決了延遲觸發的問題,也避免了callback的地獄
。我們仍然可以進一步將其返回值語義化,以業務自有的術語命名而形成一套DSL API。
function open(data){
var defer = $q.defer();
// resolve or reject defer;
var promise = defer.promise;
promise.ok = function(func){
promise.then(func);
return promise;
};
promise.cancel = function(func){
promise.then(null, func);
return promise;
};
return promise;
};
則我們可以如下方式來訪問它:
$modal.open(item)
.ok(function(data){
// ok邏輯
})
.cancel(function(data){
// cancel 邏輯
});
是不是感覺更具有語義呢?在Angular中$http的返回方法success、error也是同樣邏輯的封裝。將success的注冊函數注冊為.then方法的成功回調,error的注冊方法注冊為then方法的失敗回調。所以success和error方法只是Angular框架為我們在Promise語法之上封裝的一套語法糖而已。
Angular的success、error回調的實現代碼:
promise.success = function(fn) {
promise.then(function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};
promise.error = function(fn) {
promise.then(null, function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};
利用Promise來實現管道式AOP攔截
在軟件設計中,AOP是Aspect-Oriented Programming的縮寫,意為:面向切面編程。通過編譯時(Compile)植入代碼、運行期(Runtime)動態代理、以及框架提供管道式執行等策略實現程序通用功能與業務模塊的分離,統一處理、維護的一種解耦設計。 AOP是OOP的延續,是軟件開發中的一個熱點,也是很多服務端框架(如Java世界的Spring)中的核心內容之一,是函數式編程的一種衍生范型。 利用AOP可以對業務邏輯的各個部分進行隔離,從而使得業務邏輯各部分之間的耦合度降低,提高程序的可重用性,同時提高開發效率。 AOP實用的場景主要有:權限控制、日志模塊、事務處理、性能統計、異常處理等獨立、通用的非業務模塊。關於更多的AOP資料請參考http://en.wikipedia.org/wiki/Aspect-oriented_programming。
在Angular中同樣也內置了一些AOP的設計思想,便於實現程序通用功能與業務模塊的分離、解耦、統一處理和維護。$http中的攔截器(interceptors)和裝飾器($provide.decorator)是Angular中兩類常見的AOP切入點。前者以管道式執行策略實現,而后者則通過運行時的Promise管道動態實現的。
首先回顧一下Angular的攔截器實現方式:
// 注冊一個攔截器服務
$provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) {
return {
// 可選方法
'request': function(config) {
// 請求成功后處理
return config;
},
// 可選方法
'requestError': function(rejection) {
// 請求失敗后的處理
if (canRecover(rejection)) {
return responseOrNewPromise
}
return $q.reject(rejection);
},
// 可選方法
'response': function(response) {
// 返回回城處理
return response;
},
// 可選方法
'responseError': function(rejection) {
// 返回失敗的處理
if (canRecover(rejection)) {
return responseOrNewPromise
}
return $q.reject(rejection);
}
};
});
// 將服務注冊到攔截器鏈中
$httpProvider.interceptors.push('myHttpInterceptor');
// 同樣也可以將攔截器注冊為一個工廠方法。 但上一中方式更為推薦。
$httpProvider.interceptors.push(['$q', function($q) {
return {
'request': function(config) {
// 同上
},
'response': function(response) {
// 同上
}
};
}]);
這樣就可以實現對Angular中的$http
或者是$resource
的Ajax請求攔截了。但在Angular內部是是如何實現這種攔截方式的呢?Angular使用的就是Promise機制,形成異步管道流,將真實的Ajax請求放置在request、requestError和response、responseError的管道中間,因此就產生了對Ajax請求的攔截。
其源碼實現如下:
var interceptorFactories = this.interceptors = [];
var responseInterceptorFactories = this.responseInterceptors = [];
this.$get = ['$httpBackend', '$browser', '$cacheFactory', '$rootScope', '$q', '$injector',
function($httpBackend, $browser, $cacheFactory, $rootScope, $q, $injector) {
var defaultCache = $cacheFactory('$http');
var reversedInterceptors = [];
forEach(interceptorFactories, function(interceptorFactory) {
reversedInterceptors.unshift(isString(interceptorFactory) ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory));
});
forEach(responseInterceptorFactories, function(interceptorFactory, index) {
var responseFn = isString(interceptorFactory) ? $injector.get(interceptorFactory) : $injector.invoke(interceptorFactory);
reversedInterceptors.splice(index, 0, {
response: function(response) {
return responseFn($q.when(response));
},
responseError: function(response) {
return responseFn($q.reject(response));
}
});
});
...
function $http(requestConfig) {
...
var chain = [serverRequest, undefined];
var promise = $q.when(config);
// apply interceptors
forEach(reversedInterceptors, function(interceptor) {
if (interceptor.request || interceptor.requestError) {
chain.unshift(interceptor.request, interceptor.requestError);
}
if (interceptor.response || interceptor.responseError) {
chain.push(interceptor.response, interceptor.responseError);
}
});
while (chain.length) {
var thenFn = chain.shift();
var rejectFn = chain.shift();
promise = promise.then(thenFn, rejectFn);
}
promise.success = function(fn) {
promise.then(function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};
promise.error = function(fn) {
promise.then(null, function(response) {
fn(response.data, response.status, response.headers, config);
});
return promise;
};
return promise;
};
在上面緊接着在$get
注入方法之后,Angular會將interceptors
和responseInterceptors
反轉合並到一個reversedInterceptors
的攔截器內部變量中保存。最后在$http函數中以[serverRequest, undefined]
(serverRequest
是Ajax請求的Promise操作)為中心,將reversedInterceptors
中的所有攔截器函數依次加入到chain鏈式數組中。如果是request或requestError,那么就放在鏈式數組起始位置;相反如果是response或responseError,那么就放在鏈式數組最后。
注意添加在chain的request和requestError或者response和responseError都一定是成對的,換句話說可能注冊一個非空的request與一個為undefined的requestError,或者是一個為undefined的request與非空的requestError。就像chain數組的聲明一樣(var chain = [serverRequest, undefined];)
,成對的放入serverRequest和undefined對象到數組中。因為后面的代碼將利用Promise的機制注冊這些攔截器函數,實現管道式AOP攔截機制。
在Promise中需要兩個函數來注冊回調,分別為成功回調和失敗回調。在這里request和response會被注冊成Promise的成功回調,而requestError和responseError則會注冊成Promise的失敗回調。所以在chain中添加的request和requestError,response或responseError都是成對出現的,這是為了能在接下來的循環中簡潔地注冊Promise回調函數。 這些被注冊的攔截器鏈,會通過$q.when(config)
Promise啟動,它會首先傳入$http
的config對象,並執行所有的request攔截器,依次再到serverRequest
這個Ajax請求,此時將掛起后邊所有的response攔截器,直到Ajax請求響應完成,再依次執行剩下的response攔截器回調; 如果在request過程中有異常失敗則會執行后邊的requestError攔截器,對於Ajax請求的失敗或者處理Ajax的response攔截器的異常也會被后面注冊的responseError攔截器捕獲。
從最后兩段代碼也能了解到關於$http
服務中的success方法和error方法,是Angular為大家提供了一種Promise的便捷寫法。success方法是注冊一個傳入的成功回調和為undefined的錯誤回調,而error則是注冊一個為null的成功回調和一個傳入的失敗回調。
總結
寫到這里,本文也進入了尾聲。希望大家能夠對Promise有一定的理解,並能夠”信手拈來“的運用於實際的項目之中,增強代碼的可讀性和可維護性。在本文中所用到的例子,你都可以在博主的jsbinhttp://jsbin.com/bayeva/edit?html,js,output中找到它們。
另外,同時也歡迎關注博主的微信公眾號[破狼](微信二維碼位於博客右側),這里將會為大家地時間推送博主的最新博文,謝謝大家的支持和鼓勵。