自定义指令
本章我们将详细讲解如何用AngularJS实现自定义指令(directives)来扩充HTML.
指令的基本概念
简单来说,指令就是Angular编译器能够识别并处理的附加在DOM元素上的标记(例如属性、元素名字、注释、CSS类)。Angular HTML编译器会在应用初始化阶段给这些DOM元素及子元素赋予额外的行为动作或者转换。
Angular框架提供了丰富的内建指令例如ngBind,ngModel,ngClass等。和创建控制器、服务等组件一样我们可以创建自定义的指令。但Angular应用启动时,Angular HTML编译器会遍历DOM树对匹配的指令进行初始化。
Angular的编译就是给DOM元素注册事件监听器,之所以用编译这个词是因为递归处理指令这个过程与编译源代码有点类似。见官方解释:
What does it mean to "compile" an HTML template? For AngularJS, "compilation" means attaching event listeners to the HTML to make it interactive. The reason we use the term "compile" is that the recursive process of attaching directives mirrors the process of compiling source code in compiled programming languages.
匹配指令
在实现自定义指令之前,我们需要理解Angular编译器处理指令的细节。
下面这个例子中,标签匹配了ngModel指令:
下面的写法,angular同样可以识别ngModel指令:
能够被Angular识别的指令标记是符合一定命名规范的。一般情况下我们使用camelCase,例如ngModel.HTML是大小写敏感的, 我们也可以用全部小写的格式,单词之间'-'符号分隔,例如ng-model. Angular对名字的处理过程如下:
1. 去掉x-或者data-前缀
2. 把: , - 或者_ 分隔的格式转成camelCase.
下面的例子使用ngBind都是合法的:
Hello
angular.module('docsBindExample', []) .controller('Controller', ['$scope', function($scope) { $scope.name = 'Max Karl Ernst Ludwig Planck (April 23, 1858 – October 4, 1947)'; }]);
官方最佳实践推荐使用dash-delimited格式,即ng-bind.如果使用HTML校验器,可以加上data-前缀,即data-ng-bind.
Angular的编译器($compile)会尝试从标签的名字,属性,CSS类和注释重识别可用的指令。例如,我们可以通过下面一些用法在模板中使用myDir指令:
官方推荐使用标签或者属性引入指令。需要迭代创建HTML DOM时推荐使用ng-repeat指令,尽量避免自定义指令。
文本和属性域绑定
在编译过程中,angular会通过$interpolate服务组件识别文本或者属性中是否有表达式(Expression)。表达式会在watches服务中注册,并在正常的digest过程中被更新。例如:
Hello {{username}}!
ngAttr属性绑定
有时候Web浏览器会挑剔属性的名字,例如:
我们希望Angular会识别属性并绑定cx模型, 但在一些浏览器的控制台中会报错:Error: Invalid value for attribute cx="{{cx}}". 原因是和SVG DOM api冲突了。 这种情况下,我们可以通过ng-attr-cx解决问题。
如果一个属性以ngAttr为前缀,Angular会加以识别,并去掉ngAttr前缀,寻找匹配的指令。这样我们就可以解决与其他api的冲突了. 注意但是用ngAttr指令是, $interpolate会打开allOrNothing标志位,这样ngAttr开头的属性在没有匹配到任何指令时会被删除掉.
创建指令
和创建其他Angular组件类似,指令是注册在某个应用模块下面的. 我们可以通过module.directive api来注册一个指令. module.directive方法接受两个参数,第一个参数为规范的指令名字,第二个参数为创建指令的工厂方法. 工厂方法将返回一个对象, 该对象包含了Angular编译器($compile)初始化指令的一些配置项. 这个工厂方法会在编译器第一次匹配指令的时候被$inject.invoke方法调用,实例化并加入注入器.
为了避免与内建的指令以及其他HTML标签冲突,推荐给自定义的指令加上特有的前缀(除了ng).
下面我将通过几个具体的例子深入理解指令的初始化选项和编译过程。
由模板扩展的指令
我们经常会发现一些代码片段反复出现,为了避免在每一处都修改,我们可以把共用片段抽取出来做成指令。下面的例子中我们将展示客户信息的部分代码抽成可重用的模板指令:
angular.module('docsSimpleDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.customer = { name: 'Naomi', address: '1600 Amphitheatre' }; }]) .directive('myCustomer', function() { return { template: 'Name: {{customer.name}} Address: {{customer.address}}' }; });上述JS代码中我们在docsSimpleDirective模块中通过directive定义了myCustomer指令,指令中通过template选项定义了一个模板,绑定了控制器中的customer信息。这样我们就可以在如下HTML中加入my-customer指令了。
Angular HTML编译器($compile)在编译连接
时会在该节点及其子节点中匹配指令。指令也可以包含其他指令。
运行结果:
上例的template选项定义了一小段DOM片段,官方推荐将较大的HTML模板抽取到单独的html文件中,通过templateUrl导入。如下示例中使用了templateUrl:
my-customer.html
Name: {{customer.name}} Address: {{customer.address}}script.js
angular.module('docsTemplateUrlDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.customer = { name: 'Naomi', address: '1600 Amphitheatre' }; }]) .directive('myCustomer', function() { return { templateUrl: 'my-customer.html' }; });index.html
运行结果:
上面的代码中我们将template抽取到独立的my-customer.html中,并通过templateUrl引入。templateUrl选项也可以是一个方法,该方法接受两个参数,第一个参数为指令对应的元素(element),第二个参数为相关联的标签属性, 返回模板的url地址。让我们通过具体的例子来理解:
script.js
angular.module('docsTemplateUrlDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.customer = { name: 'Naomi', address: '1600 Amphitheatre' }; }]) .directive('myCustomer', function() { return { templateUrl: function(elem, attr){ return 'customer-'+attr.type+'.html'; } }; });index.html
customer-name.html
Name: {{customer.name}}customer-address.html
Address: {{customer.address}}运行结果:
在上述例子中,template url方法中使用了"type"属性,例如
, 在myCustomer的templateUrl函数中attr.type值为"name", 返回url为customer-name.html即引入了Name: {{customer.name}}片段。注意:
1. 在templateUrl的方法中是访问不到作用域变量的,因为template请求发生在初始化作用域之前。
2. 默认情况下指令只能是标签或者属性,如果想通过CSS类名引入指令需要在restrict选项中指定。
restrict有如下三个选项:
‘A’ - 匹配标签的属性
‘E’ - 匹配标签的名字
‘C’ - 匹配标签的CSS类名
这三个选项可同时使用, ‘AEC’ 即Angular会试图匹配上述三种情况。
让我们看restrict为'E'的例子:
script.js
angular.module('docsRestrictDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.customer = { name: 'Naomi', address: '1600 Amphitheatre' }; }]) .directive('myCustomer', function() { return { restrict: 'E', templateUrl: 'my-customer.html' }; });index.html
my-customer.html
Name: {{customer.name}} Address: {{customer.address}}运行结果:
上述例子中自定义了my-customer标签,这样只需简单的在HTML加入该标签即可引入customer信息及行为。
我们可以通过自定义标签来实现领域特定语言(Domain-Specific Language). 如果是在现有标签上附加功能,可优先选择属性。
隔离指令的作用域
上述的例子中的指令的缺陷是指令都被绑定到特定控制器相关的作用域中。这样每次引入指令我们必须创建一个与之对应的控制器,并与之绑定,例如:
script.js
angular.module('docsScopeProblemExample', []) .controller('NaomiController', ['$scope', function($scope) { $scope.customer = { name: 'Naomi', address: '1600 Amphitheatre' }; }]) .controller('IgorController', ['$scope', function($scope) { $scope.customer = { name: 'Igor', address: '123 Somewhere' }; }]) .directive('myCustomer', function() { return { restrict: 'E', templateUrl: 'my-customer.html' }; });my-customer.html
Name: {{customer.name}} Address: {{customer.address}}index.html
运行结果:
在index.html中我们将
script.js
angular.module('docsIsolateScopeDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.naomi = { name: 'Naomi', address: '1600 Amphitheatre' }; $scope.igor = { name: 'Igor', address: '123 Somewhere' }; }]) .directive('myCustomer', function() { return { restrict: 'E', scope: { customerInfo: '=info' }, templateUrl: 'my-customer-iso.html' }; });my-customer-info.html
Name: {{customerInfo.name}} Address: {{customerInfo.address}}index.html
运行结果:
通过scope选项,我们以为指令绑定自己的作用域模型。在上述的scope选项中"customerInfo"为作用域内的属性,属性的值”=info“告诉编译器将customerInfo绑定到"info"属性指定的外围作用域的模型"naomi"或者"igor", 在index.html对应的代码为
为指令创建自己的作用域提高了指令的可重用性,指令只会受到传入的model的影响。 需要注意的是子作用域可以继承父作用域,但指令的作用域是不会继承外面的作用域的,是被隔离开来的。官方推荐为指令创建独立的作用域。
在指令中操作DOM
在下面的例子中,我们将创建一个指令来显示当前的时间, 视图每秒钟刷新一次.
指令通过link选项来更新DOM,link选项为一个预定义的JS函数,方法签名为function link(scope, element, attrs) { ... }:
1. scope 是一个Angular作用域对象
2. element 为指令匹配的jqLite element对象
3. attrs 是一个hash对象,包含了属性名字和值
在下面的例子, 视图每秒更新一次或者在用户改变了绑定的时间格式的情况下被刷新。 通过$interval服务周期性的更新视图,同时在指令被删除时删除$interval对象,防止内存泄露。
angular.module('docsTimeDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.format = 'M/d/yy h:mm:ss a'; }]) .directive('myCurrentTime', ['$interval', 'dateFilter', function($interval, dateFilter) { function link(scope, element, attrs) { var format, timeoutId; function updateTime() { element.text(dateFilter(new Date(), format)); } scope.$watch(attrs.myCurrentTime, function(value) { format = value; updateTime(); }); element.on('$destroy', function() { $interval.cancel(timeoutId); }); // start the UI update process; save the timeoutId for canceling timeoutId = $interval(function() { updateTime(); // update DOM }, 1000); } return { link: link }; }]);
通过directive方法定义指令"myCurrentTime",并注入了$interval和dateFilter组件, 即可通过my-current-time属性在span元素中引入指令。在link函数中通过$interval,每隔一秒调用updateTime方法更新时间信息, 并通过$watch监听元素中myCurrentTime属性,注意这里my-current-time="format"绑定了format模型,及format更新会被观察到,从而调用updateTime更新时间信息。最后在element上注册了回调,当元素被销毁是注销$interval服务。
Date format:
Current time is:
运行结果:
一般情况下在作用域和元素中注册的事件监听会在作用域和元素被销毁时自动注销,只有在服务或者指令相匹配的DOM节点上的注册的监听器不会自动被删除,需要手工删除防止内存泄露。推荐在指令中通过element.on('$destroy',...)或者scope.$on('$destroy',..)注册回调来在指令被删除是清理资源。
把模板包装指令
在上述的例子中,我们将模型属性传入有独立作用域的指令来初始化指令作用域,我们还可以把整个模板传入指令中。在如下示例中,我们ng-transclude选项HTML模板套上templateUrl选项定义的”dialog box“.
angular.module('docsTransclusionDirective', []) .controller('Controller', ['$scope', function($scope) { $scope.name = 'Tobias'; }]) .directive('myDialog', function() { return { restrict: 'E', transclude: true, templateUrl: 'my-dialog.html' }; });
Check out the contents, {{name}}!
my-dialog.html
运行结果:
transclude是的指令中的模板访问外围作用域而不是内部的作用域, 即my-dialog中的name属性为控制器作用域中的name属性值。让我们看如下的例子, 虽然我们在link函数中定义了name属性,但绑定的是控制器作用域中的name属性:
angular.module('docsTransclusionExample', []) .controller('Controller', ['$scope', function($scope) { $scope.name = 'Tobias'; }]) .directive('myDialog', function() { return { restrict: 'E', transclude: true, scope: {}, templateUrl: 'my-dialog.html', link: function (scope, element) { scope.name = 'Jeff'; } }; });
Check out the contents, {{name}}!
angular.module('docsIsoFnBindExample', []) .controller('Controller', ['$scope', '$timeout', function($scope, $timeout) { $scope.name = 'Tobias'; $scope.hideDialog = function () { $scope.dialogIsHidden = true; $timeout(function () { $scope.dialogIsHidden = false; }, 2000); }; }]) .directive('myDialog', function() { return { restrict: 'E', transclude: true, scope: { 'close': '&onClose' }, templateUrl: 'my-dialog-close.html' }; });
index.html
运行结果:Check out the contents, {{name}}!
angular.module('dragModule', []) .directive('myDraggable', ['$document', function($document) { return function(scope, element, attr) { var startX = 0, startY = 0, x = 0, y = 0; element.css({ position: 'relative', border: '1px solid red', backgroundColor: 'lightgrey', cursor: 'pointer' }); element.on('mousedown', function(event) { // Prevent default dragging of selected content event.preventDefault(); startX = event.pageX - x; startY = event.pageY - y; $document.on('mousemove', mousemove); $document.on('mouseup', mouseup); }); function mousemove(event) { y = event.pageY - startY; x = event.pageX - startX; element.css({ top: y + 'px', left: x + 'px' }); } function mouseup() { $document.off('mousemove', mousemove); $document.off('mouseup', mouseup); } }; }]);
Drag ME
运行结果:
angular.module('docsTabsExample', []) .directive('myTabs', function() { return { restrict: 'E', transclude: true, scope: {}, controller: function($scope) { var panes = $scope.panes = []; $scope.select = function(pane) { angular.forEach(panes, function(pane) { pane.selected = false; }); pane.selected = true; }; this.addPane = function(pane) { if (panes.length === 0) { $scope.select(pane); } panes.push(pane); }; }, templateUrl: 'my-tabs.html' }; }) .directive('myPane', function() { return { require: '^myTabs', restrict: 'E', transclude: true, scope: { title: '@' }, link: function(scope, element, attrs, tabsCtrl) { tabsCtrl.addPane(scope); }, templateUrl: 'my-pane.html' }; });my-tabls.html
my-pane.html
index.html
运行结果:Hello
Lorem ipsum dolor sit amet
World
Mauris elementum elementum enim at suscipit.
angular.module('docsTabsExample', []) .directive('myPane', function() { return { require: ['^myTabs', '^ngModel'], restrict: 'E', transclude: true, scope: { title: '@' }, link: function(scope, element, attrs, controllers) { var tabsCtrl = controllers[0], modelCtrl = controllers[1]; tabsCtrl.addPane(scope); }, templateUrl: 'my-pane.html' }; });细心的读者可能会问link和controller选项的区别: controller选项可以用来暴露API, 和别的组件互通,而link方法可以使用require引入的控制器。