angularjs 一篇文章看懂自定义指令directive
壹 ❀ 引
在angularjs开发中,指令的使用是无处无在的,我们习惯使用指令来拓展HTML;那么如何理解指令呢,你可以把它理解成在DOM元素上运行的函数,它可以帮助我们拓展DOM元素的功能。比如最常用ng-click可以让一个元素能监听click事件,这里你可能就有疑问了,同样都是监听为什么不直接使用click事件呢,angular提供的事件指令与传统指令有什么区别?我们来看一个例子:
<body ng-controller="myCtrl as vm"> <div class="demo"> <p ng-bind="vm.name"></p> <button ng-click="vm.changeA()" class="col1">buttonA</button> <button class="btnB col2" onclick="a()">buttonB</button> </div> </body>
angular.module('myApp', []) .controller('myCtrl', function () { let vm = this; vm.name = '听风是风'; //通过angularjs指令绑定事件 vm.changeA = function () { vm.name = 'echo'; }; //使用原生的js绑定方式 let btn = document.querySelector(".btnB"); btn.onclick = function () { vm.name = '时间跳跃'; }; });
我们分别使用angularjs提供的事件指令与传统事件来通过按钮点击,修改文本的内容,效果如下:
很奇怪,只有ng-click成功修改了文本内容,传统的事件并不能做到这一点,怎么解决呢?其实我们手动添加$apply方法就可以了,代码如下:
btn.onclick = function () { $scope.$apply(function () { vm.name = '时间跳跃'; }); };
我们从这个例子可以知道,当我们使用angularjs指令时,ng-click除了事件响应还做了脏检测,当数据发生变化通知视图重新渲染。准确来说,angular会将执行行为放入到$apply中进行调用,如果数据发生了变化,$apply会通知$digest循环,从而调用所有watcher,从而达到视图更新的目的,当然这里扯的有点远了,只是为了说明官方指令与传统事件的区别。
angularjs官方提供的指令繁多,例如事件类ng-click,ng-change,样式类ng-class,ng-style等等,如何使用这里就不一一介绍了,本文主要围绕自定义指令展开,阅读完本文,一起来实现属于自己的指令吧。
贰 ❀ 创建一个简单的指令
在angularjs还未更新出component时,我们一般会使用directive开发自定义指令或者组件,也正因为directive功能的强大,导致指令与组件概念含糊不清,所以才有后面用于做组件的component,当然对于component我们另起一篇文章再说。
directive是直接用于操作dom的函数,它甚至能直接改变dom的结构,我们从一个最简单的directive开始:
<body ng-controller="MainCtrl as vm"> <echo></echo> </body>
angular.module('myApp', []) .controller('MainCtrl', function () { }) .directive('echo',function(){ return{ restrict:'E', replace:true, template:'<div>你好,我是听风是风。</div>' } });
页面效果:
我们已经实现了一个非常简单的指令(组件),现在我们可以在页面中尽情复用它。假设template是一个特别复杂的dom结构,通过指令我们就可以省下重复的代码编写,听起来非常棒不是吗。
<echo></echo> <echo></echo> <echo></echo>
当然angularjs自定义指令其实拥有很多灵活的属性,用于完成更复杂的功能,一个完整的directive模板结构应该是这样,属性看着有点多,没关系,接下来我们针对属性一一细说。
angular.module('myApp', []).directive('directiveName', function () { return { restrict: String, priority: Number, terminal: Boolean, template: ' String or Template Function', templateUrl: String, replace: 'Boolean or String', scope: 'Boolean or Object', transclude: Boolean, controller: function (scope, element, attrs, transclude, otherInjectables) {}, controllerAs: String, require: String, link: function (scope, iElement, iAttrs) {}, compile: function (tElement, tAttrs, transclude) { return { pre: function (scope, iElement, iAttrs, controller) {}, post: function (scope, iElement, iAttrs, controller) {} }; //或 return function postLink() {} } }; });
叁 ❀ 指令参数详解
1.restrict /rɪˈstrɪkt/ 限制;约束;
restrict表示指令在DOM中能以哪种形式被声明,是一个可选值,可选值范围有E(元素)A(属性)C(类名)M(注释)四个值,如果不使用此属性则默认值为EA,以下四种表现相同:
<!-- E --> <echo></echo> <!-- A --> <div echo></div> <!-- C --> <div class="echo"></div> <!-- M --> <!-- directive:echo -->
restrict的值可单个使用或者多个组合使用,比如restrict:'E'即表示只允许使用元素来声明组件,而restrict:'EACM'则表示你可以使用四种方式的任一一种来声明组件。
2.priority /praɪˈɒrəti/ 优先权
priority值为数字,表示指令的优先级,若一个DOM上存在多个指令时,优先级高的指令先执行,注意此属性只在指令作为DOM属性时起作用,我们来看个例子:
<div echo demo></div>
angular.module('myApp', [])
.controller('MainCtrl', function () {})
.directive('echo', function () {
return {
restrict: 'EACM',
priority: 10,
controller:function(){
console.log('我的优先级是10')
}
}
})
.directive('demo', function () {
return {
restrict: 'EACM',
priority: 20,
controller:function(){
console.log('我的优先级是20')
}
}
})
可以看到优先级更好的指令优先执行,若两个指令优先级相同时,声明在前的指令会先执行,ngRepeat的优先级为1000,它是所有内置指令中优先级最高的指令。大多数情况下我们会忽略此属性,默认即为0;
3.terminal /ˈtɜːmɪnl/
terminal值为布尔值,用于决定优先级低于自己的指令是否还执行,例如上方例子中,我们为demo指令添加terminal:true,可以看到echo指令不会执行:
4.template /ˈtempleɪt/ 模板
template的值是一段HTML文本或一个函数,HTML文本的例子上文已有展示,这里简单说下值为函数的情况,我们来看个例子:
<div echo name="听风是风"></div>
angular.module('myApp', []) .controller('MainCtrl', function () {}) .directive('echo', function () { return { restrict: 'EACM', template: function (tElement, tAttrs) { console.log(tElement,tAttrs); return '<div>你好,我是' + tAttrs.name + '</div>' } } })
template函数接受两个参数,tElement和tAttrs,这里我们分别输出两个属性,可以看到tElement表示正在使用此指令的DOM元素,而tAttrs包含了使用此指令DOM元素上的所有属性。
所以在上述例子中,我们在DOM上添加了一个name属性,而在函数中我们通过tAttrs.name访问了此属性的值,所以最终DOM解析渲染为如下:
由于templateUrl相对template对于模板的处理更优雅,所以一般不会使用template。
5.templateUrl 模板路径
相对template直接将模板代码写在指令中,templateUrl推荐将模板代码另起文件保存,而这里保存对文件路径的引用;当然templateUrl同样支持函数,用法与template相同就我们来看一个简单的例子:
angular.module('myApp', []) .controller('MainCtrl', function () {}) .directive('echo', function () { return { restrict: 'EACM', templateUrl: 'template/echo-template.html' } })
特别注意,在使用template与templateUrl的模板文件时,如果你使用了replace:true属性(后面会介绍),且模板代码DOM结构有多层,请记住使用一个父级元素包裹你所有DOM结构,否则会报错,因为angularjs模板只支持拥有一个根元素。
正确:
<div> <span>我是听风是风</span> <span>好好学习天天向上</span> </div>
错误:
<span>我是听风是风</span> <span>好好学习天天向上</span>
其次,在使用templateUrl时,需要在本地启动服务器来运行你的angular项目,否则在加载模板时会报错。如果你不知道怎么搭建本地服务,推荐npm 中的 live-server,使用非常简单,详情请百度。
6.replace /rɪˈpleɪs/ 替换
replace值为布尔值,用于决定指令模板是否替换声明指令的DOM元素,默认为false,我们来看两个简单的例子,首先指令作为元素:
<echo></echo>
值为false:
值为true:
可以看到当为true时,echo元素直接被全部替换;我们再来看看指令作为属性:
<div echo> <span>欢迎来到听风是风的博客</span> </div>
值为false:
值为true:
可以看到,当指令作为属性时,replace值为false只替换声明指令DOM的子元素为模板元素,当值为true时,整个元素都被替换成模板元素,同时还保留了属性echo。
7.scope [skəʊp] 作用域
scope属性用于决定指令作用域与父级作用域的关系,可选值有布尔值或者一个对象,默认为false,我们一个个介绍。
当 scope:flase 时,表示指令不创建额外的作用域,默认继承使用父级作用域,所以指令中能正常使用和修改父级中所有变量和方法,我们来看个简单的例子:
<body ng-controller="myCtrl"> 我是父:<input type="text" ng-model="num"> <div echo></div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { let vm = this; $scope.num = 100; }).directive('echo', function () { return { restrict: 'EACM', scope: false, template: '<div>我是子:<input type="text" ng-model="num"><div>', replace: true } })
可以看到指令完全继承了父作用域,共用了一份数据,不管我们修改父或者子指令,这份数据都将同步改变并影响彼此,这就是继承不隔离。
当 scope:true 时表示指令创建自己的作用域,但仍然会继承父作用域,说直白点就是,指令自己有的用自己的,没有的找父级拿,同一份数据父级能影响指令,但指令却无法反向影响父级,这就是继承但隔离。
angular.module('myApp', []) .controller('myCtrl', function ($scope) { let vm = this; $scope.num = 100; $scope.name = 'echo'; }).directive('echo', function () { return { restrict: 'EACM', scope: true, template: '<div>我是子:<input type="text" ng-model="num">我的名字是:{{name}}<div>', replace: true, controller:function ($scope) { $scope.name = '听风是风'; } } })
可以看到父子作用域都有name属性,但指令中还是使用了自身的属性,其次,指令中没有的num属性继承自父级,当修改父级时子会同步改变,但反之父不会改变,最有趣的是一旦修改了子,父级也无法再影响子。
当 scope:{} 时,表示指令创建一个隔离作用域,此时指令作用域不再继承父作用域,两边的数据不再互通:
说到这,你是否会觉得不隔离直接使用父级作用域会更方便,从使用角度来说确实如此。但实际开发中,我们自定义的指令往往会在各种上下文中使用,只有保证指令拥有隔离作用域,不会关心和不影响上下文,这样才能极大提升指令复用性。
那么问题又来了,如果我指令需要使用父级作用域的数据怎么办?有隔离自然有解决方案,这就得使用绑定策略了。angularjs中directive的绑定策略分为三种,@,=,和&,一一介绍。
@通常用于传递字符串,注意,使用@传递过去的一定得是字符串,而且@属于单向绑定,即父修改能影响指令,但指令修改不会反向影响父,我们来看个例子:
<body ng-controller="myCtrl"> <input type="text" ng-model="data.name"> <echo my-name="{{data}}"></echo> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { $scope.data = 'echo'; }).directive('echo', function () { return { restrict: 'EACM', scope: { myName:"@" }, template: '<div><input type="text" ng-model="myName"><div>', replace: true, } })
注意,我在指令上通过my-name属性来传递这个对象,但在指令scope中我们接受数据时得改为小驼峰myName,其次请留意data两侧加了{{}}包裹,使用@时这是必要的,具体效果如下:
= 用于传递各类数据,字符串,对象,数组等等,而且是双向绑定,即不管修改父还是子,这份数据都会被修改,我们将上方代码的@改为 = ,同时做小部分调整,具体效果如下:
<body ng-controller="myCtrl"> <input type="text" ng-model="data"> <echo my-name="data"></echo> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { $scope.data = 'echo' }).directive('echo', function () { return { restrict: 'EACM', scope: { myName: "=" }, template: '<div><input type="text" ng-model="myName"><div>', replace: true, } })
请注意,指令上传递data时两边并未使用{{}}包裹,这与@传值还是有很大区别。
& 用于传递父作用域中声明的方法,也就是通过&我们可以在指令中直接使用父的方法,我们来看个例子:
<body ng-controller="myCtrl"> <input type="text" ng-model="data"> <echo my-name="sayName(data)"></echo> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { $scope.sayName = function (name) { console.log(name); }; }).directive('echo', function () { return { restrict: 'EACM', scope: { myName: "&" }, template: '<div><button ng-click="myName()">点我</button><div>', replace: true, } })
这有点类似于为指令提供了一个点击的入口,当点击指令时实际执行的是父上面的方法,而这个方法本质上不属于指令,所以我们没办法传递指令的值给这个方法,上方的例子传递的也是父作用域的值。
8.controller [kənˈtrəʊlə(r)] 控制器
我们都知道angular中控制器是很重要的一部分,我们常常在控制器操作数据通过scope作为桥梁以达到更新视图变化的目的,很明显指令拥有自己的scope,当然拥有自己的controller控制器也不是什么奇怪的事情。
controller的值可以是一个函数,或者一个字符串,如果是字符串指令会在应用中查找与字符串同名的构造函数作为自己的控制器函数,我们来看一个非常有趣的例子:
<body ng-controller="myCtrl as vm"> <input type="text" ng-model="name"> <div echo></div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { let vm = this; $scope.name = '听风是风'; }).directive('echo', function () { return { restrict: 'EACM', scope: {}, template: '<div><input type="text" ng-model="name"><div>', replace: true, controller: 'myCtrl' } })
在上述例子中,我们在父作用域声明了一个变量name,有趣的是我们并未对指令传递name属性,甚至还为指令添加了隔离作用域,但是因为指令的controller的值使用了与父作用域控制器相同的名字myCtrl,导致指令中也拥有了相同的controller,同样拥有了自己name属性,但这两个name属性互不干扰,毕竟有隔离作用域的存在。
如果控制器的值是一个函数,那就更简单了,还是上面的例子我们只是改改controller的值,如下:
angular.module('myApp', []) .controller('myCtrl', function ($scope) { let vm = this; $scope.name = '听风是风'; }).directive('echo', function () { return { restrict: 'EACM', scope: {}, template: '<div><input type="text" ng-model="name"><div>', replace: true, controller: function ($scope) { $scope.name = 'echo'; } } })
当然指令的controller的形参不止一个scope,一共有$scope,$element,$attrs,$transclude四个,我们一一介绍(指令属性还真是多...)。
$scope:指令当前的作用域,所有在scope上绑定的属性方法,在指令中都可以随意使用,在上面的例子中我们已经有所展示。
$element:使用指令的当前元素,比如上面的例子,因为echo指令是加在div元素上,我们直接输出$element属性,可以看到就是div:
$attr:使用指令当前元素上的属性,还是上面的例子,我们给此div添加一些额外的属性,同样输出它:
<div echo name="echo" age="26"></div>
$transclude:链接函数,用于克隆和操作DOM元素,没错,通过此方法我们甚至能在controller中操作dom,注意,如果要使用此方法得保证transclude属性值为true,来看个简单的例子:
<body ng-controller="myCtrl"> <div attr="www.baidu.com" echo> 点我跳转百度 </div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo', function () { return { restrict: 'EACM', scope: { myName: "&" }, transclude: true,//若想使用$transclude方法请设置为true controller: function ($scope, $element, $attrs, $transclude) { $transclude(function (clone) { var a = angular.element('<a>'); a.attr('href',$attrs.attr);//取得div上的attr属性并设置给a a.text(clone.text());// 通过clone属性可以获取指令嵌入内容,包括文本,元素名等等,已经过JQ封装,这里获取文本并添加给a $element.append(a); // 将a添加到指令所在元素内 }) } } })
如果对于angularjs生命周期稍有了解,应该都知道angular会在compile阶段编译dom,在link链接阶段绑定事件,所以官方一般是推荐在compile阶段操作DOM,而非controller内部。
9.transclude
在上文controlle介绍中我们已经知道如果想在controller中使用$transclude方法必须设置transclude为true,这里我们来介绍下此属性。
transclude的值为布尔值,默认flase,我们知道指令的模板总是会替换掉使用指令DOM的子元素,看个例子回顾下replace属性:
<body ng-controller="myCtrl"> <div echo> <span>我是听风</span> </div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo', function () { return { restrict: 'EA', template: '<p>我是echo</p>', replace: false, } })
div元素使用了echo指令,因为replace设置为false,所以div元素会被保留,但div的子元素span会被替换为指令模板p元素:
那如果我想保留div的子元素span怎么,这里就可以使用transclude属性做到这一点,另外transclude通常与ng-transclude指令一起使用,我们再来看一个例子:
<body ng-controller="myCtrl"> <div echo> <span>我是听风</span> </div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo', function () { return { restrict: 'EA', template: '<p>我是echo<span ng-transclude></span></p>', replace: false, transclude:true } })
可以看到原div中的子元素span被成功保留加入到了指令模板中添加了ng-transclude指令的元素中。
10.controllerAs
controllerAs用于设置控制器的别名,我们都知道angularjs在1.2版本之后,对于数据绑定提供了额外一种方式,第一种是绑定在scope上,第二种是使用controller as vm类似的写法,绑定在this上。我们来看个简单的例子:
<body ng-controller="myCtrl as vm"> <input type="text" ng-model="name1"> <div>{{name1}}</div> <input type="text" ng-model="vm.name2"> <div>{{vm.name2}}</div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { $scope.name1 = 'echo'; this.name2 = '听风是风'; })
可以看到两种绑定效果完全一致,那么在指令中也有控制器,我们也可以通过this来绑定数据,而controllerAs定义的字段就是我们在模板上访问数据的前缀:
<body ng-controller="myCtrl"> <div echo></div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo', function () { return { restrict: 'EA', template: '<p>{{vm.name}}</p>', controllerAs:'vm', controller:function (){ this.name = '听风是风!'; } } })
11.require [rɪˈkwaɪə(r)] 需求
对于指令开发,link函数和controller中都可以定义指令需要的属性或方法,但如果这个属性或方法只是本指令使用,你可以定义在指令的link函数中,但如果这个属性方法你想在别的指令中也使用,推荐定义在controller中。
而require属性就是用来引用其它指令的controller,require的值可以是一个字符串或者一个数组,字符串就是其它指令名字,而数组就是包含多个指令名的数组,我们来看一个简单的例子:
<body ng-controller="myCtrl"> <div echo1> <div echo2></div> </div> </body>
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo1', function () { return { restrict: 'EA', controller: function ($scope) { this.sayAge = function () { console.log(26); } }, } }).directive('echo2', function () { return { restrict: 'EA', scope:{}, require: '^echo1', link:function (scope, elem, attrs, controller) { controller.sayAge();//26 } } })
上述例子中,我们在指令echo1的控制器中定义了一个sayName方法,注意得绑定this上;而在指令echo2中,我们require了指令echo1,这样我们就能通过link函数的第四个参数访问到echo1的控制器中的所有属性方法(绑在this上的),达到方法复用。
有没有注意到require的字符串前面有一个 ^ 标志,require的值一共可以以四种前缀来修饰,我们分别解释:
1.无前缀
如果没有前缀,指令将会在自身所提供的控制器中进行查找,如果没有找到任何控制器(或具有指定名字的指令)就抛出一个错误。我们来看一个简单的例子:
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo2', function () { return { restrict: 'EA', scope: {}, require: 'echo2',//require自己 controller: function () { this.sayName = function () { console.log('听风是风'); } }, link: function (scope, elem, attrs, controller) { controller.sayName() //听风是风 } } })
这个例子中我们让指令require自己,从而让link函数中能访问自己controller中的方法。
2.^
如果添加了^前缀,指令会在自身以及父级指令链中查找require参数所指定的指令的控制器,如果没找到报错。
3.?
同样是在当前指令中找,如果没找到会将null传给link函数的第四个参数。与不加前缀的区别是提供null从而不报错。
4.?^
?与^的组合,从当前找,如果没找到去上层找,如果没找到就提供null。
5.^^
Angular 1.5.6版本之后新增,表示跳过自身直接从父级开始找,找不到报错。
12.link 链接函数
我们在前面介绍其它属性时已经有粗略提及link函数了,在link函数中我们也能像在controller中一样为模板绑定事件,更新视图等。看个简单的例子:
angular.module('myApp', []) .controller('myCtrl', function ($scope) { }).directive('echo2', function () { return { restrict: 'EA', scope: {}, template:'<div ng-click="vm.sayName()">点我输出{{name}}</div>', controllerAs:'vm', controller: function () { this.sayName = function () { console.log('听风是风'); } }, link: function (scope, elem, attrs, controller) { scope.name = '听风是风'; } } })
link函数拥有四个参数,scope表示指令的作用域,在scope上绑定的数据在模板上都能直接访问使用。elem表示当前使用指令的DOM元素,attrs表示当前使用指令DOM元素上的属性,这三点与前面介绍指令controller参数一致。第四个参数controller表示指令中require的指令的controller,前面已经有例子展示,注意,如果指令没有require其它指令,那么第四个参数就是指令自身的作用域,看个例子:
.directive('echo1', function () { return { restrict: 'EACM', replace: true, controller: function ($scope) { $scope.name = 'echo'; this.name1 = 'echo1'; }, link: function (scope, ele, att, ctrl) { console.log(ctrl); console.log(scope.name); // echo console.log(ctrl.name1); // echo1 } } })
那么现在我们知道了,在link里面scope能直接访问自身controller中scope的属性,而this上的属性,同样能通过第四个参数访问,前期是没require其它指令。
指令的控制器controller和link函数可以进行互换。控制器主要是用来提供可在指令间复用的行为,但链接函数只能在当前内部指令中定义行为,且无法在指令间复用。简单点说link函数可以将指令互相隔离开来,而controller则定义可复用的行为。
13.compile 编译函数
如果你想在指令模板编译之前操作DOM,那么compile函数将会起作用,但出于安全问题一般不推荐这么做。同样不推荐在compile中进行DOM方法绑定与数据监听,这些行为最好都交给link或者controller来完成。
其次compile和link互斥,如果你在一个指令同时使用了compile和link,那么link函数不会执行。
肆 ❀ 总
这篇博客前前后后写了一个星期,导致文章篇幅有点长,耗时久一方面是知识点确实多,其次是对于指令我也有很多地方需要重新细化理解,这篇文章确实算是系统学习的一个过程。
在文章结尾我只是粗略提及了link与compile函数,对于angularjs的高级用法,理解这两兄弟由其重要,所以我打算另起一篇文章专门用来介绍link,compile与controller的区别,顺带介绍angularjs的生命周期。
使用指令或组件一定离不开生命周期钩子函数,关于钩子函数的介绍,我也会另起一篇文章,这两篇文章都会在一周内完成,也算是给自己一个小目标。
那么本文就写到这了。
如果你好奇controller,link,compile有何区别,preLink与postLink又有何不同,以及它们的执行先后感兴趣,欢迎阅读博主 angularjs link compile与controller的区别详解,了解angular生命周期 这篇博客,对你一定有所帮助。
如果你对于directive的好兄弟component有兴趣,可以阅读博主这篇文章 一篇文章看懂angularjs component组件
如果你对于component与directive使用有所混淆,可以阅读博主这篇文章 angularjs中directive指令与component组件有什么区别?