ng-repeat是AngularJS中一個非常重要和有意思的directive,常見的用法之一是將某種自定義directive和ng-repeat一起使用,循環地來渲染開發者所需要的組件。比如現在有一個form-text指令,用於快速構建起帶自定義數據驗證的表單文本框,我們可以用類似下面的代碼方便地建立起一個簡單的表單:
controller中:
$scope.form = {};
$scope.form.inputs = [{
model: 'name',
required: 'required',
title: '請輸入用戶名',
hints: '請輸入5-15個字符',
regexp: '^.{5,15}$',
classes: ['form-text', 'repeat-widget']
}, {
model: 'phone',
required: 'required',
title: '請輸入手機號',
hints: '請輸入11位手機號',
regexp: '^1[0-9]{10}$',
classes: ['form-text', 'repeat-widget']
}, {
model: 'email',
required: 'required',
title: '請輸入您的郵箱',
hints: '請正確輸入您的郵箱地址',
regexp: '^[\\w-.]+@\\w+\\.\\w+$',
classes: ['form-text', 'repeat-widget']
}];
html:
<div class="form-text" ng-repeat="input in form.inputs" ng-model="form[input.model]" required="{{input.required}}" title="{{input.title}}" hints="{{input.hints}}" regexp="{{input.regexp}}" items="input.items"></div>
然而這樣的用法有一個缺陷:當表單中含有其他類型的組件時,比如form-radio或form-checkbox(分別用於封裝radio或checkbox),如果只是簡單地將這些元素放入到inputs數組中,渲染結果可能並非如我們所期望的。
第一個容易想到的地方在於如何解決動態指定指令名稱的問題。正如大家所熟悉的,自定義direcitve的restrict通常有三種取值,A(attribute),C(classname)和 E(element)。在ng-repeat中要動態指定元素名或屬性名實現起來都較為困難,但是動態指定class名是比較容易的,常用的就有三種方法:既可以使用封裝級別較高的ng-class、ng-attr-class指令,又可以使用朴素的class="{{}}"。
根據這樣的思路,將上面代碼中的class="form-text"換成ng-class="input.classes"是否可以完成這個任務呢?恐怕沒有這么容易,雖然這是實現本文描述的業務邏輯的一個必要步驟,但並非最重要的步驟和關鍵點。
事實上,該業務的關鍵點在於理解AngularJS自定義指令的compile和link過程,並在恰當的時間點上予以靈活應用。本文將結合筆者的經驗,由淺入深地介紹整個實現過程。當然,受限於本人的AngularJS水平,文中必然會出現不少紕漏和不嚴謹之處,歡迎大家批評指正。
一. 本文中涉及到的自定義directive
正如上文所提及,為了方便解釋,我們先來創建了三種帶簡單驗證功能的自定義directive: form-text、form-radio和form-checkbox,分別對應原生的input[type=text]、input[type=radio]和input[type=checkbox]元素。
placeholder對應原生元素的placeholder屬性,hints對應錯誤提示,title對應輸入框上方的文本,required表示元素是否為必填項,regexp為驗證模式所需的正則表達式,items對應radio和checkbox的選項數組,數組中的每個對象有兩個屬性:text和value,分別對應顯示的label和實際的value。這些命令都被添加到了form.widgets模塊中:
(代碼較長,為了不影響閱讀,默認折疊了)
angular.module('form.widgets', [])
.directive('formText', function () {
return {
restrict: 'CE',
scope: {
placeholder: '@',
hints: '@',
title: '@',
required: '@',
regexp: '@',
type: '@'
},
require: 'ngModel',
template: ''
+ '<div style="margin-bottom:20px;">'
+ '<label>{{title}}</label>'
+ '<input class="form-control" ng-model="value" type="{{type}}"/>'
+ '<div ng-if="failed" style="margin-top:10px;" class="alert alert-danger">{{hints}}</div>'
+ '</div>',
link: function (scope, elem, attrs, ctrl) {
var required = scope.required === 'true' || scope.required === 'required';
var regexp = new RegExp(scope.regexp);
function validate(value) {
scope.failed = true;
if (value === '' && !required) {
scope.failed = false;
}
if (regexp.test(value)) {
scope.failed = false;
}
}
ctrl.$formatters.push(function (value) {
scope.value = value || '';
});
scope.$watch('value', function (value) {
ctrl.$setViewValue(value);
validate(value);
});
}
};
})
.directive('formRadio', function () {
return {
restrict: 'CE',
scope: {
items: '=',
title: '@',
name: '@',
required: '@',
hints: '@'
},
require: 'ngModel',
template: ''
+ '<div type="radio" style="margin-bottom:20px;">'
+ '<label>{{title}}</label>'
+ '<div>'
+ '<label style="margin-right:20px;" ng-repeat="item in items"><input name="{{name}}" value="{{item.value}}" ng-model="validator.value" type="radio"/> {{item.text}}</label>'
+ '</div>'
+ '<div ng-if="failed" style="margin-top:10px;" class="alert alert-danger">{{hints}}</div>'
+ '</div>',
link: function (scope, elem, attrs, ctrl) {
var required = scope.required === 'true' || scope.required === 'required';
var values = scope.items.map(function (item) {
return item.value + '';
});
function validate(value) {
value += '';
scope.failed = false;
if (required && values.indexOf(value) < 0) {
scope.failed = true;
}
}
ctrl.$formatters.push(function (value) {
scope.validator.value = value || '';
});
scope.validator = {};
scope.$watch('validator.value', function (value) {
ctrl.$setViewValue(value);
validate(value);
});
}
};
})
.directive('formCheckbox', function () {
return {
restrict: 'CE',
scope: {
items: '=',
title: '@',
required: '@',
hints: '@'
},
require: 'ngModel',
template: ''
+ '<div type="radio" style="margin-bottom:20px;">'
+ '<label>{{title}}</label>'
+ '<div>'
+ '<label style="margin-right:20px;" ng-repeat="item in items"><input ng-model="validator.value[item.value]" type="checkbox"/> {{item.text}}</label>'
+ '</div>'
+ '<div ng-if="failed" style="margin-top:10px;" class="alert alert-danger">{{hints}}</div>'
+ '</div>',
link: function (scope, elem, attrs, ctrl) {
var required = scope.required === 'true' || scope.required === 'required';
var values = scope.items.map(function (item) {
return item.value + '';
});
function validate(value) {
var checked = false;
for (var key in value) {
if (value[key]) {
checked = true;
}
}
scope.failed = required && !checked ? true : false;
}
ctrl.$formatters.push(function (value) {
value = value || [];
scope.validator.value = {};
value.forEach(function (item) {
scope.validator.value[item] = true;
});
});
scope.validator = {};
scope.$watch('validator.value', function (value) {
var viewValue = [];
for (var key in value) {
if (value[key]) {
viewValue.push(key);
}
}
ctrl.$setViewValue(viewValue);
validate(value);
}, true);
}
};
});
二. 自定義directive的聲明式(declarative)使用
該類用法比較簡單也比較典型,在這里就不多贅述。唯一需要注意的是,myApp模塊依賴於form.widgets模塊。
<form-text ng-model="form.name" required="required" title="請輸入用戶名" hints="請輸入5-15個字符" regexp="^.{5,15}$"></form-text>
<form-text ng-model="form.email" required="required" title="請輸入您的郵箱" hints="請正確輸入您的郵箱地址" regexp="^[\w-.]+@\w+\.\w+$"></form-text>
<form-radio ng-model="form.gender" name="gender" items="form.genders" required="required" title="請選擇性別" hints="請選擇性別"></form-radio>
<form-checkbox ng-model="form.interest" items="form.interests" required="required" title="請告訴我們您的興趣愛好" hints="請至少選擇一項"></form-checkbox>
<script>
angular.module('myApp', ['form.widgets'])
.controller('myCtrl', function ($scope, $timeout, $compile) {
var form = {};
$scope.form = form;
form.genders = [{
text: '男',
value: 0
}, {
text: '女',
value: 1
}];
form.interests = [{
text: '電影',
value: 'films'
}, {
text: '音樂',
value: 'music'
}, {
text: '足球',
value: 'soccer'
}, {
text: '健身',
value: 'fitness'
}];
});
</script>
三. 利用ng-repeat循環聲明單一類型的自定義directive
這種用法就是文首提到的用法。代碼之前已經貼過了,在這里就不重復了。第一感可能會認為這種方案之所以可用,是因為ng-repeat的優先級非常低(ngRepeat指令的優先級為1000,參見文檔https://docs.angularjs.org/api/ng/directive/ngRepeat)。是否的確是這個原因,第四種用法中會有所涉及,大家可以自行判斷。
四. ng-repeat動態解析自定義directive
終於到了本文的核心部分, 首先我們要回答一個問題:
既然ng-repeat的優先級低,而ng-class的優先級高(默認優先級,0),ng-class解析完成后新的classname,比如form-text,已經被添加上(姑且這么認為,事實上ng-class對classname的修改並不是發生在link階段),和第三種用法類似,既然如此,為什么基於classname的directive無法被識別?
因為太晚啦!因為太晚啦!因為太晚啦!(重要的事情說三遍)
在對於某段特定的HTML片段進行$compile時,該過程只會執行一次;$complie結束時,返回的link函數中已經包含了之后要調用的各directive的link方法的信息(這句話中的兩個link含義不同,第一個link指AngularJS編譯HTML的link階段,第二個link指某一指令的link方法)。也就是說,雖然ng-class的優先級較高,在ng-class的link階段已經將諸如form-text一類的classname添加到了DOM元素上(再強調一次,事實上classname在這一階段並沒有改變,但是為了強調生命周期的概念,這里姑且認為classname已經被改變),但是由於此時$compile階段已經結束,由$compile返回的link函數中並不帶有form-text的link方法,自然也未對其進行編譯,因而無法渲染出我們想要的效果。
說到這里,我們至少確定了一點:由於ng-class的渲染發生在$compile階段之后的link階段,因此無法利用ng-class(ng-attr-class、class={{}}的原因類似,都和生命周期相關,但不完全一樣)動態地改變classname並完成渲染。
原因找到了,讓我們暫時先拋開ng-repeat,來簡化一下這個問題,因為下面這個問題解決了,需求也就完成了,如何渲染:
<div ng-class="'form-text'" ng-model="form.name" required="required" title="請輸入用戶名" hints="請輸入5-15個字符" regexp="^.{5,15}$"></div>
既然無法利用上一次的編譯周期,那么手動啟動一次難道還不行嗎?答案是肯定的。而且AngularJS並沒有隱藏$compile API,我們很容易通過依賴注入獲取這一強大的功能。但關鍵是如何才能在上一個編譯結束之后"立即"手動啟動一次編譯?這里思路不只一種,但利用setTimeout(或者$timeout)向event queue中添加一個異步回調函數應該是比較直接的做法。
問題到這里,解決方案也就比較明顯了。為了query方便,讓我們為剛剛的div添加一個class="repeat-widget"
然后在controller中加上如下一段代碼:
$timeout(function () {
var widgets = document.querySelectorAll('.repeat-widget');
Array.prototype.slice.call(widgets).forEach(function (widget) {
var link = $compile(widget);
link($scope);
});
});
這段代碼利用$compile編譯已經有了form-text這個classname的div,編譯完成后再將其link到當前$scope上,大功告成!
等等,本文的主題不是說要在ng-repeat的基礎上實現嗎?如果單單一個widget的聲明還要寫的這么復雜,那並沒有什么實際意義啊。
要把這個方案移植到ng-repeat上,其實已經非常容易了,只有兩個小問題還需要解決一下:
1. ng-repeat生成的子元素每一個都會帶上ng-repeat屬性,再次$compile又會repeat一次,形成我們不想要的雙重循環,如何處理?
2. 需要link的不再是page級別的$scope,而是ng-repeat在循環中產生各個子scope,如何處理?
第一個問題很簡單,removeAttribute即可。
第二個問題,我們可以利用angular.element(node).scope()來獲取子scope。
請看下面的代碼:
$timeout(function () {
var widgets = document.querySelectorAll('.repeat-widget');
Array.prototype.slice.call(widgets).forEach(function (widget) {
// 移除ng-repeat,防止被再次編譯
widget.removeAttribute('ng-repeat');
// 獲取子scope
var scope = angular.element(widget).scope();
var link = $compile(widget);
link(scope);
});
});
當然,如果每次利用ng-repeat動態地編譯directive都需要這樣一段代碼的話,那也太不優雅了。別忘了我們是在AngularJS的世界中,把這個邏輯封裝成一個更強大的directive才是這個方案的理想歸宿。有興趣的同學可以自行完成這一步。
本分享到此就告一段落了,如果本文能夠或多或少地幫助大家加深對AngularJS中compile階段和link階段的理解,那就再好不過了。
最終的html:
<div ng-class="input.classes" ng-repeat="input in form.inputs" ng-model="form[input.model]" required="{{input.required}}" title="{{input.title}}" hints="{{input.hints}}" regexp="{{input.regexp}}" items="input.items" name="{{input.name}}"></div>
最終的controller:
angular.module('myApp', ['form.widgets'])
.controller('myCtrl', function ($scope, $timeout, $compile) {
var form = {};
$scope.form = form;
form.genders = [{
text: '男',
value: 0
}, {
text: '女',
value: 1
}];
form.interests = [{
text: '電影',
value: 'films'
}, {
text: '音樂',
value: 'music'
}, {
text: '足球',
value: 'soccer'
}, {
text: '健身',
value: 'fitness'
}];
var inputs = [{
model: 'name',
required: 'required',
title: '請輸入用戶名',
hints: '請輸入5-15個字符',
regexp: '^.{5,15}$',
classes: ['form-text', 'repeat-widget']
}, {
model: 'phone',
required: 'required',
title: '請輸入手機號',
hints: '請輸入11位手機號',
regexp: '^1[0-9]{10}$',
classes: ['form-text', 'repeat-widget']
}, {
model: 'email',
required: 'required',
title: '請輸入您的郵箱',
hints: '請正確輸入您的郵箱地址',
regexp: '^[\\w-.]+@\\w+\\.\\w+$',
classes: ['form-text', 'repeat-widget']
}, {
model: 'gender',
required: 'required',
title: '請選擇性別',
items: form.genders,
name: 'gender',
hints: '請選擇性別',
classes: ['form-radio', 'repeat-widget']
}, {
model: 'interest',
required: 'required',
title: '請告訴我們您的興趣愛好',
items: form.interests,
hints: '請至少選擇一項',
classes: ['form-checkbox', 'repeat-widget']
}];
form.inputs = inputs;
$timeout(function () {
var widgets = document.querySelectorAll('.repeat-widget');
Array.prototype.slice.call(widgets).forEach(function (widget) {
widget.removeAttribute('ng-repeat');
var scope = angular.element(widget).scope();
var link = $compile(widget);
link(scope);
});
});
});

作者:ralph_zhu
時間:2015-12-26 20:10
