AngularJS數據雙向綁定揭祕
AngularJS在$scope變量中使用髒值檢查來實現了數據雙向綁定。和Ember.js數據雙向綁定中動態設施setter和getter不同,髒治檢查允許AngularJS監視那些存在或者不存在的變量。
$scope.$watch
$scope.$watch( watchExp, listener, objectEquality );
爲了監視一個變量的變化,你可以使用$scope.$watch函數。這個函數有三個參數,它指明瞭”要觀察什麼”(watchExp),”在變化時要發生什麼”(listener),以及你要監視的是一個變量還是一個對象。當我們在檢查一個參數時,我們可以忽略第三個參數。例如下面的例子:
$scope.name = 'Ryan';
$scope.$watch( function( ) {
return $scope.name;
}, function( newValue, oldValue ) {
console.log('$scope.name was updated!');
} );
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
代碼laycode
- v1.1
AngularJS將會在$scope中註冊你的監視函數。你可以在控制檯中輸出$scope來查看$scope中的註冊項目。
你可以在控制檯中看到$scope.name已經發生了變化 – 這是因爲$scope.name之前的值似乎undefined而現在我們將它賦值爲Ryan!
對於$wach的第一個參數,你也可以使用一個字符串。這和提供一個函數完全一樣。在AngularJS的源代碼中可以看到,如果你使用了一個字符串,將會運行下面的代碼:
if (typeof watchExp == 'string' && get.constant) {
var originalFn = watcher.fn;
watcher.fn = function(newVal, oldVal, scope) {
originalFn.call(this, newVal, oldVal, scope);
arrayRemove(array, watcher);
};
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
代碼laycode
- v1.1
這將會把我們的watchExp設置爲一個函數,它也自動返回作用域中我們已經制定了名字的變量。
$$watchers
$scope中的$$watchers變量保存着我們定義的所有的監視器。如果你在控制檯中查看$$watchers,你會發現它是一個對象數組。
$$watchers = [
{
eq: false, // 表明我們是否需要檢查對象級別的相等
fn: function( newValue, oldValue ) {}, // 這是我們提供的監聽器函數
last: 'Ryan', // 變量的最新值
exp: function(){}, // 我們提供的watchExp函數
get: function(){} // Angular's編譯後的watchExp函數
}
];
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
代碼laycode
- v1.1
$watch函數將會返回一個deregisterWatch函數。這意味着如果我們使用$scope.$watch對一個變量進行監視,我們也可以在以後通過調用某個函數來停止監視。
$scope.$apply
當一個控制器/指令/等等東西在AngularJS中運行時,AngularJS內部會運行一個叫做$scope.$apply的函數。這個$apply函數會接收一個函數作爲參數並運行它,在這之後纔會在rootScope上運行$digest函數。
AngularJS的$apply函數代碼如下所示:
$apply: function(expr) {
try {
beginPhase('$apply');
return this.$eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {
clearPhase();
try {
$rootScope.$digest();
} catch (e) {
$exceptionHandler(e);
throw e;
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
代碼laycode
- v1.1
上面代碼中的expr參數就是你在調用$scope.$apply()時傳遞的參數 – 但是大多數時候你可能都不會去使用$apply這個函數,要用的時候記得給它傳遞一個參數。
下面我們來看看ng-keydown是怎麼來使用$scope.$apply的。爲了註冊這個指令,AngularJS會使用下面的代碼。
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 ngEventHandler(scope, element) {
element.on(lowercase(name), function(event) {
scope.$apply(function() {
fn(scope, {$event:event});
});
});
};
}
};
}];
}
);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
代碼laycode
- v1.1
上面的代碼做的事情是循環了不同的類型的事件,這些事件在之後可能會被觸發並創建一個叫做ng-[某個事件]的新指令。在指令的compile函數中,它在元素上註冊了一個事件處理器,它和指令的名字一一對應。當事件被出發時,AngularJS就會運行scope.$apply函數,並讓它運行一個函數。
只是單向數據綁定嗎?
上面所說的ng-keydown只能夠改變和元素值相關聯的$scope中的值 – 這只是單項數據綁定。這也是這個指令叫做ng-keydown的原因,只有在keydown事件被觸發時,能夠給與我們一個新值。
但是我們想要的是雙向數據綁定!
我們現在來看一看ng-model。當你在使用ng-model時,你可以使用雙向數據綁定 – 這正是我們想要的。AngularJS使用$scope.$watch(視圖到模型)以及$scope.$apply(模型到視圖)來實現這個功能。
ng-model會把事件處理指令(例如keydown)綁定到我們運用的輸入元素上 – 這就是$scope.$apply被調用的地方!而$scope.$watch是在指令的控制器中被調用的。你可以在下面代碼中看到這一點:
$scope.$watch(function ngModelWatch() {
var value = ngModelGet($scope);
//如果作用域模型值和ngModel值沒有同步
if (ctrl.$modelValue !== value) {
var formatters = ctrl.$formatters,
idx = formatters.length;
ctrl.$modelValue = value;
while(idx--) {
value = formatters[idx](value);
}
if (ctrl.$viewValue !== value) {
ctrl.$viewValue = value;
ctrl.$render();
}
}
return value;
});
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
代碼laycode
- v1.1
如果你在調用$scope.$watch時只爲它傳遞了一個參數,無論作用域中的什麼東西發生了變化,這個函數都會被調用。在ng-model中,這個函數被用來檢查模型和視圖有沒有同步,如果沒有同步,它將會使用新值來更新模型數據。這個函數會返回一個新值,當它在$digest函數中運行時,我們就會知道這個值是什麼!
爲什麼我們的監聽器沒有被觸發?
如果我們在$scope.$watch的監聽器函數中停止這個監聽,即使我們更新了$scope.name,該監聽器也不會被觸發。
正如前面所提到的,AngularJS將會在每一個指令的控制器函數中運行$scope.$apply。如果我們查看$scope.$apply函數的代碼,我們會發現它只會在控制器函數已經開始被調用之後纔會運行$digest函數 – 這意味着如果我們馬上停止監聽,$scope.$watch函數甚至都不會被調用!但是它究竟是怎樣運行的呢?
$digest函數將會在$rootScope中被$scope.$apply所調用。它將會在$rootScope中運行digest循環,然後向下遍歷每一個作用域並在每個作用域上運行循環。在簡單的情形中,digest循環將會觸發所有位於$$watchers變量中的所有watchExp函數,將它們和最新的值進行對比,如果值不相同,就會觸發監聽器。
當digest循環運行時,它將會遍歷所有的監聽器然後再次循環,只要這次循環發現了”髒值”,循環就會繼續下去。如果watchExp的值和最新的值不相同,那麼這次循環就會被認爲發現了髒值。理想情況下它會運行一次,如果它運行超10次,你會看到一個錯誤。
因此當$scope.$apply運行的時候,$digest也會運行,它將會循環遍歷$$watchers,只要發現watchExp和最新的值不相等,變化觸發事件監聽器。在AngularJS中,只要一個模型的值可能發生變化,$scope.$apply就會運行。這就是爲什麼當你在AngularJS之外更新$scope時,例如在一個setTimeout函數中,你需要手動去運行$scope.$apply():這能夠讓AngularJS意識到它的作用域發生了變化。
創建自己的髒值檢查
到此爲止,我們已經可以來創建一個小巧的,簡化版本的髒值檢查了。當然,相比較之下,AngularJS中實現的髒值檢查要更加先進一些,它提供瘋了異步隊列以及其他一些高級功能。
設置Scope
Scope僅僅只是一個函數,它其中包含任何我們想要存儲的對象。我們可以擴展這個函數的原型對象來複制$digest和$watch。我們不需要$apply方法,因爲我們不需要在作用域的上下文中執行任何函數 – 我們只需要簡單的使用$digest。我們的Scope的代碼如下所示:
var Scope = function( ) {
this.$$watchers = [];
};
Scope.prototype.$watch = function( ) {
};
Scope.prototype.$digest = function( ) {
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
代碼laycode
- v1.1
我們的$watch函數需要接受兩個參數,watchExp和listener。當$watch被調用時,我們需要將它們push進入到Scope的$$watcher數組中。
var Scope = function( ) {
this.$$watchers = [];
};
Scope.prototype.$watch = function( watchExp, listener ) {
this.$$watchers.push( {
watchExp: watchExp,
listener: listener || function() {}
} );
};
Scope.prototype.$digest = function( ) {
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
代碼laycode
- v1.1
你可能已經注意到了,如果沒有提供listener,我們會將listener設置爲一個空函數 – 這樣一來我們可以$watch所有的變量。
接下來我們將會創建$digest。我們需要來檢查舊值是否等於新的值,如果二者不相等,監聽器就會被觸發。我們會一直循環這個過程,直到二者相等。這就是”髒值”的來源 – 髒值意味着新的值和舊的值不相等!
var Scope = function( ) {
this.$$watchers = [];
};
Scope.prototype.$watch = function( watchExp, listener ) {
this.$$watchers.push( {
watchExp: watchExp,
listener: listener || function() {}
} );
};
Scope.prototype.$digest = function( ) {
var dirty;
do {
dirty = false;
for( var i = 0; i < this.$$watchers.length; i++ ) {
var newValue = this.$$watchers[i].watchExp(),
oldValue = this.$$watchers[i].last;
if( oldValue !== newValue ) {
this.$$watchers[i].listener(newValue, oldValue);
dirty = true;
this.$$watchers[i].last = newValue;
}
}
} while(dirty);
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
代碼laycode
- v1.1
接下來,我們將創建一個作用域的實例。我們將這個實例賦值給$scope。我們接着會註冊一個監聽函數,在更新$scope之後運行$digest!
var Scope = function( ) {
this.$$watchers = [];
};
Scope.prototype.$watch = function( watchExp, listener ) {
this.$$watchers.push( {
watchExp: watchExp,
listener: listener || function() {}
} );
};
Scope.prototype.$digest = function( ) {
var dirty;
do {
dirty = false;
for( var i = 0; i < this.$$watchers.length; i++ ) {
var newValue = this.$$watchers[i].watchExp(),
oldValue = this.$$watchers[i].last;
if( oldValue !== newValue ) {
this.$$watchers[i].listener(newValue, oldValue);
dirty = true;
this.$$watchers[i].last = newValue;
}
}
} while(dirty);
};
var $scope = new Scope();
$scope.name = 'Ryan';
$scope.$watch(function(){
return $scope.name;
}, function( newValue, oldValue ) {
console.log(newValue, oldValue);
} );
$scope.$digest();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
代碼laycode
- v1.1
成功了!我們現在已經實現了髒值檢查(雖然這是最簡單的形式)!上述代碼將會在控制檯中輸出下面的內容:
Ryan undefined
這正是我們想要的結果 – $scope.name之前的值是undefined,而現在的值是Ryan。
現在我們把$digest函數綁定到一個input元素的keyup事件上。這就意味着我們不需要自己去調用$digest。這也意味着我們現在可以實現雙向數據綁定!
var Scope = function( ) {
this.$$watchers = [];
};
Scope.prototype.$watch = function( watchExp, listener ) {
this.$$watchers.push( {
watchExp: watchExp,
listener: listener || function() {}
} );
};
Scope.prototype.$digest = function( ) {
var dirty;
do {
dirty = false;
for( var i = 0; i < this.$$watchers.length; i++ ) {
var newValue = this.$$watchers[i].watchExp(),
oldValue = this.$$watchers[i].last;
if( oldValue !== newValue ) {
this.$$watchers[i].listener(newValue, oldValue);
dirty = true;
this.$$watchers[i].last = newValue;
}
}
} while(dirty);
};
var $scope = new Scope();
$scope.name = 'Ryan';
var element = document.querySelectorAll('input');
element[0].onkeyup = function() {
$scope.name = element[0].value;
$scope.$digest();
};
$scope.$watch(function(){
return $scope.name;
}, function( newValue, oldValue ) {
console.log('Input value updated - it is now ' + newValue);
element[0].value = $scope.name;
} );
var updateScopeValue = function updateScopeValue( ) {
$scope.name = 'Bob';
$scope.$digest();
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 65
- 66
代碼laycode
- v1.1
使用上面的代碼,無論何時我們改變了input的值,$scope中的name屬性都會相應的發生變化。這就是隱藏在AngularJS神祕外衣之下數據雙向綁定的祕密!
本文參考自How AngularJS implements dirty checking and how to replicate it ourselves,原文地址http://ryanclark.me/how-angularjs-implements-dirty-checking/