zone.js 在異步任務之間進行持久性傳遞
來自: http://greengerong.com/blog/2016/01/30/zone-dot-js-bao-li-zhi-mei/
在ng2的開發過程中,Angular團隊為我們帶來了一個新的庫 – zone.js。zone.js的設計靈感來源于Dart語言,它描述JavaScript執行過程的上下文,可以在異步任務之間進行持久性傳遞,它類似于Java中的TLS(thread-local storage: 線程本地存儲)技術,zone.js則是將TLS引入到JavaScript語言中的實現框架。
那么zone.js能為我們解決什么問題呢?在回答這個問題之前,博主更希望回顧下在JavaScript開發中,我們究竟遇見了什么難題?
問題引入
我們先來看一段常規的同步JavaScript代碼:
var foo = function(){ ... }, bar = function(){ ... }, baz = function(){ ... }; foo(); bar(); baz();
這段代碼并沒有什么特殊之處,它的執行順序也并無什么特殊之處,完全在我們的預知之內:foo –> bar –> baz。對它做性能監測也很容易,我們只需要在執行上下文前后記錄執行時間即可。
var start, timer = performance ? performance.now.bind(performance) : Date.now.bind(Date); start = timer(); foo(); bar(); baz(); console.log(Math.floor((timer() - start) * 100) / 100 + 'ms');
但在JavaScript的世界并不全是這么簡單,眾所周知的JavaScript單線程執行的。因此為了不阻塞UI界面的用戶體驗,在JavaScript執行的很多耗時操作都被封裝為了異步操作,如:setTimeout、XMLHttpRequest、DOM事件等。由于瀏覽器的寄宿限制,JavaScript中異步操作是與生俱來的特性,被深深的印在了骨髓之中。這也是Ryan Dahl博士選擇JavaScript開發Node.js平臺的原因之一。關于JavaScript單線程執行可以參考博主的另一篇博文:JavaScript單線程和瀏覽器事件循環簡述。
那么對于下面這段異步代碼,我們又如何做性能監測呢?
var foo = function(){ setTimeout(..., 2000); }, bar = function(){ $.get(...).success(...); }, baz = function(){ ... }; foo(); bar(); baz();
在這段代碼中,引入了setTimeout和AJAX異步調用。其中AJAX回調和setTimeout回調時間順序很難確定,因此給這段代碼引入性能檢測代碼并不像上面的順序執行代碼一樣那么簡單了。如果我們需要強行加入性能的檢測,則會在setTimeout和$.get回調中插入相關的hook代碼并并記錄執行時間,這樣我們的業務代碼也會變得非常混亂,就像一團“意大利拉面”一樣(What the fuck!)。
zone.js簡介
在本文開篇提到zone.js為JavaScript提供了執行上下文,可以在異步任務之間進行持久性傳遞。該是zone.js上場的時候了。zone.js采用猴子補丁(Monkey-patched)的暴力方式將JavaScript中的異步任務都包裹了一層,使得這些異步任務都將運行在zone的上下文中。每一個異步的任務在zone.js都被當做為一個Task,并在Task的基礎上zone.js為開發者提供了執行前后的鉤子函數(hook)。這些鉤子函數包括:
- onZoneCreated:產生一個新的zone對象時的鉤子函數。zone.fork也會產生一個繼承至基類zone的新zone,形成一個獨立的zone上下文;
- beforeTask:zone Task執行前的鉤子函數;
- afterTask:zone Task執行完成后的鉤子函數;
- onError:zone運行Task時候的異常鉤子函數;
并且zone.js對JavaScript中的大多數異步事件都做了包裹封裝,它們包括:
- zone.alert;
- zone.prompt;
- zone.requestAnimationFrame、zone.webkitRequestAnimationFrame、zone.mozRequestAnimationFrame;
- zone.addEventListener;
- zone.addEventListener、zone.removeEventListener;
- zone.setTimeout、zone.clearTimeout、zone.setImmediate;
- zone.setInterval、zone.clearInterval
以及對promise、geolocation定位信息、websocket等也進行了包裹封裝,你可以在這里找到它們https://github.com/angular/zone.js/tree/master/lib/patch。
下面我們先來看一個簡單的zone.js示例:
var log = function(phase){ return function(){ console.log("I am in zone.js " + phase + "!"); }; }; zone.fork({ onZoneCreated: log("onZoneCreated"), beforeTask: log("beforeTask"), afterTask: log("afterTask"), }).run(function(){ var methodLog = function(func){ return function(){ console.log("I am from " + func + " function!"); }; }, foo = methodLog("foo"), bar = methodLog("bar"), baz = function(){ setTimeout(methodLog('baz in setTimeout'), 0); }; foo(); baz(); bar(); });
執行這段示例代碼的輸出是:
I am in zone.js beforeTask! I am from foo function! I am from bar function! I am in zone.js afterTask! I am in zone.js onZoneCreated! I am in zone.js beforeTask! I am from baz in setTimeout function! I am in zone.js afterTask!
從上面的輸出結果,我們能夠看出在zone.js中將run方法塊分為了兩個Task,它們分別是方法體運行時的Task和異步setTimeout的Task。并且我們能夠在這些Task的創建,執行前后攔截并做一些有意義的事情。
在zone.js中fork方法會產生一個繼承至zone的子類,并在fork函數中可以配置特定的鉤子方法,形成獨立的zone上下文。而run方法則是啟動執行業務代碼的對外接口。
同時zone也支持父子繼承,以及它也定義了一套DSL語法,支持$、+、-的前綴。
- $會傳遞父類zone的鉤子函數,便于對zone鉤子函數執行的控制;
- -代表在父zone的鉤子函數之前運行本鉤子函數;
- +則與之相反,代表在父zone的鉤子函數之后運行本鉤子函數
更多的語法使用,請參考zone.js github首頁文檔https://github.com/angular/zone.js。
引入zone.js
有了上面的這些關于zone.js的基礎知識,在本文開始的遺留問題我們就可以迎刃而解了。下面這段代碼是來自zone.js項目的示例代碼:https://github.com/angular/zone.js/blob/master/example/profiling.html
var profilingZone = (function () { var time = 0, timer = performance ? performance.now.bind(performance) : Date.now.bind(Date); return { beforeTask: function () { this.start = timer(); }, afterTask: function () { time += timer() - this.start; }, time: function () { return Math.floor(time*100) / 100 + 'ms'; }, reset: function () { time = 0; } }; }()); zone.fork(profilingZone).run(function(){ //業務邏輯代碼 });
這里在beforeTask中啟動了時間計算,并在afterTask中計算出當前累積的花費的時間。因此我們在業務代碼的邏輯中就可以隨時利用zone.time()來獲取當前耗時了。
zone.js的實現
了解了zone.js的時候之后,或許你會像我一樣感覺很神奇,它是如何實現的呢?
下面是zone.js中browser.ts的代碼片段(https://github.com/angular/zone.js/blob/master/lib/patch/browser.ts):
export function apply() { fnPatch.patchSetClearFunction(global, global.Zone, [ ['setTimeout', 'clearTimeout', false, false], ['setInterval', 'clearInterval', true, false], ['setImmediate', 'clearImmediate', false, false], ['requestAnimationFrame', 'cancelAnimationFrame', false, true], ['mozRequestAnimationFrame', 'mozCancelAnimationFrame', false, true], ['webkitRequestAnimationFrame', 'webkitCancelAnimationFrame', false, true] ]); fnPatch.patchFunction(global, [ 'alert', 'prompt' ]); eventTargetPatch.apply(); propertyDescriptorPatch.apply(); promisePatch.apply(); mutationObserverPatch.patchClass('MutationObserver'); mutationObserverPatch.patchClass('WebKitMutationObserver'); definePropertyPatch.apply(); registerElementPatch.apply(); geolocationPatch.apply(); fileReaderPatch.apply(); }
從這里我們能看到,zone.js對瀏覽器中的setTimeout、setInterval、setImmediate、以及事件、promise、地理信息geolocation都做了特殊處理。那么這些處理是怎么處理的呢?下面是關于fnPatch.patchSetClearFunction的實現代碼,來自zone.js中functions.ts(https://github.com/angular/zone.js/blob/master/lib/patch/functions.ts)的代碼片段:
export function patchSetClearFunction(window, Zone, fnNames) { function patchMacroTaskMethod(setName, clearName, repeating, isRaf) { //瀏覽器原生方法留存 var setNative = window[setName]; var clearNative = window[clearName]; var ids = {}; if (setNative) { var wtfSetEventFn = wtf.createEvent('Zone#' + setName + '(uint32 zone, uint32 id, uint32 delay)'); var wtfClearEventFn = wtf.createEvent('Zone#' + clearName + '(uint32 zone, uint32 id)'); var wtfCallbackFn = wtf.createScope('Zone#cb:' + setName + '(uint32 zone, uint32 id, uint32 delay)'); // 對瀏覽器原生方法的包裹封裝 window[setName] = function () { return global.zone[setName].apply(global.zone, arguments); }; // 對瀏覽器原生方法的包裹封裝 window[clearName] = function () { return global.zone[clearName].apply(global.zone, arguments); }; // 創建自己包裹方法,由上面的wind[setName]轉移到這里執行. Zone.prototype[setName] = function (fn, delay) { var callbackFn = fn; if (typeof callbackFn !== 'function') { // force the error by calling the method with wrong args setNative.apply(window, arguments); } var zone = this; var setId = null; // wrap the callback function into the zone. arguments[0] = function() { var callbackZone = zone.isRootZone() || isRaf ? zone : zone.fork(); var callbackThis = this; var callbackArgs = arguments; return wtf.leaveScope( wtfCallbackFn(callbackZone.$id, setId, delay), callbackZone.run(function() { if (!repeating) { delete ids[setId]; callbackZone.removeTask(callbackFn); } return callbackFn.apply(callbackThis, callbackArgs); }) ); }; if (repeating) { zone.addRepeatingTask(callbackFn); } else { zone.addTask(callbackFn); } setId = setNative.apply(window, arguments); ids[setId] = callbackFn; wtfSetEventFn(zone.$id, setId, delay); return setId; }; ...... } } fnNames.forEach(function(args) { patchMacroTaskMethod.apply(null, args); }); };
在上面的代碼中,首先會將瀏覽器的原生方法保存在setNative中以便將會重用。緊接著zone.js就開始了它的暴力行為,覆蓋window[setName]和window[clearName]然后將對setName的調用轉到自身的zone[setName]的調用,zone.js就是如此暴力的對瀏覽器原生對象實現了攔截轉移。然后它會在Task執行的前后調用自身的addRepeatingTask、addTask以及wtf事件來應用注冊上的所有鉤子函數。
到這里相信作為讀者的你已經明白了zone.js的實現機制了,是不是和筆者一樣有種“簡單粗暴”的感覺?但是它真的很強大,為我們實現了對異步Task的跟蹤、分析等。
zone.js應用場景
zone.js能實現異步Task跟蹤,分析,錯誤記錄、開發調試跟蹤等,這些都是zone.js場景的應用場景。你也可以在https://github.com/angular/zone.js/tree/master/example看見更多的示例代碼,以及Brian在ng-conf 2014關于zone.js的演講視頻: https://www.油Tube.com/watch?v=3IqtmUscE_U.
當然對于一些特定的業務分析zone.js也有它很好的運用場景。如果你使用過Angular1的開發,那么也許你還能記憶猶新的想起:使用第三方事件或者ajax卻忘記$scope.$apply的場景吧。在Angular1中如果在非Angular的上下文改變數據Model,Angular是無法預知的,因此也不會觸發界面的更新。所以我們不得不顯示的調用$scope.$apply或者$timeout來觸發界面的更新。Angular框架為了更多的獲知變化的事件,不得不為封裝了一整套框架內置的服務和指令,如ngClick、ngChange、$http,$timeout等,這也增加了Angular1的學習成本。
也是為了解決Angular1的這一些列問題,Angular2團隊引入了zone.js,放棄自定義這類服務和指令,相反而是擁抱瀏覽器的原生對象和方法。所以在Angular2中可以使用瀏覽器的任何事件了,只需要括號模板語法的標識:(eventName),等價于on-eventName;也可以直接使用瀏覽器的原生對象了,如setTimeout,addEventListener、promise、fetch等。
當然,zone.js也能應用于Angular1的項目之中。示例代碼如下(http://jsbin.com/kenilivuvi/edit?html,js,output):
angular.module("com.ngbook.demo", []) .controller("DemoController", ['$scope', function($scope){ zone.fork({ afterTask: function(){ var phase = $scope.$root.$$phase; if(['$apply', '$digest'].indexOf(phase) === -1) { $scope.$apply(); } } }).run(function(){ setTimeout(function(){ $scope.fromZone = "I am from zone with setTimeout!"; }, 2000); }); }]);
在示例代碼中,在每次Task的完成后都會嘗試$scope.$apply,強制將Model數據的改變更新到UI界面。對于在Angular1中使用zone.js更多的地方應該是在Directive中,同時也可以將zone的創建過程封裝為服務(工廠方法,每次返回一個全新的zone對象)。在Angular2中也有同樣zone的封裝,它被稱為ngZone(https://github.com/angular/angular/blob/master/modules/angular2/src/core/zone/ng_zone.ts)。