ng-repeat是AngularJS中一個很是重要和有意思的directive,常見的用法之一是將某種自定義directive和ng-repeat一塊兒使用,循環地來渲染開發者所須要的組件。好比如今有一個form-text指令,用於快速構建起帶自定義數據驗證的表單文本框,咱們能夠用相似下面的代碼方便地創建起一個簡單的表單:javascript
controller中:html
$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:java
<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數組中,渲染結果可能並不是如咱們所指望的。node
第一個容易想到的地方在於如何解決動態指定指令名稱的問題。正如你們所熟悉的,自定義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
事實上,該業務的關鍵點在於理解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模塊中:api
(代碼較長,爲了避免影響閱讀,默認摺疊了)數組
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