前段时间曾经在InfoQ中文站上发表文章,介绍了dojo类机制的基本用法。有些朋友在读后希望能够更深入了解这部分的内容,本文将会介绍dojo类机制幕后的知识,其中会涉及到dojo类机制的实现原理并对一些关键方法进行源码分析,当然在此之前希望您能够对JavaScript和dojo的使用有些基本的了解。
dojo的类机制支持类声明、继承、调用父类方法等功能。dojo在底层实现上是通过操作原型链来实现其类机制的,而在实现继承时采用类式继承的方式。值得一提的是,dojo的类机制允许进行多继承(注意,只有父类列表中的第一个作为真正的父类,其它的都是将其属性以mixin的方法加入到子类的原型链中),为解决多重继承时类方法的顺序问题,dojo用JavaScript实现了Python和其它多继承语言所支持的C3父类线性化算法,以实现线性的继承关系,想了解更多该算法的知识,可参考这里,我们在后面的分析中将会简单讲解dojo对此算法的实现。
1. dojo类声明概览
dojo类声明相关的代码位于“/dojo/_base/declare.js”文件中,定义类是通过dojo.declare方法来实现的。关于这个方法的基本用法,已经在dojo类机制简介这篇文章中进行了阐述,现在我们看一下它的实现原理(在这部分的代码分析中,会在整体上介绍dojo如何声明类,后文会对里面的重要细节内容进行介绍):
//此即为dojo.declare方法的定义 d.declare = function(className,superclass,props){ //前面有格式化参数相关的操作,一般情况下定义类会把三个参数全传进来,分别为 //类名、父类(可以为null、某个类或多个类组成的数组)和要声明类的属性及方法 //定义一系列的变量供后面使用 var proto,i,t,ctor,name,bases,chains,mixins = 1,parents = superclass; // 处理要声明类的父类 if(opts.call(superclass) == "[object Array]"){ //如果父类参数传过来的是数组,那么这里就是多继承,要用C3算法处理父类的关系 //得到的bases为数组,第一个元素能标识真正父类(即superclass参数中的第一个)//在数组中的索引,其余的数组元素是按顺序排好的继承链,后面还会介绍到C3算法 bases = c3mro(superclass,className); t = bases[0]; mixins = bases.length - t; superclass = bases[mixins]; }else{ //此分支内是对没有父类或单个父类情况的处理,不再详述 } //以下为构建类的原型属性和方法 if(superclass){ for(i = mixins - 1;; --i){ //此处遍历所有需要mixin的类 //注意此处,为什么说多个父类的情况下,只有第一个父类是真正的父类呢,因//为在第一次循环的实例化了该父类,并记在了原型链中,而其它需要mixin的//父类在后面处理时会把superclass设为一个空的构造方法,合并父类原型链//后进行实例化 proto = forceNew(superclass); if(!i){ //此处在完成最后一个父类后跳出循环 break; } // mix in properties t = bases[i];//得到要mixin的一个父类 (t._Meta ? mixOwn : mix)(proto,t.prototype);//合并原型链 // chain in new constructor ctor = new Function;//声明一个新的Function ctor.superclass = superclass; ctor.prototype = proto;//设置原型链 //此时将superclass指向了这个新的Function,再次进入这个循环的时候,实例//化的是ctor,而不是mixin的父类 superclass = proto.constructor = ctor; } }else{ proto = {}; } //此处将上面得到的方法(及属性)与要声明类本身所拥有的方法(及属性)进行合并 safeMixin(proto,props); ………… //此处收集链式调用相关的信息,后面会详述 for(i = mixins - 1; i; --i){ // intentional assignment t = bases[i]._Meta; if(t && t.chains){ chains = mix(chains || {},t.chains); } } if(proto["-chains-"]){ chains = mix(chains || {},proto["-chains-"]); } //此处根据上面收集的链式调用信息和父类信息构建最终的构造方法,后文详述 t = !chains || !chains.hasOwnProperty(cname); bases[0] = ctor = (chains && chains.constructor === "manual") ? simpleConstructor(bases) : (bases.length == 1 ? singleConstructor(props.constructor,t) : chainedConstructor(bases,t)); //在这个构造方法中添加了许多的属性,在进行链式调用以及调用父类方法等处会用到 ctor._Meta = {bases: bases,hidden: props,chains: chains,parents: parents,ctor: props.constructor}; ctor.superclass = superclass && superclass.prototype; ctor.extend = extend; ctor.prototype = proto; proto.constructor = ctor; // 对于dojo.declare方法声明类的实例均有以下的工具方法 proto.getInherited = getInherited; proto.inherited = inherited; proto.isInstanceOf = isInstanceOf; // 此处要进行全局注册 if(className){ proto.declaredClass = className; d.setObject(className,ctor); } //对于链式调用父类的那些方法进行处理,实际上进行了重写,后文详述 if(chains){ for(name in chains){ if(proto[name] && typeof chains[name] == "string" && name != cname){ t = proto[name] = chain(name,chains[name] === "after"); t.nom = name; } } } return ctor;//Function };
以上简单介绍了dojo声明类的整体流程,但是一些关键的细节如C3算法、链式调用在后面会继续进行介绍。
2. C3算法的实现
通过以前的文章和上面的分析,我们知道dojo的类声明支持多继承。在处理多继承时,不得不面对的就是继承链如何构造,比较现实的问题是如果多个父类都拥有同名的方法,那么在调用父类方法时,要按照什么规则确定调用哪个父类的呢?在解决这个问题上dojo实现了C3父类线性化的方法,对多个父类进行合理的排序,从而完美解决了这个问题。
为了了解继承链的相关知识,我们看一个简单的例子:
dojo.declare("A",null); dojo.declare("B",null); dojo.declare("C",null); dojo.declare("D",[A,B]); dojo.declare("E",[B,C]); dojo.declare("F",C]); dojo.declare("G",[D,E]);
以上的代码中,声明了几个类,通过C3算法得到G的继承顺序应该是这样G->E->C->D->B->A的,只有按照这样的顺序才能保证类定义和依赖是正确的。那我们看一下这个C3算法是如何实现的呢:
function c3mro(bases,className){ //定义一系列的变量 var result = [],roots = [{cls: 0,refs: []}],nameMap = {},clsCount = 1,l = bases.length,i = 0,j,lin,base,top,proto,rec,refs; //在这个循环中,构建出了父类各自的依赖关系(即父类可能会依赖其它的类) for(; i < l; ++i){ base = bases[i];//得到父类 ………… //在dojo声明的类中都有一个_Meta属性,记录父类信息,此处能够得到包含本身在//内的继承链 lin = base._Meta ? base._Meta.bases : [base]; top = 0; for(j = lin.length - 1; j >= 0; --j){ //遍历继承链中的元素,注意,这里的处理是反向的,即从最底层的开始,一直到链的顶端 proto = lin[j].prototype; if(!proto.hasOwnProperty("declaredClass")){ proto.declaredClass = "uniqName_" + (counter++); } name = proto.declaredClass; // nameMap以map的方式记录了用到的类,不会重复 if(!nameMap.hasOwnProperty(name)){ //每个类都会有这样一个结构,其中refs特别重要,记录了引用了依赖类 nameMap[name] = {count: 0,refs: [],cls: lin[j]}; ++clsCount; } rec = nameMap[name]; if(top && top !== rec){ //满足条件时,意味着当前的类依赖此时top引用的类,即链的前一元素 rec.refs.push(top); ++top.count; } top = rec;//top指向当前的类,开始下一循环 } ++top.count; roots[0].refs.push(top);//在一个父类处理完成后就将它放在根的引用中 } //到此为止,我们建立了父类元素的依赖关系,以下要正确处理这些关系 while(roots.length){ top = roots.pop(); //将依赖的类放入结果集中 result.push(top.cls); --clsCount; // optimization: follow a single-linked chain while(refs = top.refs,refs.length == 1){ //若当前类依赖的是一个父类,那处理这个依赖链 top = refs[0]; if(!top || --top.count){ //特别注意此时有一个top.count变量,是用来记录这个类被引用的次数,//如果减一之后,值还大于零,说明后面还有引用,此时不做处理,这也就是//在前面的例子中为什么不会出现G->E->C->B的原因 top = 0; break; } result.push(top.cls); --clsCount; } if(top){ //若依赖多个分支,则将依赖的类分别放到roots中,这段代码只有在多继承,//第一次进入时才会执行 for(i = 0,l = refs.length; i < l; ++i){ top = refs[i]; if(!--top.count){ roots.push(top); } } } } if(clsCount){//如果上面处理完成后,clsCount的值还大于1,那说明出错了 err("can't build consistent linearization",className); } //构建完继承链后,要标识出真正父类在链的什么位置,就是通过返回数组的第一个元素 base = bases[0]; result[0] = base ? base._Meta && base === result[result.length - base._Meta.bases.length] ? base._Meta.bases.length : 1 : 0; return result; }
通过以上的分析,我们可以看到,这个算法实现起来相当复杂,如果朋友们对其感兴趣,建议按照上文的例子,自己加断点进行调试分析。dojo的作者使用了不到100行的代码实现了这样强大的功能,里面有很多值得借鉴的设计思想。
3. 链式构造器的实现
在第一部分代码分析中我们曾经看到过定义构造函数的代码,如下:
bases[0] = ctor = (chains && chains.constructor === "manual") ? simpleConstructor(bases) : (bases.length == 1 ? singleConstructor(props.constructor,t));
这个方法对于理解dojo类机制很重要。从前一篇文章的介绍中,我们了解到默认情况下,如果dojo声明的类存在继承关系,那么就会自动调用父类的构造方法,且是按照继承链的顺序先调用父类的构造方法,但是从1.4版本开始,dojo提供了手动设置构造方法调用的选项。在以上的代码中涉及到dojo声明类的三个方法,如果该类没有父类,那么调用的就是singleConstructor,如果有父类的话,那么默认调用的是chainedConstructor,如果手动设置了构造方法,那么调用的就是simpleConstructor,要启动这个选项只需在声明该类的时候添加chains的constructor声明即可。
比方说,我们在定义继承自com.levinzhang.Person的com.levinzhang.Employee类时,可以这样做:
dojo.declare("com.levinzhang.Employee",com.levinzhang.Person,{ "-chains-": { constructor:"manual" },………… }
添加以上代码后,在构造com.levinzhang.Employee实例时,就不会再调用所有父类的构造方法了,但是此时我们可以使用inherited方法显式的调用父类方法。
限于篇幅,以上的三个方法不全部介绍,只介绍chainedConstructor的核心实现:
function chainedConstructor(bases,ctorSpecial){ return function(){ //在此之前有一些准备工作,不详述了 //找到所有的父类,分别调用其构造方法 for(i = l - 1; i >= 0; --i){ f = bases[i]; m = f._Meta; f = m ? m.ctor : f;//得到父类的构造方法 if(f){ //通过apply调用父类的方法 f.apply(this,preArgs ? preArgs[i] : a); } } // 请注意在构造方法执行完毕后,会执行名为postscript的方法,而这个方法是//dojo的dijit组件实现的关键生命周期方法 f = this.postscript; if(f){ f.apply(this,args); } }; }
在声明dojo类的时候,如果想调用父类的方法一般都是通过使用inherited方法来实现,但从1.4版本开始,dojo支持链式调用所有父类的方法,并引入了一些AOP的概念。我们将会分别介绍这两种方式。
在上一篇文章中,我们曾经介绍过,通过在类中使用inherited就可以调用到。这里我们要深入inherited的内部,看一下其实现原理。因为inherited支持调用父类的一般方法和构造方法,两者略有不同,我们关注调用一般方法的过程。
function inherited(args,a,f){ ………… //在此之前有一些参数的处理 if(name != cname){ // 不是构造方法 if(cache.c !== caller){ //在此之间的一些代码解决了确定调用者的问题,即确定从什么位置开始找父类 } //按照顺序找父类的同名方法 base = bases[++pos]; if(base){ proto = base.prototype; if(base._Meta && proto.hasOwnProperty(name)){ f = proto[name];//找到此方法了 }else{ //如果没有找到对应的方法将按照继承链依次往前找 opf = op[name]; do{ proto = base.prototype; f = proto[name]; if(f && (base._Meta ? proto.hasOwnProperty(name) : f !== opf)){ break; } }while(base = bases[++pos]); // intentional assignment } } f = base && f || op[name]; }else{ //此处是处理调用父类的构造方法 } if(f){ //方法找到后,执行 return a === true ? f : f.apply(this,a || args); } }
这是从dojo 1.4版本新加入的功能。如果在执行某个方法时,也想按照一定的顺序执行父类的方法,只需在定义类时,在-chains-属性中加以声明即可。
dojo.declare("com.levinzhang.Employee",{ "-chains-": { sayMyself: "before" },…… }
添加了以上声明后,意味着Employee及其所有的子类,在调用sayMyself方法时,都会先调用本身的同名方法,然后再按照继承链依次调用所有父类的同名方法,我们还可以将值“before”替换为“after”,其执行顺序将会相反。在-chains-属性中声明的方法,在类定义时,会进行特殊处理,正如我们在第一章中看到的那样:
if(chains){ for(name in chains){ if(proto[name] && typeof chains[name] == "string" && name != cname){ t = proto[name] = chain(name,chains[name] === "after"); t.nom = name; } } }
我们可以看到在-chains-中声明的方法都进行了替换,换成了chain方法的返回值,而这个方法也比较简单,源码如下:
function chain(name,reversed){ return function(){ var b,m,f,step = 1; if(reversed){ //判定顺序,即“after”还是“before”,分别对应于循环的不同起点和方向 i = bases.length - 1; step = -1; } for(; b = bases[i]; i += step){ //按照顺序依次查找父类 m = b._Meta; //找到父类中同名的方法 f = (m ? m.hidden : b.prototype)[name]; if(f){ //依次执行 f.apply(this,arguments); } } }; }
5. 工具方法和属性如isInstanceOf、declaredClass的实现
除了上面提到的inherited方法以外,dojo在实现类功能的时候,还实现了一些工具方法和属性,这里介绍一个方法isInstanceOf和一个属性declaredClass。从功能上来说isInstanceOf方法用来判断一个对象是否为某个类的实例,而declaredClass属性得到的是某个对象所对应声明类的名字。
function isInstanceOf(cls){ //得到实例对象继承链上的所有类 var bases = this.constructor._Meta.bases; //遍历所有的类,看是否与传进来的类相等 for(var i = 0,l = bases.length; i < l; ++i){ if(bases[i] === cls){ return true; } } return this instanceof cls; }
而declaredClass属性的实现比较简单,只是在声明类的原型上添加了一个属性而已,类的实例对象就可以访问这个属性得到其声明类的名字了。这段代码在dojo.declare方法中:
if(className){ proto.declaredClass = className; d.setObject(className,ctor); }
在dojo实现类机制的过程中,有一些内部的方法,是很值得借鉴的如forceNew、safeMixin等,这些方法在实现功能的同时,保证了代码的高效执行,感兴趣的朋友可以进一步的研究。
6. 总结与思考
1) dojo在实现类机制方面支持多继承方式,其它JavaScript类库中很少能做到,而利用JavaScript原生语法实现多继承也较为困难。在这一点上dojo的类机制的功能确实足够强大。但是多继承会增加编码的难度,对开发人员如何组织类也有更高的要求;
2) 链式调用父类方法时,我们可以看到dojo引入了许多AOP的理念,在1.7的版本中,将会有单独的模块提供AOP相关的支持,我们将会持续关注类似的功能;
3) 在dojo的代码中,多处都会出现方法替换,如链式方法调用、事件绑定等,这种设计思想值得我们关注和学习;
4) 使用了许多的内部属性,如_Meta、bases等,这些元数据在实现复杂的类机制中起到了至关重要的作用,在进行源码分析的时候,我们可以给予关注,如果要实现类似功能也可以进行借鉴。
探究类库的实现原理是提高自己编码水平的好办法,类似于dojo这样类库的核心代码基本上每一行都有其设计思想在里面(当然也不可以盲目崇拜),每次阅读和探索都会有所发现和心得,当然里面肯定也会有自以为是或谬误之处,在此很乐意和读到这篇文章的朋友们一起研究,欢迎批评指正。
参考资料:
http://blog.csdn.net/dojotoolkit/
作者信息:张卫滨,关注企业级Java开发和RIA技术,个人博客:http://lengyun3566.iteye.com,微博:http://weibo.com/zhangweibin1981
声明:
本文已经首发于InfoQ中文站,版权所有,原文为《dojo类机制实现原理分析》,如需转载,请务必附带本声明,谢谢。
InfoQ中文站是一个面向中高端技术人员的在线独立社区,为Java、.NET、Ruby、SOA、敏捷、架构等领域提供及时而有深度的资讯、高端技术大会如QCon 、线下技术交流活动QClub、免费迷你书下载如《架构师》等。