zoukankan      html  css  js  c++  java
  • 理解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 

  • 相关阅读:
    cgroup开机自启动
    QPS
    Linux shell脚本的字符串截取
    设计模式 java
    kafka基本介绍
    Vagrant安装配置
    SuperMap空间数据处理与制图操作短视频汇总
    SuperMap 三维产品资料一览表
    SuperMap GIS资料-----云与Web端技术资料集锦
    世界地图和主要国家的 JSON 文件
  • 原文地址:https://www.cnblogs.com/front-end-ralph/p/5078786.html
Copyright © 2011-2022 走看看