Angular指令编译原理
前言
angular
之所以使用起来很方便,是因为通常我们只需要在html里面引入一个或多个(自定义或内置的)指令就可以完成一个特定的功能(这也是angular推荐的方式),比如:一个简单的双向绑定
(用ng-model指令),或者模板循环渲染
(用ng-repeat指令),又或者是模板是否显示
(用ng-if指令),而对于这些指令的内部实现一般我们无需太多关心(除非你想深入了解),我们更乐意于把侧重点放在指令如何使用
(即API)上。
我们知道一个应用是由多个功能组成的,而在angular应用中,一个功能又是由一个或多个指令所完成,那么我们可以认为angular应用被很多指令所驱动着,当我们给angular应用添加了所有我们需要的指令后,angular内部则会负责帮我们编译和运行所有指令,从而完成特定功能的实现。
那么,对于指令,我们自然会有以下的疑问: 1. 指令是什么? 2. 指令如何定义存储? 3. 指令如何编译运行?
带着疑问,我们慢慢一步一步深入到源码compile.js
中。
指令
指令从html的角度,可以认为指令名字是一个标识符,可以作为元素名(E),元素属性(A),注释(M),类名(C)出现在html中;而从javascript的角度,则可以认为是返回的一个规范化的有特殊意义的指令对象。
听起来有点抽象,简而言之一切都得从自定义一个简单指令开始:
html
<bodyng-app="myApp"> <divmy-directive></div></body>
js
angular.module('myApp',[]) .directive('myDirective',function(){ //返回一个对象(暂且称之为指令对象)return{ restrict:'A',replace:true,scope:true,template:'<span>helloworld</span>',compile:function(tElement){ console.log('complile:',tElement); returnfunction(scope,elem){ console.log('link:',elem); } } } });
上面的代码,大家肯定都很熟悉,就是利用定义好的模块对象myApp
调用它的directive()
方法,成功注册了一个名字叫做myDirective
的指令,然后在html中便可以将my-directive
作为元素属性
的方式调用这个指令(这里只是简单的模板替换和打印日志)。
这里其实我们关注的其实有两点:
directive
方法的实现返回的
指令对象
(暂且这样称呼)每个字段的含义
指令定义
module对象的directive方法的实质
首先我们得知道模块对象是什么?不清楚的可以看下这篇文章angular指令流程提到的setupModuleLoader
函数。
其实模块对象的directive方法只是一个link
,并没有真正的指令注册,这是什么意思呢?
先来看看它的定义:
directive:invokeLater('$compileProvider','directive')
再看看invokeLater方法
的实现:
functioninvokeLater(provider,method,insertMethod,queue){ if(!queue)queue=invokeQueue; returnfunction(){ queue[insertMethod||'push']([provider,arguments]); returnmoduleInstance; };}
这样其实就很明了了,module对象的directive方法当调用时实质上只是做一个队列(invokeQueue)的存储,并没有正真意义上的指令注册。
就拿myDirective指令
来说,存储的就是[’$compileProvider’,‘directive’,[’myDirective’,function() {…}]]这样一个数据元单位。
那么真正的指令注册是在什么时候呢,以及怎么注册的呢?在angular指令流程源码注释里也提到过:
当angular执行bootstrap)
方法时,内部会调用loadModules)
这个方法,来加载所有的module对象(这里就包括了myApp
模块对象),模块加载的实质其中就包括遍历当前模块对象上的invokeQueue
队列这一项,取出每一个数据元单位,然后执行对应服务的对应方法,所以这里的指令注册便会link到$compileProvider
服务的)
方法。
于是乎,便可以总结了:module.directive)
方法只是指令的存储,指令的注册是在angular应用)
后,通过调用$compileProvider)
方法实现的。
其实module对象的其它方法也都是像这样延后执行(invokeLater)的(例如:.config)
,.controller.provider)
等),这样的好处我认为是基于模块化的思想
,即我们的指令(directive),控制器(controller),服务(service)等都是基于模块(module)的,angular程序的启动可以有选择性地加载想要的模块(即拥有了挂载在模块上的指令,控制器和服务等),这也是angular的第三方插件经常做的事情,声明一个自己的模块(如:ui.router
),然后在(依赖)模块之上注册各种服务(如:$UrlRouterProvider
)等供外界调用.
ok,当我们知道了指令注册的实质,我们便可以这样注册myDirective
指令:
不过,显然还是第一种方式来的更简便,也是推荐的使用方式。$compileProvider服务directive方法的实现
上面我们知道了指令注册的实质是
)
,那就赶紧来看看它源代码实现吧://指令存储容器,一个指令可以有多个指令工厂函数(即多个指令对象)//格式如下://{//directive1:[fn1,fn2,..],//directive2:[fn3,fn4,..]//...//}varhasDirectives={}; this.directive=functionregisterDirective(name,directiveFactory){ assertNotHasOwnProperty(name,'directive'); //单个指令注册if(isString(name)){ assertArg(directiveFactory,'directiveFactory'); //如果该指令还没有任何一个指令工厂if(!hasDirectives.hasOwnProperty(name)){ //先初始化hasDirectives[name]=[]; //将该指令注册为服务,也就是说当我们通过$injector服务来获取该服务返回的指令对象集合(注意:是有缓存的单例哦)$provide.factory(name+Suffix,['$injector','$exceptionHandler',function($injector,$exceptionHandler){ //指令对象集合vardirectives=[]; //循环遍历指令工厂集合,并收集每个工厂函数返回的指令对象forEach(hasDirectives[name],function(directiveFactory,index){ try{ //调用工厂函数,注意这里用的是$injector,所以工厂函数也可以是一个拥有依赖注入的函数或数组vardirective=$injector.invoke(directiveFactory); //如果返回的directive是函数,那么被认为是指令对象的compile字段,//该函数返回的结果将作为指令对象的link字段if(isFunction(directive)){ directive={compile:valueFn(directive)}; //其他的则认为directive是指令对象//如果compile字段不存在,link字段存在的情况}elseif(!directive.compile&&directive.link){ directive.compile=valueFn(directive.link); } //下面是指令对象的字段一些默认值设置,这些字段的含义之后再说directive.priority=directive.priority||0; directive.index=index; directive.name=directive.name||name; directive.require=directive.require||(directive.controller&&directive.name); directive.restrict=directive.restrict||'EA'; //存储到指令对象集合中directives.push(directive); }catch(e){ $exceptionHandler(e); } }); //返回到指令对象集合returndirectives; }]); } //存储当前指令工厂hasDirectives[name].push(directiveFactory); //多个指令对象的形式注册,如:{'d1':function(){},d2:['$injector',function($injector){}]} }else{ forEach(name,reverseParams(registerDirective)); } //提供链式调用returnthis;};ok,结合上面的源代码加注释,其实可以知道一下几点:
一个指令可以注册多个工厂函数,就意味着将对应多个指令对象(即指令对象集合),其实多个指令对象之间是有一些冲突的,比如只能拥有有一个模板,拥有一个孤立作用域等
一个指令对应的指令对象集合是通过注册为服务的方式被外界获取的,比如我们可以这样获取上面例子中的myDirective指令集合(代码在这里):
angular.module('myApp').run(['$injector',function($injector){ console.log($injector.get('myDirective'+'Directive'));}]);另外,工厂函数是支持依赖注入的,所以我们注册指令的形式就可以有那么几种:
//普通的指令app.directive('myDirective',function(){ returnfunction(){ console.log('postLink1'); }});//隐式注入app.directive('myDirective',function($injector){ returnfunction(){ console.log('postLink2:',$injector); }});//显式注入app.directive('myDirective',function($injector){ returnfunction(){ console.log('postLink3:',$injector); }}]);指令对象的各个字段
作为使用者,在创建指令的时候,我们其实只要关注工厂函数返回的那个对象(我们称之为
指令对象
)就可以了,因为在指令编译的时候,它的每个字段将决定着你创建的指令会拥有哪些特性。对于这些字段含义,打算在后面的编译指令时结合源码,举例讲解会比较清楚些。
指令详解
不知道大家是否还记得
angular
开始编译的入口,在scope原理的开头有提到过:injector.invoke(['$rootScope','$rootElement','$compile','$injector',functionbootstrapApply(scope,element,compile,injector){ scope.$apply(function(){ element.data('$injector',injector); compile(element)(scope);//这里这里开始编译}); }]);在所有
module
都装载完毕在之后,compile(element)(scope;
这句开始编译和链接整个dom树(其实就是调用dom上出现的指令)。这里
$compile
是$CompileProvider
服务返回的函数单例。第一步:传递应用根节点给$compile函数,开始编译,返回link函数。
第二步:传递根作用域给link函数,开始链接(每个指令分为pre link 和 post link两个过程)
指令的link和compile
angular在解析指令的时候,其实会先按一定的顺序执行所有指令的
compile函数
,然后执行所有指令的preLink函数
(如果存在的话),最后执行所有指令的postLink函数
。指令对象有两个重要字段,分别是
compile
和link
,其中compile函数的返回值会被用作link字段(函数或者对象如:{pre: function() {}, post}
),所以有下面几种情况:
当compile字段存在时,link字段将被忽略,compile函数的返回值将作为link字段。
当compile不存在,link字段存在时,
angular
通过这样directive.compile = valueFn(directive.link;
包装一层,使用用户定义定义的link字段我们说了,link分为preLink和posLink两个阶段,这在哪里体现出来呢,是这样的:link字段或者compile函数的返回值(将作为link字段)可以有两个情况:
app.directive('myDirective',function(){ return{ compile:function(){ return{ pre:function(){ console.log('preLink'); },post:function(){ console.log('postLink'); } } } }});还有种情况,我们的指令工厂返回的是一个函数,那么angular通过这样的包装
directive = { compile: valueFn}
,即该函数将作为指令对象的postLink函数,像这样:为了看清angular编译链接指令的顺序,我用以下代码输出日志的方式来说明(代码在这里):<bodyng-app="myApp"> <Aa1> <Bb1b2></B> <C> <Ee1></E> <F> <G></G> </F> </C> <Dd1></D> </A></body>
varapp=angular.module('myApp',[]);varnames=['a1','b1','b2','e1','d1'];names.forEach(function(name){ app.directive(name,function(){ return{ compile:function(){ console.log(name+'compile'); return{ pre:function(){ console.log(name+'preLink'); },post:function(){ console.log(name+'postLink'); } }; } }; });});
控制台输出:
a1compile b1compile b2compile e1compile d1compile a1preLink b1preLink b2preLink b2postLink b1postLink e1preLink e1postLink d1preLink d1postLink a1postLink
可以看出:
所有的指令都是先compile,然后preLink,然后postLink。
节点指令的preLink是在所有子节点指令preLink,postLink之前,所以一般这里就可以通过scope给子节点传递一定的信息。
节点指令的postLink是在所有子节点指令preLink,postLink完毕之后,也就意味着,当父节点指令执行postLink时,子节点postLink已经都完成了,此时
子dom树已经稳定
,所以我们大部分dom操作,访问子节点都在这个阶段。指令在link的过程,其实是一个深度优先遍历的过程,postLink的执行其实是一个回溯的过程。
节点上的可能有若干指令,在搜集的时候就会按一定顺序排列(通过byPriority排序),执行的时候,preLinks是正序执行,而postLinks则是倒序执行。
来一张清晰的流程图:
大都数情况下,我们编写指令的时候大部分情况会用到postLink(link字段如果是函数而非对象,默认情况下也是postLink)
那我们来看看preLink的应用场景(找到一个例子),代码在这里:
<bodyng-app="myApp"> <my-parent></my-parent></body>
Hey,IamLovesueee Hey,andmyparentisundefined
这里父指令模板里面嵌套一个子指令(子指令继承了父指令的作用域),我们在子指令中有一个模板变量{says}
,它的值我们在link函数中引用了scope.name
,因为我们认为子作用域继承了父作用域,但是结果却显示为undefined
。
问题在哪里?我们按顺序来,当link开始时,首先是myParent
指令,先preLink(这里没有),此时发现存在子指令myChild
,那么对子指令进行preLink(也没有),…,之后再对子指令进行postLink,注意此时.name为undefined
,(myParent的postLink还未执行),所以子指令的.says
的取值就是:Hey,I am child,and my parent is undefined,最后才会执行myParent
指令的postLink。
所以说,由于父指令的postLink总是在子指令的preLink和postLink之后执行,而父指令的preLink总是在子指令的preLink和postLink之前执行,所以当父指令要通过scope传递数据数据给子指令(或者说子指令想要访问父指令的作用域数据)时,我们便可以通过preLink函数给scope赋值,像这样改造上面的例子,代码在这里:
app.directive('myParent',link:{ pre:function(scope,Iam'; } } };});
指令的scope
在scope原理中,就说明了一点:指令的编译过程中伴随着作用域的创建,这个作用域是跟指令相关的(比较熟悉的内置指令:
ngController
创建scope)。
在scope原理中也说过,作用域是可继承的,也有孤立作用域的存在,那么在指令中,我们通过指定scope字段来利用这些特点:
scope: true(非空),创建一个继承自父作用域的子作用域,这就意味着,子指令拥有了自己的作用域,同时可以访问父指令的作用域数据。
scope: false(空值),不创建任何作用域,将父作用域当做当前作用域,这意味着,子指令对数据的任何修改都会影响父作用域。
scope: {…}(对象),创建孤立作用域,这就意味着,与父作用域没有任何联系。
对于前两点,没啥需要解释的,来看看第三点孤立作用域:
我们知道孤立作用域,是单独存在的一个作用域,没继承关系,也不直接引用父作用域,那么问题来了:如果想要访问父作用域的数据,该怎么办?
angular帮我们解决了这个问题,通过将当前节点的属性做为数据传递桥梁,父作用域可以传递数据给节点属性,孤立作用域便可通过一个映射关系来访问这个节点属性来获取数据,从而达到访问父作用域的目的。
问:还记得指令创建孤立作用域的的条件是什么吗?
答:scope是必须是一个对象。
问:那么这个对象有什么用?
答:这是孤立作用域跟父作用域的数据通信的关键,其实是一个孤立作用域字段跟节点属性的映射,这样的映射有三种形式,决定着跟父作用域数据的读(写)关系。
三种访问父作用域的方式:
@attrName : 单向绑定,这就意味着,孤立作用域的任何改变都不会影响到父作用域,父作用域的改变则影响着孤立作用域。
=attrName : 双向绑定,这就意味着,孤立作用域和它的父作用域的任何改变都会影响着对方。
类似于这样的映射结构:
scope:{ name:"@",//单向绑定,孤立作用域的name字段对应着节点的的name属性,其实我们也可以改变属性名:name:@parentName,这样它对应的节点属性就是parent-namecolor:"=",//双向绑定,孤立作用域的color字段对应着节点的的color属性reverse:"&"//函数绑定,孤立作用域的reverse字段对应着节点的的reverse属性}
这里有一个例子,很好地说明了这三种绑定,代码在这里。
接下来我们需要探究的是孤立作用域如何通过这个映射做到数据传递的?
1.单向绑定
case'@': attrs.$observe(attrName,function(value){ isolateBindingContext[scopeName]=value; }); attrs.$$observers[attrName].$$scope=scope; if(attrs[attrName]){ isolateBindingContext[scopeName]=$interpolate(attrs[attrName])(scope); }
angular内部有一个Attribute
类,用来管理节点的属性,angular利用attrs.$observe
方法,监测节点属性值是否变化,变化了则改变孤立作用域对应的scopeName的值。
2.双向绑定
case'=': //该属性绑定是否可选if(optional&&!attrs[attrName]){ return; } //父作用域的读parentGet=$parse(attrs[attrName]); //...省略不重要代码 //父作用域的写parentSet=parentGet.assign||function(){ lastValue=isolateBindingContext[scopeName]=parentGet(scope); throw$compileMinErr('nonassign',"Expression'{0}'usedwithdirective'{1}'isnon-assignable!",attrs[attrName],newIsolateScopeDirective.name); }; //记录孤立作用域修改之前的值lastValue=isolateBindingContext[scopeName]=parentGet(scope); varunwatch=scope.$watch($parse(attrs[attrName],functionparentValueWatch(parentValue){ //父作用域数据与孤立作用域数据不同if(!compare(parentValue,isolateBindingContext[scopeName])){ //父作用域的数据变化,那么同步孤立作用域数据if(!compare(parentValue,lastValue)){ isolateBindingContext[scopeName]=parentValue; }else{ //孤立作用域的数据变化,那么同步子作用域数据parentSet(scope,parentValue=isolateBindingContext[scopeName]); } } returnlastValue=parentValue; }),null,parentGet.literal);
上面的scope
是父作用域,isolateBindingContext
可认为孤立作用域(也可以是controller实例),parentGet
和parentSet
是对父作用域的读和写操作。
利用父作用域的.$watch
添加对属性值的监听,每一次digest
,函数parentValueWatch
都会执行
如果父作用域数据(parentValue)与孤立作用域数据不同,那么就有两种情况:
孤立作用域的数据变化,那么执行
parentSet(scope= isolateBindingContext[scopeName];
,同步设置父作用域的值父作用域的数据变化,那么执行
isolateBindingContext] = parentValue;
,同步设置孤立作用域的值
这样就可以达到双向绑定的作用了。
3.函数绑定
case'&': parentGet=$parse(attrs[attrName]); isolateBindingContext[scopeName]=function(locals){ returnparentGet(scope,locals); };
代码很简单,其实就是包装了一个函数,利用$parse
服务解析到的parentGet
函数和父作用域scope
,调用父作用域的对应函数。
指令的controller
提到
controller
,大家肯定会想到定义controller(像:myModule.controller(…)),我们经常会在controller里面注入$scope
当前作用域,然后往$scope
里面设置值或者函数,那样我们就可以在html中引用这个controller,然后调用$scope
里的值或者函数,但是这里我们要谈到的是directive中的controller
,到底有啥不同点和相同点?
首先说说,我们通常在指令中如何定义controller?
第一种:通过controller名,引用已定义的controller,代码在这里
app.controller('myController',function($scope){ $scope.name='Lovesueee';//给$scope赋值this.name='maxin';//给controller实例赋值});app.directive('myDirective',function(){ return{ controller:'myController',attrs,ctrl){ console.log(ctrl,scope); } }});
我们自定义一个controller叫做myController
(存储在controllers集合里),然后在指令myDirective
中,通过字符串'myController'
引用这个定义好的controller,最后在link函数中第四个参数便可以调用到这个controller实例(打开控制台看下输出日志)
它的查找原理是什么?
angular会首先会从所有定义好的controllers集合(就像directives集合一样)里面找名字叫做'myController'
的controller(这里就是这样的),如果存在则返回这个contructor,不存在则会从当前$scope
里面查找同名的controller,如果还不存在且设置准许全局查找,则会在全局里面查找同名的controller,它的实现:
expression=controllers.hasOwnProperty(constructor) ?controllers[constructor] :getter(locals.$scope,constructor,true)|| (globals?getter($window,true):undefined);
试一试从当前作用域里查找controller,除了上述的controller: 'myController'
这种字符串引用定义好的controller,我们当然也可以直接在指令中用函数(可以依赖注入哦)定义一个匿名controller,修改之前的例子:
第二种:通过直接定义匿名controllerh函数,再说说ng-controller
指令,实质就是和定义其他指令一样,它的实现:
varngControllerDirective=[function(){ return{ restrict:'A',controller:'@',priority:500 };}];
: true创建了一个继承作用域,定义了: @
字段,这里的@
的表示controller
字段的真正取值来自于-controller="myController"
中的myController
,那么接下来的情况就和第一种情况类似了,只不过,我们大部分情况下,在使用-controller
指令时没有用controller的实例,而是创建一个继承了父作用域的$scope
,然后向$scope
里面赋值来完成模板渲染或者回调,像这样:
<divng-controller="MyController"> <buttonng-click="show()">{{text}}</button> </div>
app.controller("MyController",['$scope',function(scope){ scope.text="点我"; scope.show=function(){ console.log(scope.text); } }]);
倘若我要使用实例化的controller实例呢?
我们在controller的构造函数中我们不再使用 那么在directive中,我们知道 来说说上述 我们知道模板的渲染肯定离开不了 identifier是别名, 最后: 说到controller,可以说下 我们知道孤立作用域的数据通过 前面提到了 $element.data('$' + directive.name + 'Controller'.instance; 这一句将该controller实例通过data方法存储到对应的dom上。 那么是如何做到引用controller实例呢? 前面说了,controller实例是通过data方法被存储在对应指令的dom元素上的,那么要想获得这样的实例,当然就得从dom元素上再次通过data方法取出来,如果是父指令的controller实例,那么就需要在 而到底要不要选择向祖先元素获取controller实例,是由 require: ‘myParent’ 表示只从当前节点上获取myParent指令的controller实例。 require: ’^myParent’ 表示从当前节点上获取myParent指令的controller实例开始,如果获取不到则一直从parent节点上取。 require: ’?myParent’,’^?myParent’ 或者 ’?^myParent’ 加上问号,表示获取不到controller实例也不会报错。 注意:require的值可以是一个数组,来引用多个controller实例。 指令的编译其实是一个复杂的angular
可以通过as
来让我们调用,我们修改代码,<divng-controller="MyControllerasctrl">
<buttonng-click="ctrl.show()">{{ctrl.text}}</button>
</div>
$scope
而是this
(代表controller实例),在html中通过MyController as ctrl
关键字来引用controller实例,所以这里的程序功能和上一个例子没啥区别,只是使用方式不同。link
函数的第四个参数其实就是controller实例,所以如果我们要通过controller传递数据时,就要用this
变量,另外指令中的controller实例
是可以被其它指令所调用的,这就涉及到指令中的require
参数,后面讲到。as
关键字实现的原理:$scope
这个变量,我们通过MyController as ctrl
这一句便可在模板中随意使用ctrl
这个变量名当做controller实例使用,完全可以猜想到$scope.ctrl
其实已经被赋值引用了当前的controller实例,看下angular
如何实现的:functionaddIdentifier(locals,identifier,instance,name){
if(!(locals&&isObject(locals.$scope))){
throwminErr('$controller')('noscp',"Cannotexportcontroller'{0}'as'{1}'!No$scopeobjectprovidedvia`locals`.",name,identifier);
}
locals.$scope[identifier]=instance;}
instance
是controller实例,locals.$scope[identifier= instance;
这一句便是实现这一个功能的核心(另外:指令中还可以通过controllerAs
设置指令的默认别名)。bindToController
,这个指令字段可能大家很少使用,当我们使用孤立作用域的时候,可能会使用到它:: .}
与父作用域进行关联,如果我们想在孤立作用域的使用controller,那么就涉及到:controller实例
的数据通过}
与父作用域进行关联的问题,bindToController: trur
就是用来解决这个问题的(这里就不细讲了,可以看下这个例子)。指令中的require
controller
,其实当一个指令被link之前,controller构造函数会被执行,创建controller实例,此时做的很重要的一点是:dom.parent)
上通过data方法取,如果是祖先指令的controller实例,则需要一直向上遍历并通过data方法取,直到根元素为止(为此angularjs封装了一个可以向祖先元素通过data方法取数据的方法,叫做inheritedData
)。require
的值决定的:
指令编译源码分析图
递归
过程(毕竟dom树),为了描述的更清楚些,我还是画了一个有点复杂思维导图(原图):
这里,我们做一个简单假设:
根节点是节点A
节点C,F,G没有指令,其他节点都有一个或多个指令。
最后
感觉还有好多好多内容要说,⊙�⊙b汗,太长了,下次说好了,若有不对,欢迎指正。
原文:http://www.html-js.com/article/Front-end-source-code-analysis-directive-angularjs130-source-code-analysis-of-the-original