《用AngularJS开发下一代Web应用》指令学习笔记
一.指令的作用:实现语义化标签
我们常用的HTML标签是这样的:
<div> <span>目录</span> </div>
而使用AngularJS的directive(指令)机制,我们可以实现这样的东西
<tabset> <tab title='Home'> <p>Welcome home!</p> </tab> <tab title='Preferences'> <p>Content</p> </tab> <tabset>
和JSP或者Struts等等框架里面的taglib功能一样,只不过这里是使用JavaScript来实现的。
二.简单实例
DirectiveStudy01.html
<html ng-app='app'> <body> <hello></hello> </body> <script src="lib/angular/angular.js"></script> <script src="directive/HelloDirect.js"></script> </html>
HelloDirect.js
var appModule = angular.module('app', []); appModule.directive('hello', function() { return { restrict: 'E', template: '<div>Hi there</div>', replace: true }; });
运行结果:
对于DirectiveStudy01.html里面的<hello>标签,浏览器显然是不认识的,它唯一能做的事情就是无视这个标签。那么,为了让浏览器能够认识这个标签,我们需要使用Angular来定义一个hello指令(本质上说就是自己来把<hello>替换成浏览器能识别的那些标准HTML标签)。
从运行结果可以看到,<hello>已经被<div>Hi there</div>这个标签替换掉了,这也是以上JS代码里面replace:true这行配置的作用,代码里面的template配置项当然就是我们要的div标签啦,至于restrict:'E'这个配置项的含义,请看下表:
当然,如果需要替换的HTML标签很长,显然不能用拼接字符串的方式来写,这时候我们可以用templateUrl来替代template,从而可以把模板写到一个独立的HTML文件中。
三.transclude(变换)
可以通过transclude属性将内容插入新的模板。当transcluden属性设置为true时,指令会删除原来的内容,使你的模板可以用ng-transclude指令进行重新插入。
DirectiveStudy02.html
<html ng-app='app'> <body> <div hello>Bob</div> </body> <script src="lib/angular/angular.js"></script> <script src="directive/HelloDirect02.js"></script> </html>HelloDirect02.js
var appModule = angular.module('app', []); appModule.directive('hello', function() { return { template: '<div>Hi there <span ng-transclude></span></div>', transclude: true }; });运行结果:
和第一个例子对比,这个例子的JS和HTML代码都略有不同,JS代码里面多了一个transclude: true,HTML代码里面在<hello>内部出现了子标签。
按照我们在第一个例子中的说法,指令的作用是把我们自定义的语义化标签替换成浏览器能够认识的HTML标签。那好,如果我们自定义的标签内部出现了子标签,应该如何去处理呢?很显然,transclude就是用来处理这种情况的。
对于当前这个例子,transclude的作用可以简化地理解成:把<hello>标签替换成我们所编写的HTML模板,但是<hello>标签内部的内容保持不变。
很显然,由于我们没有加replace:true选项,所以<hello>标签还在,没有被替换掉。
四.compile和link函数
虽然插入模板的方式很有用,但是对于指令来说,真正有趣的工作发生在compile和link函数中。
compile和link这两个函数是根据Angular创建动态视图的两个处理阶段来命名的。从高层来看Angular的初始化过程,它们依次如下:
1.加载脚本
加载Angular库,并查找ng-app指令,从而找到应用的边界。
2.编译阶段
在这个阶段,Angular将会遍历DOM结构,标识出模板中注册的所有指令。对于每一条指令,它会根据指令定义的规则(template、replace、transclude等)来转换DOM结构,如果存在compile函数,则调用它。调用compile函数将得到一个编译好的template函数,它将会调用从所有指令中搜集而来的link函数。
3.连接阶段
为了让视图成为动态的,Angular会对每一条指令运行一个link函数。link函数的一般操作是在DOM或者模型上创建监听器,监听器会使视图和模型的内容随时保持同步。
总结来说,后两个阶段就负责转换模板的编译阶段,以及修改视图中数据的连接阶段。在以上内容中,指令中的compile和link函数的主要不同点在于,compile函数用来对模板自身进行转换,而link函数负责在模型和视图之间进行动态关联。作用域在连接阶段才会被绑定到编译之后的link函数上,然后再通过数据绑定技术,指令就变成了动态的。
出于性能方面的考虑,这两个阶段是分开处理的。compile函数仅仅在编译阶段运行一次,而link函数会执行很多次——对于指令的每个实例,link函数都会执行一次。例如,在指令的上一层标签里面使用了ng-repeat,这里你并不想调用compile函数,因为那样会导致对ng-repeat中的每一次遍历都进行一次DOM-walk(DOM遍历)。希望只编译一次,然后再连接。
当然,你需要知道compile和link函数之间的不同点以及它们各自的功能。对于你将编写的大多数指令来说,并不需要对模板进行转换,所以,大部分情况下只要编写link函数就可以了。
为了进行比较,再分别看一下compile和link函数的语法。对于compile函数,语法是这样的:
compile: function compile(tElement, tAttrs, transclude) { return { pre: function preLink(scope, iElement, iAttrs, controller) {...}, post: function postLink(scope, iElement, iAttrs, controller) {...} } }
对于link函数,是这样:
link: function postLink(scope, iElement, iAttrs) {...}
注意这里有一个不同点,即link函数会访问scope(作用域)对象,而compile不会。这是因为,scope对象在编译阶段还不存在。当然你也可以在compile函数中返回link函数,这些link函数仍然可以访问scope对象。
同时请注意,compile和link都会接收到对应DOM元素的引用以及元素的属性列表。这里的不同点是,compile函数会接收模板元素及其属性列表,所以函数形参带有一个t前缀;而link函数会接收到视图实例对象,视图实例是使用模板创建的,所以函数形参带有一个i前缀。
只有当一个指令位于其他指令内部,而外部指令会生成模板的多份拷贝时,这种差异性才会显得比较重要。ng-repeat指令就是一个很好的例子。
DirectiveCompileAndLink.html
<html ng-app='app'> <body ng-controller='MyController'> <div ng-repeat='thing in things'> {{thing}}.<hello></hello> </div> </body> <script src="lib/angular/angular.js"></script> <script src="directive/CompileAndLink.js"></script> </html>
CompileAndLink.js
var appModule = angular.module('app', []); appModule.directive('hello', function() { return { restrict: 'E', template: '<span>Hi there</span>', replace: true }; }); appModule.controller('MyController',function($scope) { $scope.things = [1,2,3,4,5,6]; });
运行结果:
这里,compile函数只会被调用一次,而link函数的调用次数等于things中的元素个数——对于{{thing}}.<hello></hello>的每一份拷贝,它都会被调用一次。所以,如果对{{thing}}.<hello></hello>的每一份拷贝(实例)都有一些共同的东西需要修改,那么,出于效率方面的考虑,最好在compile函数里面来做这件事情。
你还会注意到,compile函数会接受一个transclude函数作为属性。如果你需要对内容进行变换,而简单的基于模板的变换并没有提供这种功能,那么,你可以在这里编写一个函数,使用程序的方式对内容进行变换。
最后,compile可以返回preLink和postLink函数,而link函数只会返回postLink函数。正如它的名称所表示的,preLink会在编译阶段之后、指令连接到子元素之前运行。类似的,postLink会在所有子元素指令都连接之后才运行。这就意味着,如果你需要修改DOM结构,你应该在postLink中来做这件事情,而如果在preLink中做这件事情会破环绑定过程,并导致错误。
五.扩展条实例Expander
我们经常需要在指令中访问scope对象,以便观察数据模型的值,当这些值发生变化时刷新UI。当你使用jQuery、Closure或其他类库来封装一些非Angular组件时,或者实现简单的DOM事件时,或者把一个Angular表达式传递给指令的属性然后执行时,都需要访问scope对象。
如果出于以上某种原因需要一个scope对象,那么scope对象的类型有以下三种选择:
1.指令对应的DOM元素上存在的scope对象。
2.可以创建一个新的scope对象,它继承了外层控制器的scope。在继承树中,位于当前scope对象上方的所有scope对象的值都可以被读取。对于DOM元素里面的任何其他指令,如果需要这种类型的scope,也可以共享这个scope,并且可以用它和树中其他scope进行通信。
3.使用独立的scope对象,它不会从父对象上继承模型的任何属性。当创建可复用的组件,并且需要把当前指令的操作和父scope隔离开时,你就需要使用这个选项。
可以使用以下配置语法来创建这些指令:
当创建独立scope时,默认情况是不可以访问父scope模型中的任何东西。但是,你可以指定把某些属性传递到你的指令中,你可以把这些属性名称看成函数的形参。
注意,虽然独立的scope不会继承模型的属性,但是它们仍然是父scope的孩子。与所有其他scope一样,它们带有一个指向父scope对象的$parent属性。
你可以通过传递属性名映射的方式把父scope中指定的属性传递给这个独立的scope。有三种方法可以在scope和父scope之间传递数据,我们把这三种方式叫做“绑定策略”,你还可以选择选择给属性名指定一个局部的别名。
不使用别名的语法形式为:
scope: {attributeName1: 'BINDING_STRATEGY', attributeName2: 'BINDING_STRATEGY',... }
使用别名的形式为:
scope: {attributeAlias: 'BINDING_STRATEGY' + 'templateAttributeName', ... }
绑定策略符号定义如下表所示:
下面通过一个具体例子来示范一下各用用法。例如,我们想要创建一个expander指令,它会显示一个很小的扩展条,点击的时候扩展条就会展开,显示额外的内容。
DirectiveExpander.html
<html ng-app='expanderModule'> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <script src="lib/angular/angular.js"></script> <link rel="stylesheet" type="text/css" href="css/ExpanderSimple.css"/> </head> <body> <div ng-controller='SomeController'> <expander class='expander' expander-title='title'> {{text}} </expander> </div> </body> <script src="directive/ExpanderSimple.js"></script> </html>
ExpanderSimple.js
var expanderModule=angular.module('expanderModule', []) expanderModule.directive('expander', function() { return { restrict : 'EA', replace : true, transclude : true, scope : { title : '=expanderTitle' }, template : '<div>' + '<div class="title" ng-click="toggle()">{{title}}</div>' + '<div class="body" ng-show="showMe" ng-transclude></div>' + '</div>', link : function(scope, element, attrs) { scope.showMe = false; scope.toggle = function toggle() { scope.showMe = !scope.showMe; } } } }); expanderModule.controller('SomeController',function($scope) { $scope.title = '点击展开'; $scope.text = '这里是内部的内容。'; });
ExpanderSimple.css
.expander { border: 1px solid black; width: 250px; } .expander>.title { background-color: black; color: white; padding: .1em .3em; cursor: pointer; } .expander>.body { padding: .1em .3em; }
运行结果:
点击展开,效果如下:
我们先来看一看指令中的每一个选项分别会为我们做一些什么事情,如下所示:
如果觉得应该把把扩展条的标题定义在模板中,而不应该定义在数据模型中,那么你可以在scope的定义中使用字符串风格的属性,用@符号作为标志进行传递,示例如下:
scope: {title:'@expanderTitle'},
在模板中,我们可以使用下面这种方法达到同样的效果:
<expander class='expander' expander-title='Click me to expand'> {{text}} </expander>
注意,在使用@策略时,我们仍然可以通过双花括号插值语法把title绑定到控制器scope上:
<expander class='expander' expander-title='{{title}}'> {{text}} </expander>
六.扩展条实例Expander——操作DOM元素
可以向指令的link和compile函数传递iElement或tElement参数,这两个参数都是包装后的引用,它们都指向原始的DOM元素。如果你加载了jQuery库,那么它们就会指向经过jQuery包装之后的元素。
如果你不使用jQuery,那么这些DOM元素都位于Angular-native包装器jqLite中。jqLite是jQuery API的子集,在Angular中我们需要用它来创建所有东西。对于很多应用来说,只要使用这个轻量级API就可以实现所有你想做的事情了。
如果你需要直接访问原始的DOM元素,你可以使用element[0]来访问对象中的第一个元素。
在Angular文档中angular.element()部分,你可以看到目前能够支持的完整API列表,你可以使用angular.element()来创建包装在jqLite中的DOM元素。anuglar对象还带有addClass()、bind()、find()、toggleClass()等函数。当然,这些都是jQuery中常用的核心函数,只是Angular的实现代码更精致而已。
除了jQuery的API之外,元素还带有一些基于Angular的函数,如下所示。这些函数是否存在与你是否使用完整的jQuery库有关。
作为例子,重新实现一下扩展条的实例,新的实现方式不再借助于ng-show和ng-click指令,代码如下:
var expanderModule=angular.module('expanderModule', []) expanderModule.directive('expander', function() { return { restrict : 'EA', replace : true, transclude : true, scope : { title : '=expanderTitle' }, template : '<div>' + '<div class="title">{{title}}</div>' + '<div class="body closed" ng-transclude></div>' + '</div>', link : function(scope, element, attrs) { var titleElement = angular.element(element.children().eq(0)); var bodyElement = angular.element(element.children().eq(1)); titleElement.bind('click', toggle); function toggle() { bodyElement.toggleClass('closed'); } } } }); expanderModule.controller('SomeController',function($scope) { $scope.title = '点击展开'; $scope.text = '这里是内部的内容。'; });
从模板中删掉了ng-click和ng-show指令,作为替代,我们给title元素创建了一个jqLite元素,然后把toggle()函数绑定到它的click事件上作为回调,这样就能在用户点击扩展条title的时候执行必要的动作。在toggle()函数中,我们通过调用扩展条body元素上的toggleClass()方法来添加或者删除closed样式类。在toggleClass()函数中我们使用一个CSS样式类把元素设置为display:none,示例如下:
.closed { display: none; }
PS:jQuery 属性操作-toggleClass() 方法
toggleClass() 对设置或移除被选元素的一个或多个类进行切换。该方法检查每个元素中指定的类。如果不存在则添加类,如果已设置则删除之。这就是所谓的切换效果。
七.控制器及综合实例
要实现需要彼此通信的嵌套指令,可以使用控制器。<menu>需要知道内部<menu-item>元素的信息,这样它才能够正确地显示和隐藏它们。同样地,<tab-set>需要知道内部<tab>元素的信息;<grid-view>需要知道内部<grid-element>元素的信息。
如前所示,为了创建能够在指令之间进行通信的接口,你可以使用controller属性语法把控制器声明成指令的一部分:
controller: function controllerConstructor($scope, $element, $attrs, $transclude)
controller函数是通过依赖注入的,所以这里所列出的参数列表都是可选的,可以按照其他顺序将其列出,当然这些参数都具有某种潜在的用途。它们还是可用的服务子集。
通过require属性语法,其他指令可以把这个控制器传递给自已。require的完整形式如下:
require: '^?directiveName'
require字符串的解释如下表所示:
作为例子,我们来重写expander指令,让它可以用在一个"accordion"集合中。它会保证当你打开一个扩展条时,集合中的所有其他扩展条都会自动关闭掉。
Accordion.js
var expModule=angular.module('expanderModule',[]) expModule.directive('accordion', function() { return { restrict : 'EA', replace : true, transclude : true, template : '<div ng-transclude></div>', controller : function() { var expanders = []; this.gotOpened = function(selectedExpander) { angular.forEach(expanders, function(expander) { if (selectedExpander != expander) { expander.showMe = false; } }); } this.addExpander = function(expander) { expanders.push(expander); } } } }); expModule.directive('expander', function() { return { restrict : 'EA', replace : true, transclude : true, require : '^?accordion', scope : { title : '=expanderTitle' }, template : '<div>' + '<div class="title" ng-click="toggle()">{{title}}</div>' + '<div class="body" ng-show="showMe" ng-transclude></div>' + '</div>', link : function(scope, element, attrs, xccordionController) { scope.showMe = false; xccordionController.addExpander(scope); scope.toggle = function toggle() { scope.showMe = !scope.showMe; xccordionController.gotOpened(scope); } } } }); expModule.controller("SomeController",function($scope) { $scope.expanders = [{ title : 'Click me to expand', text : 'Hi there folks, I am the content that was hidden but is now shown.' }, { title : 'Click this', text : 'I am even better text than you have seen previously' }, { title : 'No,click me!', text : 'I am text that should be seen before seeing other texts' }]; });
accordion指令,它会做一些元素定位工作。我们把控制器的构造函数以及进行元素定位操作的方法添加到accordion指令中。accordion指令中定义了一个addExpander()函数,扩展条可以调用它来注册自身;还创建了一个可被扩展条调用的gotOpened()函数,通过它,accordion的控制器就知道需要把其他所有处于打开状态的扩展条都关闭。
在expander指令自身中,我们扩展它的时候要求accordion的控制器来自它的父元素,然后 在合适的时候调用addExpander()和gotOpened()函数。
注意,accordion指令中的控制器创建了一个API接口,有了它,所有扩展条控件之间就可以进行通信了。
编写模板来使用以上指令如下:
DirectiveAccordionExpander.html
<html ng-app="expanderModule"> <head> <meta http-equiv="content-type" content="text/html; charset=utf-8" /> <script src="lib/angular/angular.js"></script> <link rel="stylesheet" type="text/css" href="css/Accordion.css"/> </head> <body ng-controller='SomeController' > <accordion> <expander class='expander' ng-repeat='expander in expanders' expander-title='expander.title'> {{expander.text}} </expander> </accordion> </body> <script src="directive/Accordion.js"></script> </html>
Accordion.css
.expander { border: 1px solid black; width: 250px; } .expander>.title { background-color: black; color: white; padding: .1em .3em; cursor: pointer; } .expander>.body { padding: .1em .3em; } .closed { display: none; }
运行效果: