angular中的$scope是頁面(view)和數據(model)之間的橋梁,它鏈接了頁面元素和model,也是angular雙向綁定機制的核心。
而ngModel是angular用來處理表單(form)的最重要的指令,它鏈接了頁面表單中的可交互元素和位於$scope之上的model,它會自動把ngModel所指向的model值渲染到form表單的可交互元素上,同時也會根據用戶在form表單的輸入或交互來更新此model值。
在源碼中,model值的格式化、解析、驗證都是由ngModel指令所對應的控制器ngModelController來實現的。
在筆者所維護的國內ng群中,經常被問到一個問題:
為什么我的ng-model=“xxx”值不能在頁面顯示了呢?
對於ngModel的這類問題主要分為兩類:
- model值不滿足表單驗證條件,所以angular不會渲染它
- 由於JavaScript特殊的原型鏈繼承機制,對$scope中屬性的賦值並不能更新到父$scope
在本節中,我們將會詳細分析此類問題,借此深入剖析ngModel的工作原理。
驗證引起的model值不顯示
我們先來看一個修改商品數量的例子,要求為必須輸入1-100的個數;
下面是對應的html代碼:
<body class="container">
<div ng-controller="DemoCtrl as demo">
<div ng-form="form" class="form-horizontal">
<div class="form-group" ng-class="{'has-error': form.amount.$invalid }">
<label for="amount">Amount</label>
<!-- 這個input將工作不正常 -->
<input id="amount" name="amount" type="number" ng-model="demo.amount" class="form-control" placeholder="1 - 100" min="1" max="100"/>
</div>
</div>
</div>
</body>
javascript代碼:
angular.module("com.ngbook.demo", [])
.controller("DemoCtrl", [function(){
var vm = this;
vm.amount = 0;
return vm;
}]);
在代碼中我們已經為ngModel變量amount賦值了整數“0”,可是界面顯示效果仍然顯示”1 – 100”的placeholder(如下圖)。

下面是關於angular number組件ngModel轉換函數代碼:
var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/;
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
textInputType(scope, element, attr, ctrl, $sniffer, $browser);
ctrl.$parsers.push(function(value) {
var empty = ctrl.$isEmpty(value);
if (empty || NUMBER_REGEXP.test(value)) {
ctrl.$setValidity('number', true);
return value === '' ? null : (empty ? value : parseFloat(value));
} else {
ctrl.$setValidity('number', false);
return undefined;
}
});
addNativeHtml5Validators(ctrl, 'number', numberBadFlags, null, ctrl.$$validityState);
ctrl.$formatters.push(function(value) {
return ctrl.$isEmpty(value) ? '' : '' + value;
});
if (attr.min) {
var minValidator = function(value) {
var min = parseFloat(attr.min);
return validate(ctrl, 'min', ctrl.$isEmpty(value) || value >= min, value);
};
ctrl.$parsers.push(minValidator);
ctrl.$formatters.push(minValidator);
}
if (attr.max) {
var maxValidator = function(value) {
var max = parseFloat(attr.max);
return validate(ctrl, 'max', ctrl.$isEmpty(value) || value <= max, value);
};
ctrl.$parsers.push(maxValidator);
ctrl.$formatters.push(maxValidator);
}
ctrl.$formatters.push(function(value) {
return validate(ctrl, 'number', ctrl.$isEmpty(value) || isNumber(value), value);
});
}
ngModel作為angular雙向綁定中的重要組成部分,負責view控件交互數據到$scope上model的同步。當然這里存在一些差異,view上的顯示和輸入都是字符串類型,而在model上的數據則是有特定類型的,如常用的int、float、Date、Array、Object等。ngModel為了實現數據到model的類型轉換,在ngModelController中提供了兩個管道數組$formatters和$parsers,它們分別是將model的數據轉換為view交互控件顯示的值和將交互控件得到的view值轉換為model數據,它們都是一個數組對象,在ngModel啟動數據轉換時,會以UNIX管道式傳遞執行這一些列的轉換。我們也可以手動的添加$formatters和$parsers的轉換函數(unshift、push),當然在這里也是做數據驗證的最佳時機,能夠轉換意味應該是合法的數據。
在number組件代碼中,我們清晰看見:依次添加了對數字驗證轉換、最小值合法性驗證、最大值合法驗證。首先會啟動$parsers轉換,如果在轉換過程中出現不合法驗證則會設置ngModelController.$setValidity驗證錯誤,則返回undefined。對於model數據到交互控件顯示,同樣也會經過$formatters轉換管道,對於沒有通過驗證的邏輯,同樣也會ngModelController.$setValidity設置驗證錯誤,返回undefined,因此這不合法的model數據不會顯示在交互控件上。
原型鏈繼承問題
JavaScript中每個對象都會鏈接到一個原型對象,並且他可以從中繼承屬性。即使通過字面量創建的對象也會鏈接到Object.prototype,它是JavaScript中的標配對象。JavaScript的原型鏈繼承相對於其他語言常見的繼承,是一種另類的繼承,它是實施於對象上的動態繼承方式,而非常見的實施與類型class之上的靜態繼承體系。JavaScript的這種繼承方式很靈活,一個對象可以被多個對象繼承,而且他們共享同一實例對象,但理解起來顯得格外復雜,從JavaScript原型和原型鏈可以看出它的復雜性。在Javascript中,每個函數都有一個原型屬性prototype指向自身的原型,而由這個函數創建的對象也有一個proto屬性指向這個原型,而函數的原型是一個對象,所以這個對象也會有一個proto指向自己的原型,這樣逐層深入直到Object對象的原型,這樣就形成了原型鏈。下面的是JavaScript原型繼承基礎原型和原型鏈展示圖。

函數是由Function函數創建的對象,因此函數也有一個proto屬性指向Function函數的原型。需要注意的是,真正形成原型鏈的是每個對象的proto屬性,而不是函數的prototype屬性。更多的內容關於原型和原型鏈的知識,請參考《Javascript模式》這本書。
JavaScript的原型鏈連接只在屬性檢索的時候才會啟用,如果我們嘗試去獲取對象的某個屬性值,但該對象沒有此屬性名,則JavaScript會試着從原型對象中獲取該屬性值。如果那個對象也沒有該屬性名,那么在繼續從它的原型中尋找,依次類推,直到Object.prototype,如果仍然沒有找到該屬性值,則返回結果為undefined。不幸的是,這種原型鏈連接檢索,只會在屬性檢索的的時候啟用,並不會在更新屬性值時啟用,因此當我們對於基礎類型(非引用對象上的屬性,換句通俗的話來說,就是不會出現“.”運算符)的屬性更新的時候,它並不能更新父對象的屬性,替代方式是在自身對象上創建了該屬性。這也是angular中對於基礎類型的屬性,不能在子controller中被修改的原因,導致在子controller中ngModel的更新並不會反應在父controller上。
下邊是關於該問題的一個簡化例子:
HTML:
<div ng-controller="ParentCtrl">
<div class="form-group">
<h4>Parent Controller:</h4>
<pre></pre>
<input type="text" ng-model="greet" class="form-control" />
</div>
<div ng-controller="ChildCtrl">
<div class="form-group">
<h4>Child controller:</h4>
<pre></pre>
<input type="text" ng-model="greet" class="form-control" />
</div>
</div>
</div>
JavaScript:
angular.module("com.ngbook.demo", [])
.controller("ParentCtrl", ["$scope", function($scope) {
$scope.greet = "hello angular!";
}])
.controller("ChildCtrl", angular.noop);
從初始化顯示效果中,我們能看出子$scope之繼承了來自父$scope的greet屬性,都顯示為”hello angular!“。如果我們嘗試利用父controller提供了input控件改變父$scope的greet屬性,你也能看見子controller區域的顯示也會被及時更新。對於ngController默認會使用原型鏈繼承其父對象的屬性,所有的$scope的根$scope或稱祖$scope是來自ngApp節點創建的$rootScope,換句話說,$rootScope是萬物之源,所有的$scope都直接或者間接繼承至它。

當我們嘗試去改變輸入框的greet屬性的時,則發生了下面的情況:子controller區域發生了更新,父controller區域卻無法更新。因為上面所說的JavaScript的原型鏈檢索並不對更新啟用,對於基礎類型JavaScript在自身對象(這里是子$scope)上創建了一個同名的變量。你也想可以從下面angular調試插件batarang截圖中看出來。一旦利用子controller的input控件修改了greet屬性,再次之后我再次嘗試修改父controller區域的greet屬性,子controller區別不會在像初始化時候那樣及時同步了,它們之間完全獨立了,各自擁有了自己的greet屬性。

batarang插件截圖

經過上面的例子分析,相信作為讀者的你已經能夠理解這類由於繼承鏈引用問題導致的ngModel不能更新問題了,請記住:這是JavaScript原型繼承的issue,並不是angular的issue。
那么我們在子controller中如何更新父controller的屬性值呢?這個問題已經很簡單了,issue的問題在於沒有啟用原型鏈的檢索,那么如果我們將ngModel的屬性變為引用對象,換句話說:在ngModel的屬性值中加了“.”,那么在JavaScript的原型鏈檢索就會啟動了。
HTML:
<div ng-controller="ParentCtrl">
<div class="form-group">
<h4>Parent Controller:</h4>
<pre></pre>
<input type="text" ng-model="vm.greet" class="form-control" />
</div>
<div ng-controller="ChildCtrl">
<div class="form-group">
<h4>Child controller:</h4>
<pre></pre>
<input type="text" ng-model="vm.greet" class="form-control" />
</div>
</div>
</div>
JavaScript:
angular.module("com.ngbook.demo", [])
.controller("ParentCtrl", ["$scope", function($scope) {
$scope.vm = {
greet: "hello angular!"
};
}])
.controller("ChildCtrl", angular.noop);
jsbin demo: http://jsbin.com/metufi/1/edit?html,js,output
這里在ngModel屬性值多引入了“vm”變量,這個時候,不管我們嘗試修改greet值,整個頁面都會得到相應的同步。關於這個問題,作者更推薦使用angular 1.2后的controller as vm的方式解決,更多的信息請閱讀《使用controller as vm方式.md》一節。
