理解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

原文:http://www.cnblogs.com/front-end-ralph/p/5078786.html 

posted @ 2015-12-26 20:15  ralph_zhu  阅读(3926)  评论(3编辑  收藏  举报