理解AngularJS生命周期:利用ng-repeat动态解析自定义directive
ng-repeat是AngularJS中一个非常重要和有意思的directive,常见的用法之一是将某种自定义directive和ng-repeat一起使用,循环地来渲染开发者所需要的组件。比如现在有一个form-text指令,用于快速构建起带自定义数据验证的表单文本框,我们可以用类似下面的代码方便地建立起一个简单的表单:
controller中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | $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:
1 | < 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模块中:
(代码较长,为了不影响阅读,默认折叠了)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | 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模块。
1 2 3 4 | < 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 > |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | <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中加上如下一段代码:
1 2 3 4 5 6 7 | $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。
请看下面的代码:
1 2 3 4 5 6 7 8 9 10 11 | $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:
1 | < 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 | 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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· 展开说说关于C#中ORM框架的用法!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?