深入理解AngularJs-scope的臟檢查(一)
進入正文前的說明:本文中的示例代碼并非AngularJs源碼,而是來自書籍<<Build Your Own AngularJs>>, 這本書的作者僅依賴jquery和lodash一步一步構(gòu)建出AngularJs的各核心模塊,對全面理解AngularJs有非常巨大的幫助。若有正在使用AngulaJs攻城拔寨并且希望完全掌握手中武器的小伙伴,相信能對你理解AngularJs帶來莫大幫助,感謝作者。
在這篇文章中,希望能讓您理清楚以下幾項與scope相關的功能:
1.dirty-checking(臟檢測)核心機制,主要包括:$watch 和 $digest;
2.幾種不同的觸發(fā)$digest循環(huán)的方式:$eval, $apply, $evalAsync, $applyAsync;
3.scope的繼承機制以及isolated scope;
4.依賴于scope的事件循環(huán):$on, $broadcast, $emit.
現(xiàn)在開始我們的第一部分:scope和dirty-checking
dirty-checking(臟檢測)原理簡述:scope通過$watch方法向this.$$watchers數(shù)組中添加watcher對象(包含watchFn, listenerFn, valueEq, last 四個屬性)。每當$digest循環(huán)被觸發(fā)時,它會遍歷$$watchers數(shù)組,執(zhí)行watcher中的watchFn,獲取當前scope上某屬性的值(一個watcher對應scope上一個被監(jiān)聽屬性),然后去同watcher中的last(上一次的值)做比較,若兩值不相等,就執(zhí)行l(wèi)istenerFn。
function Scope() {
this.$$watchers = []; // 監(jiān)聽器數(shù)組
this.$$lastDirtyWatch = null; // 每次digest循環(huán)的最后一個臟的watcher, 用于優(yōu)化digest循環(huán)
this.$$asyncQueue = []; // scope上的異步隊列
this.$$applyAsyncQueue = []; // scope上的異步apply隊列
this.$$applyAsyncId = null; //異步apply信息
this.$$postDigestQueue = []; // postDigest執(zhí)行隊列
this.$$phase = null; // 儲存scope上正在做什么,值有:digest/apply/null
this.$root = this; // rootScope
this.$$listeners = {}; // 存儲包含自定義事件鍵值對的對象
this.$$children = []; // 存儲當前scope的兒子Scope,以便$digest循環(huán)遞歸
}
實際上scope就是一個普通的javascript對象,一個類構(gòu)造函數(shù),可以通過new進行實例化。根據(jù)臟檢測的原理,接下來,我們一起看看scope的$watch方法的實現(xiàn)。
/* $watch方法:向watchers數(shù)組中添加watcher對象,以便對應調(diào)用 */
Scope.prototype.$watch = function(watchFn, listenerFn, valueEq) {
var self = this;
watchFn = $parse(watchFn);
// watchDelegate: 針對watch expression是常量和 one-time-binding的情況,進行優(yōu)化。在第一次初始化之后刪除watch
if(watchFn.$$watchDelegate) {
return watchFn.$$watchDelegate(self, listenerFn, valueEq, watchFn);
}
var watcher = {
watchFn: watchFn,
listenerFn: listenerFn || function() {},
valueEq: !!valueEq,
last: initWatchVal
};
this.$$watchers.unshift(watcher);
this.$root.$$lastDirtyWatch = null;
return function() {
var index = self.$$watchers.indexOf(watcher);
if(index >= 0) {
self.$$watchers.splice(index, 1);
self.$root.$$lastDirtyWatch = null;
}
};
};
$watch方法的參數(shù):
watchFn-監(jiān)視表達式,在使用$watch時,通常是傳入一個expression, 經(jīng)過$parse服務處理后返回一個監(jiān)視函數(shù),提供動態(tài)訪問scope上屬性值的功能,可以看作 function() { return scope.someValue; }。
listenerFn-監(jiān)聽函數(shù),當$digest循環(huán)dirty時(即scope上$$watchers數(shù)組中有watcher監(jiān)測到屬性值變化時),執(zhí)行的回調(diào)函數(shù)。
valueEq-是否全等監(jiān)視,布爾值,valueEq默認為false,此時$watch對監(jiān)視對象進行“引用監(jiān)視”,如果被監(jiān)視的表達式是原始數(shù)據(jù)類型,$watch能夠發(fā)現(xiàn)改變。如果被監(jiān)視的表達式是引用類型,由于引用類型的賦值只是將被賦值變量指向當前引用,故$watch認為沒有改變。若需要對引用類型進行監(jiān)視,則需要將valueEq設置為true,這是$watch會對被監(jiān)視對象進行“全等監(jiān)視”,在每次比較前會用angular.copy()對被監(jiān)視對象進行深拷貝,然后用angular.equal()進行比對。雖然“全等監(jiān)視”能夠監(jiān)視到所有改變,但如果被監(jiān)視對象很大,性能肯定會大打折扣。所以應該根據(jù)實際情況來使用valueEq。
從代碼中能夠看出,$watch的功能其實非常簡單,就是構(gòu)造watcher對象,并將watcher對象插入到scope.$$watchers數(shù)組中,然后返回一個銷毀當前watcher的函數(shù)。
接下來進入到臟檢測最核心的部分:$digest循環(huán)
《Build your own AngularJs》的作者將$digest分成了兩個函數(shù):$digestOnce 和 $digest。這雖然不用與框架源碼,但能夠使代碼更易理解。兩個函數(shù)實際上分別對應了$digest的內(nèi)層循環(huán)和外層循環(huán)。代碼如下:
內(nèi)層循環(huán)
Scope.prototype.$$digestOnce = function() {
var dirty;
var continueLoop = true;
var self = this;
this.$$everyScope(function(scope) {
var newValue, oldValue;
_.forEachRight(scope.$$watchers, function(watcher) {
try {
if(watcher) {
newValue = watcher.watchFn(scope);
oldValue = watcher.last;
if(!scope.$$areEqual(newValue, oldValue, watcher.valueEq)) {
scope.$root.$$lastDirtyWatch = watcher;
watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
watcher.listenerFn(newValue,
(oldValue === initWatchVal? newValue : oldValue), scope);
dirty = true;
} else if(scope.$root.$$lastDirtyWatch === watcher) {
continueLoop = false;
return false;
}
}
} catch(e) {
console.error(e);
}
});
return continueLoop;
});
return dirty;
};
代碼中,$$everyScope是遞歸childScope執(zhí)行回調(diào)函數(shù)的工具方法,后面會貼出。
$digestOnce的核心邏輯就在$$everyScope方法的循環(huán)體內(nèi),即遍歷scope.$$watchers, 比對新舊值,根據(jù)比對結(jié)果確定是否執(zhí)行l(wèi)istenerFn,并向listenerFn中傳入newValue, oldValue, scope供開發(fā)者獲取。
示例代碼第18行,watcher.last的賦值證實了上文提到的$watch的第三個參數(shù)valueEq的作用。
示例代碼第23行,由于$digest循環(huán)會一直運行直到?jīng)]有dirty watcher時,故單次$digest循環(huán)通過緩存最后一個dirty的watcher,在下一次$digest循環(huán)時如果遇到$$lastDirtyWatcher就停止當前循環(huán)。這樣做減少了遍歷watcher的數(shù)量,優(yōu)化了性能。
外層循環(huán)
在我們的示例中,外層循環(huán)即由 $digest來控制。$digest函數(shù)主要由do while循環(huán)體內(nèi)調(diào)用$digestOnce進行臟檢測 以及 對其他一些異步操作的處理組成。代碼如下:
// digest循環(huán)的外循環(huán),保持循環(huán)直到?jīng)]有臟值為止
Scope.prototype.$digest = function() {
var ttl = TTL;
var dirty;
this.$root.$$lastDirtyWatch = null;
this.$beginPhase('$digest');
if(this.$root.$$applyAsyncId) {
clearTimeout(this.$root.$$applyAsyncId);
this.$$flushApplyAsync();
}
do {
while (this.$$asyncQueue.length) {
try {
var asyncTask = this.$$asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression);
} catch(e) {
console.error(e);
}
}
dirty = this.$$digestOnce();
if((dirty || this.$$asyncQueue.length) && !(ttl--)) {
this.$clearPhase();
throw TTL + ' digest iterations reached';
}
} while (dirty || this.$$asyncQueue.length);
this.$clearPhase();
while(this.$$postDigestQueue.length) {
try {
this.$$postDigestQueue.shift()();
} catch(e) {
console.error(e);
}
}
};
在這一節(jié)中我們的主要關注點是臟檢測,異步任務相關的$$applyAsync,$$flushApplyAsync,$$asyncQueue,$$postDigestQueue之后再做分析。
示例代碼第24行,調(diào)用$$digestOnce,并把返回值賦值給dirty。在do while循環(huán)中,只要dirty為true,那么循環(huán)就會一直執(zhí)行下去,直到dirty的值為 false。這就是臟檢測機制的外層循環(huán)的實現(xiàn),是不是覺得其實很簡單呢,嘿嘿。
設想一下,某些值可能會在listenerFn中持續(xù)被改變并且,無法穩(wěn)定下來,那勢必會出現(xiàn)死循環(huán)。為了解決這個問題,AngularJs使用 TTL(time to live)來對循環(huán)次數(shù)進行控制,超過最大次數(shù),就會throw錯誤 并 告訴開發(fā)者循環(huán)可能永遠不會穩(wěn)定。
現(xiàn)在我們把注意力移到代碼第26行的 if 代碼塊上,不難看出,這里是對最大$digest循環(huán)次數(shù)進行了限制,每執(zhí)行一次do while循環(huán)的循環(huán)體,TTL就會自減1。當TTL值為0,再進行循環(huán)就會報錯。當然咯,這個TTL的值也是能夠進行配置的。
現(xiàn)在,相信小伙伴們對$digest循環(huán)已經(jīng)比較清楚了吧~簡單來說,dirty-checking就是依賴緩存在scope上的$$watchers和$digest循環(huán)來對值進行監(jiān)聽的。有了$digest,當然還需要有手段去觸發(fā)它咯。
接下來,我們將進入第二部分:觸發(fā)$digest循環(huán) 和 異步任務處理
$eval
說到觸發(fā)$digest循環(huán),大部分同學都會想到$apply。要說$apply就需要先說說$eval。
$eval使我們能夠在scope的context中執(zhí)行一段表達式,并允許傳入locals object對當前scope context進行修改。
tip:$parse服務能夠接受一個表達式或者函數(shù)作為參數(shù),經(jīng)過處理返回一個函數(shù)供開發(fā)者調(diào)用。這個函數(shù)有兩個參數(shù)context object(通常就是scope),locals object(本地對象,常用來覆蓋context中的屬性)。
Scope.prototype.$eval = function(expr, locals) {
return $parse(expr)(this, locals);
};
$apply
$apply 方法接收一個expression或者function作為參數(shù),$apply通過$eval函數(shù)執(zhí)行傳入的expression 或 function。最終從$rootScope上觸發(fā)$digest循環(huán)。
$apply 被認為是 使AngularJs與第三方庫混合使用最標準的方式。初學者朋友剛開始都會遇到用第三方庫修改了scope上的屬性或者被watch的屬性,但并沒有觸發(fā)$digest循環(huán),導致雙向綁定失效的問題。此時,$apply就是解決這種情況的良藥!
Scope.prototype.$apply = function(expr) {
try {
this.$beginPhase('$apply');
return this.$eval(expr);
} finally {
this.$clearPhase();
this.$root.$digest();
}
};
$apply本質(zhì)上,就是用$eval執(zhí)行了一段表達式,再調(diào)用rootScope的$digest方法。
有時候,當我們能夠確定我們不需要從rootScope開始進行$digest循環(huán)時,我可以調(diào)用scope.digest() 來代替 $apply,這樣能夠帶來性能的提升。
$evalAsync
$evalAsync 用于延遲執(zhí)行一段表達式。通常我們更習慣使用$timeout服務來進行代碼的延遲執(zhí)行,但$timeout會將執(zhí)行控制權交給瀏覽器,如果瀏覽器同時還需要執(zhí)行諸如 ui渲染/事件控制/ajax 等任務時,我們代碼延遲執(zhí)行的時機就會變得非常不可控。
我們來看看$evalAsync是如何讓代碼延遲執(zhí)行的時機變得嚴格,可控的。
Scope.prototype.$evalAsync = function(expr) {
var self = this;
if(!self.$$phase && !self.$$asyncQueue.length) {
setTimeout(function() {
if(self.$$asyncQueue.length) {
self.$root.$digest();
}
}, 0);
}
this.$$asyncQueue.push({
scope: this,
expression: expr
});
};
$evalAsync方法的主要功能是從代碼第11行開始,向$$asyncQueeu中添加對象。$$asyncQueue隊列的執(zhí)行是在$digest的do while循環(huán)中進行的。
while (this.$$asyncQueue.length) {
try {
var asyncTask = this.$$asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression);
} catch(e) {
console.error(e);
}
}
$evalAsync的代碼會在正在運行的$digest循環(huán)中被執(zhí)行,如果當前沒有正在運行的$digest循環(huán),會自己延遲觸發(fā)一個$digest循環(huán)來執(zhí)行延遲代碼。
$applyAsync
$applyAsync用于合并短時間內(nèi)多次$digest循環(huán),優(yōu)化應用性能。
在日常開發(fā)工作中,常常會遇到要短時間內(nèi)接收若干http響應,同時觸發(fā)多次$digest循環(huán)的情況。使用$applyAsync可合并若干次$digest,優(yōu)化性能。
/* 這個方法用于 知道需要在短時間內(nèi)多次使用$apply的情況,
能夠?qū)Χ虝r間內(nèi)多次$digest循環(huán)進行合并,
是針對$digest循環(huán)的優(yōu)化策略
*/
Scope.prototype.$applyAsync = function(expr) {
var self = this;
self.$$applyAsyncQueue.push(function() {
self.$eval(expr);
});
if(self.$root.$$applyAsyncId === null) {
self.$root.$$applyAsyncId = setTimeout(function() {
self.$apply(_.bind(self.$$flushApplyAsync, self));
}, 0);
}
};
$$postDigest
$$postDigest方法提供了在下一次digest循環(huán)后執(zhí)行代碼的方式,這個方法的前綴是"$$",是一個AngularJs內(nèi)部方法,應用開發(fā)極少用到。
此方法不自主觸發(fā)$digest循環(huán),而是在別處產(chǎn)生$digest循環(huán)之后執(zhí)行。
/* $$postDigest 用于在下一次digest循環(huán)后執(zhí)行函數(shù)隊列
不同于applyAsync 和 evalAsync, 它不觸發(fā)digest循環(huán)
*/
Scope.prototype.$$postDigest = function(fn) {
this.$$postDigestQueue.push(fn);
};
到這里,我們對臟檢測的原理,即它的工作機制就了解的差不多了。希望這些知識能夠幫助你更好的應用AngularJs來開發(fā),能夠更輕松地定位錯誤。
下一章,我會繼續(xù)為大家介紹文章開頭提到的另外兩處scope相關的特性。篇幅較長,感謝您的耐心閱讀~也希望大家多多支持腳本之家。
相關文章
詳解Angular中的自定義服務Service、Provider以及Factory
本篇文章主要介紹了詳解Angular中的自定義服務Service、Provider以及Factory,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-04-04
AngularJS入門教程之與服務器(Ajax)交互操作示例【附完整demo源碼下載】
這篇文章主要介紹了AngularJS與服務器Ajax交互操作的方法,可實現(xiàn)post傳輸數(shù)據(jù)的功能,并附帶完整的demo源碼供讀者下載參考,源碼中還包含了前面章節(jié)的示例文件,需要的朋友可以參考下2016-11-11
基于angular實現(xiàn)三級聯(lián)動的生日插件
這篇文章主要為大家詳細介紹了基于angular實現(xiàn)三級聯(lián)動的生日插件,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-05-05
Angular.js 4.x中表單Template-Driven Forms詳解
Angular 4.x 中有兩種表單,一種是Template-Driven Forms - 模板驅(qū)動式表單,另外一種是Reactive Forms - 響應式表單 ,下面這篇文章主要給大家介紹了Angular.js 4.x中表單Template-Driven Forms的相關資料,需要的朋友可以參考學習,下面來一起看看吧。2017-04-04

