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