写点什么

dojo 类机制实现原理分析

2011 年 10 月 26 日

前段时间曾经在 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, bases, 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",[A, 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, name, 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) : chainedConstructor(bases, 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);
}
};
}

4. 调用父类方法的实现

在声明 dojo 类的时候,如果想调用父类的方法一般都是通过使用 inherited 方法来实现,但从 1.4 版本开始,dojo 支持链式调用所有父类的方法,并引入了一些 AOP 的概念。我们将会分别介绍这两种方式。

通过 inherited 方式调用父类方法

在上一篇文章中,我们曾经介绍过,通过在类中使用 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", com.levinzhang.Person,{
"-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, bases, chains[name] === "after");
t.nom = name;
}
}
}

我们可以看到,在 -chains- 中声明的方法都进行了替换,换成了 chain 方法的返回值,而这个方法也比较简单,源码如下:

复制代码
function chain(name, bases, reversed){
return function(){
var b, m, f, i = 0, 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 这样类库的核心代码基本上每一行都有其设计思想在里面(当然也不可以盲目崇拜),每次阅读和探索都会有所发现和心得,当然里面肯定也会有自以为是或谬误之处,在此很乐意和读到这篇文章的朋友们一起研究,欢迎批评指正。

参考资料:

关于作者

张卫滨,关注企业级 Java 开发和 RIA 技术,个人博客: http://lengyun3566.iteye.com ,微博: http://weibo.com/zhangweibin1981


感谢侯伯薇对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011 年 10 月 26 日 00:006512

评论

发布
暂无评论
发现更多内容

产品经理训练营-第二、三周作业

发条檀木

产品经理训练营

GNUCash 3: 科目布局

lidaobing

GNUCash 28天写作

【JS】执行静态代码

学习委员

JavaScript 前端 js 28天写作

第九周作业&总结

胡益

【shell命令】关于mac的open命令

程序员架构进阶

工具 Shell 命令行 28天写作

产品经理训练营-第二周学习总结

月亮 😝

批判性思维自修课(二)

石君

28天写作 批判性思维

CSS(三)——简单的网页制作

程序员的时光

CSS 七日更 28天写作

CSS(四)——CSS高级特性

程序员的时光

CSS 程序员 七日更 28天写作

react-router学习笔记

hao-kuai

React-Router

产品经理训练营作业-利益相关方

郭郭

开发质量提升系列:checklist 投产检查列表(下)

罗小龙

代码质量 28天写作 checklist

项目管理文化建设

Ian哥

28天写作

静下心来

青城

程序人生 28天写作 青城

第二周作业-利益相关方

Au revoir

【CSS】文字毛玻璃效果(简单版)

学习委员

html/css CSS小技巧 28天写作 纯CSS

产品经理-作业2

简小一

基于Serverless实现静态博客访问统计功能

zFish

Serverless AWS Hugo Pulumi

Serverless应用开发小记

zFish

DevOps AWS IaC #Serverless Terraform

没有女友的建议,不会有这家巨头公司

李忠良

28天写作

产品经理训练营作业 01

KingSwim

第二章作业

Deborah

机器学习·笔记之:这节课在干啥?

Nydia

Android硬编解码MediaCodec使用笔记

Changing Lin

android 音视频

2021,你值得看的华为/字节/腾讯/京东/网易/滴滴面经分享

比伯

Java 编程 架构 面试 计算机

酒话?醉话?真话?写在2021年的开始

ITCamel

2020年总结 2021年展望

28天瞎写的第二百二十八天:用 UltraEdit 找优越感的故事

树上

28天写作 UltraEdit

【CSS】角标(伪元素)

学习委员

css3 html/css 28天写作 纯CSS

第四课作业

糯米~

微信视频号的长视频和短视频 | 视频号28天(18)

赵新龙

28天写作

汽车AI芯片(28天写作 Day18/28)

mtfelix

自动驾驶 28天写作 车规级芯片

dojo类机制实现原理分析-InfoQ