理解AngularJS生命周期:利用ng-repeat动态解析自定义directive
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