AngularJs 的元素與模型雙向綁定依賴於循環檢測它們之間的值,這種做法叫做臟檢測,這幾天研究了一下其源碼,將 Angular 的實現分享一下。
首先看看如何將 Model 的變更更新到 UI
Angular 的 Model 是一個 Scope 的類型,每個 Scope 都歸屬於一個 Directive 對象,比如 $rootScope 就歸屬於 ng-app。
從 ng-app 往下,每個 Directive 創建的 Scope 都會一層一層鏈接下去,形成一個以 $rootScope 為根的鏈表,注意 Scope 還有同級的概念,形容更貼切我覺得應該是一棵樹。
我們大概看一下 Scope 都有哪些成員:
function Scope() {
this.$id = nextUid();
// 依次為: 階段、父 Scope、Watch 函數集、下一個同級 Scope、上一個同級 Scope、首個子級 Scope、最后一個子級 Scope
this.$$phase = this.$parent = this.$$watchers =
this.$$nextSibling = this.$$prevSibling =
this.$$childHead = this.$$childTail = null;
// 重寫 this 屬性以便支持原型鏈
this['this'] = this.$root = this;
this.$$destroyed = false;
// 以當前 Scope 為上下文的異步求值隊列,也就是一堆 Angular 表達式
this.$$asyncQueue = [];
this.$$postDigestQueue = [];
this.$$listeners = {};
this.$$listenerCount = {};
this.$$isolateBindings = {};
}
Scope.$digest,這是 Angular 提供的從 Model 更新到 UI 的接口,你從哪個 Scope 調用,那它就會從這個 Scope 開始遍歷,通知模型更改給各個 watch 函數,
來看看 $digest 的源碼:
$digest: function() {
var watch, value, last,
watchers,
asyncQueue = this.$$asyncQueue,
postDigestQueue = this.$$postDigestQueue,
length,
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
logIdx, logMsg, asyncTask;
// 標識階段,防止多次進入
beginPhase('$digest');
// 最后一個檢測到臟值的 watch 函數
lastDirtyWatch = null;
// 開始臟檢測,只要還有臟值或異步隊列不為空就會一直循環
do {
dirty = false;
// 當前遍歷到的 Scope
current = target;
// 處理異步隊列中所有任務, 這個隊列由 scope.$evalAsync 方法輸入
while(asyncQueue.length) {
try {
asyncTask = asyncQueue.shift();
asyncTask.scope.$eval(asyncTask.expression);
} catch (e) {
clearPhase();
$exceptionHandler(e);
}
lastDirtyWatch = null;
}
traverseScopesLoop:
do {
// 取出當前 Scope 的所有 watch 函數
if ((watchers = current.$$watchers)) {
length = watchers.length;
while (length--) {
try {
watch = watchers[length];
if (watch) {
// 1.取 watch 函數的運算新值,直接與 watch 函數最后一次值比較
// 2.如果比較失敗則嘗試調用 watch 函數的 equal 函數,如果沒有 equal 函數則直接比較新舊值是否都是 number 而且都是 NaN
if ((value = watch.get(current)) !== (last = watch.last) &&
!(watch.eq
? equals(value, last)
: (typeof value == 'number' && typeof last == 'number'
&& isNaN(value) && isNaN(last)))) {
// 檢測到值改變,設置一些標識
dirty = true;
lastDirtyWatch = watch;
watch.last = watch.eq ? copy(value, null) : value;
// 調用 watch 函數的變更通知函數, 也就是說各個 directive 從這里更新 UI
watch.fn(value, ((last === initWatchVal) ? value : last), current);
// 當 digest 調用次數大於 5 的時候(默認10),記錄下來以便開發人員分析。
if (ttl < 5) {
logIdx = 4 - ttl;
if (!watchLog[logIdx]) watchLog[logIdx] = [];
logMsg = (isFunction(watch.exp))
? 'fn: ' + (watch.exp.name || watch.exp.toString())
: watch.exp;
logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
watchLog[logIdx].push(logMsg);
}
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
dirty = false;
break traverseScopesLoop;
}
}
} catch (e) {
clearPhase();
$exceptionHandler(e);
}
}
}
// 恕我理解不能,下邊這三句是賣萌嗎
// Insanity Warning: scope depth-first traversal
// yes, this code is a bit crazy, but it works and we have tests to prove it!
// this piece should be kept in sync with the traversal in $broadcast
// 沒有子級 Scope,也沒有同級 Scope
if (!(next = (current.$$childHead || (current !== target && current.$$nextSibling)))) {
// 又判斷一遍不知道為什么,不過這個時候 next === undefined 了,也就退出當前 Scope 的 watch 遍歷了
while(current !== target && !(next = current.$$nextSibling)) {
current = current.$parent;
}
}
} while ((current = next));
// 當 TTL 用完,依舊有未處理的臟值和異步隊列則拋出異常
if((dirty || asyncQueue.length) && !(ttl--)) {
clearPhase();
throw $rootScopeMinErr('infdig',
'{0} $digest() iterations reached. Aborting!\n' +
'Watchers fired in the last 5 iterations: {1}',
TTL, toJson(watchLog));
}
} while (dirty || asyncQueue.length);
// 退出 digest 階段,允許其他人調用
clearPhase();
while(postDigestQueue.length) {
try {
postDigestQueue.shift()();
} catch (e) {
$exceptionHandler(e);
}
}
}
雖然看起來很長,但是很容易理解,默認從 $rootScope 開始遍歷,對每個 watch 函數求值比較,出現新值則調用通知函數,由通知函數更新 UI,我們來看看 ng-model 是怎么注冊通知函數的:
$scope.$watch(function ngModelWatch() {
var value = ngModelGet($scope);
// 如果 ng-model 當前記錄的 modelValue 不等於 Scope 的最新值
if (ctrl.$modelValue !== value) {
var formatters = ctrl.$formatters,
idx = formatters.length;
// 使用格式化器格式新值,比如 number,email 之類
ctrl.$modelValue = value;
while(idx--) {
value = formatters[idx](value);
}
// 將新值更新到 UI
if (ctrl.$viewValue !== value) {
ctrl.$viewValue = value;
ctrl.$render();
}
}
return value;
});
那么 UI 更改如何更新到 Model 呢
很簡單,靠 Directive 編譯時綁定的事件,比如 ng-model 綁定到一個輸入框的時候事件代碼如下:
var ngEventDirectives = {};
forEach(
'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '),
function(name) {
var directiveName = directiveNormalize('ng-' + name);
ngEventDirectives[directiveName] = ['$parse', function($parse) {
return {
compile: function($element, attr) {
var fn = $parse(attr[directiveName]);
return function(scope, element, attr) {
// 觸發以上指定的事件,就將元素的 scope 和 event 對象一起發送給 direcive
element.on(lowercase(name), function(event) {
scope.$apply(function() {
fn(scope, {$event:event});
});
});
};
}
};
}];
}
);
Directive 接收到輸入事件后根據需要再去 Update Model 就好啦。
相信經過以上研究應該對 Angular 的綁定機制相當了解了吧,現在可別跟人家說起臟檢測就覺得是一個 while(true) 一直在求值效率好低什么的,跟你平時用事件沒啥兩樣,多了幾次循環而已。
最后注意一點就是平時你通常不需要手動調用 scope.$digest,特別是當你的代碼在一個 $digest 中被回調的時候,因為已經進入了 digest 階段所以你再調用則會拋出異常。
我們只在沒有 Scope 上下文的代碼里邊需要調用 digest,因為此時你對 UI 或 Model 的更改 Angular 並不知情。