理解AngularJS生命週期:利用ng-repeat動態解析自定義directive

ng-repeat是AngularJS中一個很是重要和有意思的directive,常見的用法之一是將某種自定義directive和ng-repeat一塊兒使用,循環地來渲染開發者所須要的組件。好比如今有一個form-text指令,用於快速構建起帶自定義數據驗證的表單文本框,咱們能夠用相似下面的代碼方便地創建起一個簡單的表單:javascript

controller中:html

$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:java

<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數組中,渲染結果可能並不是如咱們所指望的。node

第一個容易想到的地方在於如何解決動態指定指令名稱的問題。正如你們所熟悉的,自定義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

事實上,該業務的關鍵點在於理解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模塊中:api

(代碼較長,爲了避免影響閱讀,默認摺疊了)數組

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 

相關文章
相關標籤/搜索