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 

  • 相关阅读:
    nginx能访问html静态文件但无法访问php文件
    LeetCode "498. Diagonal Traverse"
    LeetCode "Teemo Attacking"
    LeetCode "501. Find Mode in Binary Search Tree"
    LeetCode "483. Smallest Good Base" !!
    LeetCode "467. Unique Substrings in Wraparound String" !!
    LeetCode "437. Path Sum III"
    LeetCode "454. 4Sum II"
    LeetCode "445. Add Two Numbers II"
    LeetCode "486. Predict the Winner" !!
  • 原文地址:https://www.cnblogs.com/front-end-ralph/p/5078786.html
Copyright © 2011-2022 走看看