创建自定义指令(二)

一、使用嵌入包含

嵌入包含的意思是将一个文档的一部分通过引用插入到另一个文档中。在指令的上下文信息中,当你要创建一个可以包含任意内容的包装器指令时,这将十分有用。

<script type="text/ng-template" id="template">
        <div class="panel panel-default">
            <div class="panel-heading">
                <h4>This is the panel</h4>
            </div>
            <div class="panel-body" ng-transclude>
            </div>
        </div>
</script>
<script type="text/javascript">
        angular.module("exampleApp", [])
            .directive("panel", function () {
                return {
                    link: function (scope, element, attrs) {
                        scope.dataSource = "directive";
                    },
                    restrict: "E",
                    scope: true,
                    template: function () {
                        return angular.element(
                            document.querySelector("#template")).html();
                    },
                    transclude: true
                }
            })
            .controller("defaultCtrl", function ($scope) {
                $scope.dataSource = "controller";
            });
</script>

<body ng-controller="defaultCtrl">
    <panel>
        The data value comes from the: {{dataSource}}
    </panel>
</body>

 

我想要达到的效果:

 <div class="panel panel-default">
            <div class="panel-heading">
                <h4>This is the panel</h4>
            </div>
            <div class="panel-body" ng-transclude>
                 The data value comes from the:controller
            </div>
 </div>

 

之所以使用嵌入包含这样一个术语,因为内容时放在要插入到模板中的panel元素里面的。在使用嵌入包含时有两个特定步骤是必需的。第一步是在创建指令时将transclude定义属性设置为true,如下:transclude:true

第二步是将ng-transclude指令使用到模板中,就放在想插入被包装元素的地方。

设置transclude为true后,会对指令所应用到的元素内容进行包装,但并不是元素本身。如果你想包含进元素,就需要将transclude属性设置为element。

另外需要注意的是,被嵌入包含的内容中的表达式是在控制器作用域中被计算的,而不是指令的作用域。我再控制器的工厂函数中和指令的链接函数中都对dataSource属性定义了值,但是AngularJS的明智之处在于从控制器中取得了该值。我说明智是因为这种方法意味着将被嵌入包含的内容不需要知道它的数据是定义在哪个作用域中,你只需要不把嵌入包含当做一个需要考虑的问题,尽管写表达式就好了,让AngularJS自己去进行计算。

尽管如此,如果在计算嵌入包含表达式时你确实想将指令作用域考虑在内,只需确保将scope属性设置为false,如下

...
restrict:"E",
scope:false,
template:function(){
...

 

这确保了指令在指令作用域上操作,而且任何定义在链接函数中的值将影响嵌入包含的表达式。修改后显示如下:

 <div class="panel panel-default">
            <div class="panel-heading">
                <h4>This is the panel</h4>
            </div>
            <div class="panel-body" ng-transclude>
                 The data value comes from the:directive
            </div>
 </div>

 

二、使用编译函数

当指令特别复杂或者需要处理大量数据时,使用编译函数操作DOM并让链接函数执行其他任务,是比较有利的。除了性能以外使用编译函数还有一个好处,就是可以使用嵌入包含来重复生成内容的能力,就像ng-repeat所做的那样。

<body ng-controller="defaultCtrl" class="panel panel-body" >
    <table class="table table-striped">
        <thead><tr><th>Name</th><th>Price</th></tr></thead>
        <tbody>
            <tr simple-repeater source="products" item-name="item">
                <td>{{item.name}}</td><td>{{item.price | currency}}</td>
            </tr>
        </tbody>
    </table>
    <button class="btn btn-default text" ng-click="changeData()">Change</button>
</body>
angular.module("exampleApp", [])
            .controller("defaultCtrl", function ($scope) {
                $scope.products = [{ name: "Apples", price: 1.20 },
                    { name: "Bananas", price: 2.42 }, { name: "Pears", price: 2.02 }];

                $scope.changeData = function () {
                    $scope.products.push({ name: "Cherries", price: 4.02 });
                    for (var i = 0; i < $scope.products.length; i++) {
                        $scope.products[i].price++;
                    }
                }
            })
            .directive("simpleRepeater", function () {
                return {
                    scope: {
                        data: "=source",
                        propName: "@itemName"
                    },
                    transclude: 'element',
                    compile: function (element, attrs, transcludeFn) {
                        return function ($scope, $element, $attr) {
                            $scope.$watch("data.length", function () {
                                var parent = $element.parent();
                                parent.children().remove();
                                for (var i = 0; i < $scope.data.length; i++) {
                                    var childScope = $scope.$new();
                                    childScope[$scope.propName] = $scope.data[i];
                                    transcludeFn(childScope, function (clone) {
                                        parent.append(clone);
                                    });
                                }
                            });
                        }
                    }
                }
            });

 

在HTML中使用source属性指定了数据对象的来源,并使用item-name属性指定了在嵌入包含的模板中可被用于应用当前对象的名称。我的目标是对每个product对象重复生成tr元素,所以我设置了transclude定义对象的element,也就是说元素本身将被包含于嵌入包含中,而不是其内容。我也可以将我的指令应用在tbody元素上并设置transclude属性为true。

这个指令的核心部分是编译函数,是由compile属性指定的。编译函数被传入三个参数:指令所应用到的元素,该元素的属性,以及一个可用于创建嵌入包含元素的拷贝的函数。编译函数会返回一个链接函数(当compile属性被使用时link属性会被忽略)。这可能看起来有点奇怪,但是请记住编译函数的目的是为了修改DOM,所以从编译函数返回一个链接函数是很有帮助的,因为它提供了一个简易的将数据从指令的一部分传递到下一部分的方法。编译函数应当仅仅是操作DOM的,所以并没有为他提供作用域,但是编译函数返回的链接函数可以声明对scope,element,attrs参数的依赖,对应于普通链接函数中的各个参数。

理解编译函数:通过调用$scope.$new方法创建了一个新的作用域。对于嵌入包含内容的每个实例,这允许我将一个不同的对象赋给item属性,使用的是如下方法进行克隆的:

...
transcludeFn(childScope,function(clone){
    parent.append(clone);
})
...

 

对于每个数据对象,调用了传给编译函数的嵌入包含函数。第一个参数是包含item属性的子作用域,item属性设置为当前数据项。第二个参数是一个传入了嵌入包含内容的一组拷贝函数,这份拷贝被使用jqLite添加到父元素下。结果是对于每个数据对象生成了指令所应用到的tr元素的一份拷贝,并且创建了一个新的作用域,在这个作用域中允许嵌入包含内容使用item来引用当前数据对象。

 

三、在指令中使用控制器

 指令能够创建出被其他指令所用的控制器。这允许指令被组合起来创建出更复杂的组件。

<body ng-controller="defaultCtrl">
    <div class="panel panel-default">
        <div class="panel-body">
            <table class="table table-striped" product-table="totalValue" product-data="products" ng-transclude>
                <tr>
                    <th>Name</th>
                    <th>Quantity</th>
                </tr>
                <tr ng-repeat="item in products" product-item></tr>
                <tr>
                    <th>Total:</th>
                    <td>{{totalValue}}</td>
                </tr>
            </table>
        </div>
    </div>
</body>
<script type="text/ng-template" id="productTemplate">
        <td>{{item.name}}</td>
        <td>
            <input ng-model='item.quantity' />
        </td>
</script>
angular.module("exampleApp", [])
        .controller("defaultCtrl", function($scope) {
            $scope.products = [{
                name: "Apples",
                price: 1.20,
                quantity: 2
            }, {
                name: "Bananas",
                price: 2.42,
                quantity: 3
            }, {
                name: "Pears",
                price: 2.02,
                quantity: 1
            }];
        })
        .directive("productItem", function() {
            return {
                template: document.querySelector("#productTemplate").outerText,
                require: "^productTable",
                link: function(scope, element, attrs, ctrl) {
                    scope.$watch("item.quantity", function() {
                        ctrl.updateTotal();
                    });
                }
            }
        })
        .directive("productTable", function() {
            return {
                transclude: true,
                scope: {
                    value: "=productTable",
                    data: "=productData"
                },
                controller: function($scope, $element, $attrs) {
                    this.updateTotal = function() {
                        var total = 0;
                        for (var i = 0; i < $scope.data.length; i++) {
                            total += Number($scope.data[i].quantity);
                        }
                        $scope.value = total;
                    }
                }
            }
        });

 

controller用于为指令创建一个控制器,这个函数可以声明对作用域的依赖,对指令所应用到的元素的依赖,和对该元素属性的依赖。require定义对象属性用于声明对控制器的依赖,属性值是指令名和一个可选的前缀。

可用的require属性值的前缀:

None  假定两个指令都应用与同一个元素

^ 在指令所应用到的元素的父元素上查找另一个指令

? 如果找不到指令并不报错——小心使用

 

我指定了名称为productTable,以及前缀^,需要这样指定是因为productTable指令被应用在productItem指令所应用到的元素的父元素上。为了使用控制器中定义的功能,我在链接函数上指定了一个附加参数,如下:

link:function(scope,element,attrs,ctrl){}

控制器参数不能被依赖注入,所以你可以调用任何你想调的东西,我的个人习惯是使用名称ctrl。做了这些修改后,我就可以调用控制器中的函数了,就像它们已经定义在本地指令中一样。

我在调用一个控制器方法,作为一个执行计算的信号,并不需要任何参数,但是你可以从一个控制器传递数据到另一个,只要你记着传给控制器函数的scope参数是定义控制器的那个指令中的作用域就可以了,而不是引用该控制器的那个指令的作用域。

 

添加另一个指令

定义控制器函数的价值来自于对功能进行分离和重用的能力,从而无需构建和测试单个庞大的组件。在上面的例子中,productTable控制器并不知道productItem控制器的设计或实现,也就是说我可以独立地测试他们并任意修改,只要productTable控制器仍然继续提供updateTotal函数即可。

这种方法也允许你能够混合搭配各种指令的功能,从而在一个程序里创建出各种功能的不同组合。

<script type="text/ng-template" id="resetTemplate">
        <td colspan="2">
            <button ng-click="reset()">Reset</button>
        </td>
</script>
<body ng-controller="defaultCtrl">
    <div class="panel panel-default">
        <div class="panel-body">
            <table class="table table-striped" product-table="totalValue" product-data="products" ng-transclude>
                <tr>
                    <th>Name</th>
                    <th>Quantity</th>
                </tr>
                <tr ng-repeat="item in products" product-item></tr>
                <tr>
                    <th>Total:</th>
                    <td>{{totalValue}}</td>
                </tr>
                <tr reset-totals product-data="products" property-name="quantity"></tr>
            </table>
        </div>
    </div>
</body>
.directive("resetTotals", function() {
            return {
                scope: {
                    data: "=productData",
                    propname: "@propertyName"
                },
                template: document.querySelector("#resetTemplate").outerText,
                require: "^productTable",
                link: function(scope, element, attrs, ctrl) {
                    scope.reset = function() {
                        for (var i = 0; i < scope.data.length; i++) {
                            scope.data[i][scope.propname] = 0;
                        }
                        ctrl.updateTotal();
                    }
                }

            }
        });

新的指令名为resetTotals,它向表格中添加了一个reset按钮,可以将所有的数量清零,在一个隔离的作用域上提供了数据数组和要清零的属性名称,该指令就可以通过数据绑定查找到要清零的位置。在值被重置后,resetTotals指令调用了productTable指令所提供的updateTotal方法。 

 

四、创建自定义表单元素

<script type="text/ng-template" id="triTemplate">
        <div class="well">
            <div class="btn-group">
                <button class="btn btn-default">Yes</button>
                <button class="btn btn-default">No</button>
                <button class="btn btn-default">Not Sure</button>
            </div>
        </div>
</script>

<body ng-controller="defaultCtrl">
    <div><tri-button ng-model="dataValue" /></div>
    <div class="well">
            Value:
            <select ng-model="dataValue">
                <option>Yes</option>
                <option>No</option>
                <option>Not Sure</option>
            </select>
    </div>
</body>
angular.module("exampleApp", [])
        .controller("defaultCtrl", function ($scope) {
            $scope.dataValue = "Not Sure";
        })
        .directive("triButton", function () {
            return {
                restrict: "E",
                replace: true,
                require: "ngModel",
                template: document.querySelector("#triTemplate").outerText,
                link: function (scope, element, attrs, ctrl) {
                    var setSelected = function (value) {
                        var buttons = element.find("button");
                        buttons.removeClass("btn-primary");
                        for (var i = 0; i < buttons.length; i++) {
                            if (buttons.eq(i).text() == value) {
                                buttons.eq(i).addClass("btn-primary");
                            } 
                        }
                    }
                    setSelected(scope.dataValue);                
                }
            }
        });

 

创建了一个名为triButton的指令,该指令可被当做一个元素来使用。我声明了对ngModel控制器的依赖(该控制器是被ng-model指令所定义的,因为AngularJS统一规范了其名称),并且向链接函数添加了ctrl参数。

setSelected函数时我定义用来突出显示与表单选中的值相同的按钮元素。

我的目标是需要能够显示用户通过自定义指令改变dataValue值,以及在别处如何接收和处理被改变的值的效果。

1)当dataValue属性在我的指令以外被修改时,对于本例也就是说通过下拉列表进行选择时,能够改变突出显示的按钮。

link: function (scope, element, attrs, ctrl) {
         var setSelected = function (value) {
                 var buttons = element.find("button");
                 buttons.removeClass("btn-primary");
                 for (var i = 0; i < buttons.length; i++) {
                      if (buttons.eq(i).text() == value) {
                            buttons.eq(i).addClass("btn-primary");
                       } 
                  }
            }

           ctrl.$render = function () {
                 setSelected(ctrl.$viewValue || "Not Sure");
            }
}

 

修改不大,影响却不小。我替换了ngModel控制器所定义的$render函数,原来的函数调用了setSelected函数。当值在指令之外被修改并且需要更新显示内容时,$render方法会被ng-model指令调用。通过读取$viewValue属性可以拿到最新的值。这里我移除了原有的对setSelected的显示调用。当程序第一次启动时,ngModel控制器会调用$render函数,以使你可以设置指令的初始状态。如果你使用动态定义的属性,$viewValue的值将会是undefined,这也是为什么 说比较好的实践是提供一个回退值,就像这个例子中一样。

这样就可以实现当下拉列表改变时,按钮跟着改变了。

 

ngModel控制器提供的基本方法和属性:

$render() 当数据绑定的值发生变化时ngModel控制器调用更新UI的函数。通常被自定义控制器所覆盖。

$setViewValue(value) 更新数据绑定的值。

$viewValue 返回应当被指令显示的格式化后的值。

$modelValue 从作用域返回未格式化的值。

$formatters 将$modelValue转成viewValue的格式化函数构成的数组.

 

2)第二个要实现的功能是 当用户单击其中一个按钮时将变化通过ng-model指令传播到作用域的功能

element.on("click", function (event) {
    setSelected(event.target.innerText);
    scope.$apply(function () {
        ctrl.$setViewValue(event.target.innerText);
     });
});

 

给按钮注册点击事件,当按钮被点击时,获取点击对象的文字内容,并调用setSelected更新突出显示的按钮。因为是自己注册的点击事件,如果要触发angular的脏检查机制,需要显式调用apply,将更新推送到数据模型中。

 

3)格式化数据值

ngModel控制器对格式化数据模型中的值提供了一种简单的机制,以便使其被指令所显示。这些格式化程序是以函数形式表示的,能够将$modelValue属性转换成$viewValue。下面要实现的是把下拉列表选中的值映射成指令中的对应按钮

ctrl.$formatters.push(function (value) {
     return value == "Huh?" ? "Not Sure" : value;
});

 

$formatters属性是由被使用的函数按序组成的一个数组。前一个格式化程序的结果被作为参数传入函数,函数返回的是本次格式化的结果。

<div class="well">
        Value: <select ng-model="dataValue">
            <option>Yes</option>
            <option>No</option>
            <option>Not Sure</option>
            <option>Huh?</option>
        </select>
</div>

 

当下拉列表框选中Huh?时,按钮选中not sure.

 

4)检验自定义表单元素

ngModel控制器还提供了将自定义指令集成到AngularJS表单验证系统的支持。

<script type="text/ng-template" id="triTemplate">
        <div class="well">
            <div class="btn-group">
                <button class="btn btn-default">Yes</button>
                <button class="btn btn-default">No</button>
                <button class="btn btn-default">Not Sure</button>
            </div>
            <span class="error" ng-show="myForm.decision.$error.confidence">  错误信息是否可见,取决于一个名为confidence的校验错误属性
                You need to be sure
            </span>
        </div>
</script>

<body ng-controller="defaultCtrl">
    <form name="myForm" novalidate>
        <div><tri-button name="decision" ng-model="dataValue" /></div>
    </form>
</body>
angular.module("exampleApp", [])
        .controller("defaultCtrl", function ($scope) {
            $scope.dataValue = "Not Sure";
        })
        .directive("triButton", function () {
            return {
                restrict: "E",
                replace: true,
                require: "ngModel",
                template: document.querySelector("#triTemplate").outerText,
                link: function (scope, element, attrs, ctrl) {

                    var validateParser = function (value) { 解析器函数,传入的是被数据绑定的值,并负责检查该值是否有效。
                        var valid = (value == "Yes" || value == "No");
                        ctrl.$setValidity("confidence", valid); 传入的参数是否有效,是通过对ngModel控制器中定义的$setValidity方法所设置的
                        return valid ? value : undefined; 解析器函数还需要对无效值返回undefined作为结果
                    }

                    ctrl.$parsers.push(validateParser); 将函数添加到ngModel控制器所定义的$parses数组,可以注册该解析器

                    element.on("click", function (event) {
                        setSelected(event.target.innerText);
                        scope.$apply(function () {
                            ctrl.$setViewValue(event.target.innerText);
                        });
                    });

                    var setSelected = function (value) {
                        var buttons = element.find("button");
                        buttons.removeClass("btn-primary");
                        for (var i = 0; i < buttons.length; i++) {
                            if (buttons.eq(i).text() == value) {
                                buttons.eq(i).addClass("btn-primary");
                            } 
                        }
                    }

                    ctrl.$render = function () {
                        setSelected(ctrl.$viewValue || "Not Sure");
                    }
                }
            }
        });

 

$setValidity方法的参数是一个key(用于显示校验信息)和校验状态(以一个布尔值表示)。

一个指令可以拥有多个解析器函数。

这个函数需要先单击yes再单击not sure,才会显示校验信息。因为直到用户与指令所展示的UI发生交互之前(或者更精确的说,当一个新的值被传给ngModel控制器之前),校验是不会执行的。所以解析器直到模型发生变化时才会被用到。可以通过在$render()函数中显示调用解析器函数来解决这一问题,但有点不太正规,这样文件一被加载校验信息就会显示出来。

ctrl.$render = function () {
    validateParser(ctrl.$viewValue);
    setSelected(ctrl.$viewValue||"Not Sure");
}

 

 

ngModel控制器提供的校验方法与属性:

$setPristine() 将校验状态重设为原始状态,从而阻止校验校验被执行。

$isEmpty() 可以设置给指令表示该控件没有值。默认实现是为了标准表单元素设计的,用于查找控字符串、null、undefined等值

$parsers 一个用于校验模型值的函数构成的数值

$error 返回一个对象,其各个属性对应于各个校验错误信息

$pristine 如果控件还没有被用户修改过,返回true

$dirty 如果控件已经被用户修改过,返回true

$valid 如果模型值有效,返回true

$invalid 如果模型值无效,返回true

 

posted @ 2016-10-28 19:20  爆炒小黄鸡  阅读(747)  评论(0编辑  收藏  举报