诚惶诚恐的写下这篇文章。用JavaScript实现继承模型,已经是非常成熟的技术,各种大牛也已经写过各式的经验总结和最佳实践。在这里,我只能就我所能,写下我自己的思考和总结。
在阅读之前,我们先假设几个在面向对象编程中的概念是大家熟悉的:
由于讲解这些概念是十分复杂的,所以还请参阅其他资料。
面向对象是当代编程的主流思想。无论是C++还是Java,都是面向对象的。严格上来讲,JavaScript并不是面向对象的,而是“基于对象的”(Object-based),因为它的确缺乏面向对象里的很多特性,例如:
但再另一方面,JavaScript是基于原型(Prototype)的对象系统。它的继承体系,叫做原型链继承。不同于继承树形式的经典对象系统,基于原型的对象系统中,对象的属性和方法是从一个对象原型(或模板)上拷贝或代理(Delegation)的。JavaScript也不是唯一使用这种继承方法的编程语言,其他的例子如:
那么,prototype
在哪里呢?
// 访问Array的原型
Array.prototype
// 访问自定义函数Foo的原型
var Foo = function() {}
Foo.prototype
__proto__
不是标准属性,但是被大多数浏览器支持
var a = {}
a.__proto__;
使用ES5的Object.getPrototypeOf
:
Object.getPrototypeOf([]) === Array.prototype;
再来点绕弯的:
[].constructor.prototype === Array.prototype
new
关键字大多数面向对象语言,都有new
关键字。他们大多和一个构造函数一起使用,能够实例化一个类。JavaScript的new
关键字是异曲同工的。
等等,不是说JavaScript不支持经典继承么!的确,其实new
的含义,在JavaScript中,严格意义上是有区别的。
当我们,执行
new F()
实际上是得到了一个从F.prototype
继承而来的一个对象。这个说法来自Douglas的很早之前的一篇文章1。在如今,如果要理解原型继承中new
的意义,还是这样理解最好。
如果我们要描述new
的工作流程,一个接近的可能流程如下:
constructor
和F.prototype
上的各式方法、属性。注意,这里执行的并不是拷贝,而是代理。后文会讲解这点。this
指向这个对象),并执行构造函数我们来定义一个简单的“类”和它的原型:
var Foo = function() {};
Foo.prototype.bar = function() {
console.log("haha");
};
Foo.prototype.foo = function() { console.log("foo"); };
我们在原型上定义了一个bar
方法。看看我们怎么使用它:
var foo = new Foo();
foo.bar(); // => "haha"
foo.foo(); // => "foo"
我们要继承Foo
:
var SuperFoo = function() {
Foo.apply(this, arguments);
};
SuperFoo.prototype = new Foo();
SuperFoo.prototype.bar = function() {
console.log("haha, haha");
};
var superFoo = new SuperFoo();
superFoo.foo(); // => "foo"
superFoo.bar(); // => "haha, haha"
注意到几个要点:
SuperFoo
中,我们执行了父级构造函数SuperFoo
中,我们让然可以调用foo
方法,即使SuperFoo
上没有定义这个方法。这是继承的一种表现:我们可以访问父类的方法SuperFoo
中,我们重新定义了bar
方法,实现了方法的重载我们仔细想想第二点和第三点。我们新指定的bar
方法到底保存到哪里了?foo
方法是如何找到的?
要回答上面的问题,必须要介绍原型链这个模型。相比树状结构的经典类型系统,原型继承采取了另一种线性模型。
当我们要在对象上查找一个属性或方法时:
prototype
对象上查找,如果没有找到进行下一步prototype
对象作为当前对象;如果当前对象存在prototype
,就能继续,否则不存在则查找失败,退出;在该对象上查找,如果没有找到,将前面提到的“当前对象”作为起始对象,重复步骤3这样的递归查找终究是有终点的,因为:
Object.prototype.__proto__ === null
也就是Object构造函数上,prototype
这个对象的构造函数上已经没有prototype
了。
我们来看之前Foo
和SuperFoo
的例子,我们抽象出成员查找的流程如下:
superFoo本身 => SuperFoo.prototype => Foo.prototype => Object.prototype
解读原型链的查找流程:
superFoo本身
意味着superFoo
这个实例有除了能够从原型上获取属性和方法,本身也有存储属性、方法的能力。我们称其为own property
,我们也有不少相关的方法来操作:SuperFoo.prototype
:SuperFoo.prototype = new Foo();
,也就是说SuperFoo.prototoye
就是这个新创建的这个Foo类型的对象Foo.prototype
上的方法和属性了Foo.prototype
:SuperFoo.prototype
的值,回想上一条Object.prototype
Object.prototype
这个对象的原型是null
,我们无法继续查找toString
那么,当在SuperFoo
上添加bar
方法呢?这时,JavaScript引擎会在SuperFoo.prototype
的本地添加bar
这个方法。当你再次查找bar
方法时,按照我们之前说明的流程,会优先找到这个新添加的方法,而不会找到再原型链更后面的Foo.prototype.bar
。
也就是说,我们既没有删掉或改写原来的bar
方法,也没有引入特殊的查找逻辑。
基本到这里,继承的大部分原理和行为都已经介绍完毕了。但是如何将这些看似简陋的东西封装成最简单的、可重复使用的工具呢?本文的后半部分将一步一步来介绍如何编写一个大体可用的对象系统。
准备几个小技巧,以便我们在后面使用。
如果要以一个对象作为原型,创建一个新对象:
function beget(o) {
function F() {}
F.prototype = o;
return new F();
}
var foo = beget({bar:"bar"});
foo.bar === "bar"; //true
理解这些应该困难。我们构造了一个临时构造函数,让它的prototype
指向我们所期望的原型,然后返回这个构造函数所创建的实例。有一些细节:
A.prototype = B.prototype
这样的事情,因为你对子类的修改,有可能直接影响到父类以及父类的所有实例。大多数情况下这不是你想看到的结果F
的实例,创建了一个本地对象
,可以持有(own)自身的属性和方法,便可以支持之后的任意修改。回忆一下superFoo.bar
方法。如果你使用的JavaScript引擎支持Object.create
,那么同样的事情就更简单:
Object.create({bar:"bar"});
要注意Object.create
的区别:
Object.create(null)
Object.create
的文档2JavaScript的函数可以在运行时很方便的获取其字符串表达:
var f = function(a) {console.log("a")};
f.toString(); // 'function(a) {console.log("a")};'
这样的能力其实时很强大的,你去问问Java和C++工程师该如何做到这点吧。
这意味着,我们可以去分析函数的字符串表达来做到:
this
JavaScript中的this
是在运行时绑定的,我们往往需要用到这个特性,例如:
var A = function() {};
A.methodA = function() {
console.log(this === A);
};
A.methodA();// => true
以上这段代码有如下细节:
A.methodA()
运行时,其上下文对象指定的是A
,所以this
指向了A
this
引用到类(构造函数)本身单纯实现一个extend
方法:
var extend = function(Base) {
var Class = function() {
Base.apply(this, arguments);
}, F;
if(Object.create) {
Class.prototype = Object.create(Base.prototype);
} else {
F = function() {};
F.prototype = Base.prototype;
Class.prototype = new F();
}
Class.prototype.constructor = Class;
return Class;
};
var Foo = function(name) {
this.name = name;
};
Foo.prototype.bar = function() {
return "bar";
};
var SuperFoo = extend(Foo);
var superFoo = new SuperFoo("super");
console.log(superFoo.name);// => "super"
console.log(superFoo.bar());// => "bar"
由于过于简单,我就不做讲解了。
XObject
extend
,但是会有修改var extend = function(Base) {
var Class = function() {
Base.apply(this, arguments);
}, F;
if(Object.create) {
Class.prototype = Object.create(Base.prototype);
} else {
F = function() {};
F.prototype = Base.prototype;
Class.prototype = new F();
}
Class.prototype.constructor = Class;
return Class;
};
var merge = function(target, source) {
var k;
for(k in source) {
if(source.hasOwnProperty(k)) {
target[k] = source[k];
}
}
return target;
};
// Base Contstructor
var XObject = function() {};
XObject.extend = function(props) {
var Class = extend(this);
if(props) {
merge(Class.prototype, props);
}
// copy `extend`
// should not use code like this; will throw at ES6
// Class.extend = arguments.callee;
Class.extend = XObject.extend;
return Class;
};
var Foo = XObject.extend({
bar: function() { return "bar"; },
name: "foo"
});
var SuperFoo = Foo.extend({
name: "superfoo",
bar: function() { return "super bar"; }
});
var foo = new Foo();
console.log(foo.bar()); // => "bar"
console.log(foo.name); // => "foo"
var superFoo = new SuperFoo();
console.log(superFoo.name); // => "superfoo"
console.log(superFoo.bar()); // => "super bar"
上面的例子中,
XObject
是我们对象系统的根类XObject.extend
可以接受一个包含属性和方法的对象来定义子类XObject
的所有子类,都没有定义构造函数逻辑的机会!真是难以接受的:init
方法来初始化对象,而将构造函数本身最简化init
方法绕开了工厂方法的实现过程中,参数传递如何传递到构造函数的问题super
属性、mixin
特性等我们解决了一部分问题,又发现了一些新问题。但本文的主要内容在这里就结束了。一个更具实际意义的对象系统,实际随处可见,Ember
和Angular
中的根类。他们都有更强大的功能,例如:
但是,这些框架中对象系统的出发点都在本文所阐述的内容之中。作为教学,John Resig在2008年的一篇博客中3总结了一个现代JavaScript框架中的对象系统的雏形。
我创建了docco代码注解来立即这段代码,本文也会结束在这段代码的注解。强力推荐大家去阅读该注解文档。
还有一些更高级的话题和技巧,会在另外一篇文章中给出。