原文: JavaScript inheritance by example
作者: Axel Rauschmayer
原文发布时间: 2012.01.08

译文:

本文通过几个例子说明了JavaScript中的关于继承的几个主题: 第一个例子是JS中构造器(constructor) Point以及它的二级构造器ColorPoint的原生实现, 然后一步步对它们进行改进.

对象

JavaScript是少数可以直接创建对象的面向对象编程语言之一. 在大部分其他编程语言中, 需要通过class来创建对象. 现在我们用JavaScript创建一个point对象.

1
2
3
4
5
6
7
8
9
10
var point = {
x: 5,
y: 2,
dist: function () {
return Math.sqrt((this.x*this.x)+(this.y*this.y));
},
toString: function () {
return "("+this.x+", "+this.y+")";
}
};

使用大括号创建对象的语法是通过对象初始化器/对象字面量. 对象point有4个属性: x, y, dist以及toString. 可以通过点操作符读取这些属性的值:

1
2
point.x
// 5

值为函数的属性叫做方法(methods), 调用方法的方式是:

1
2
3
4
5
point.dist()
// 5.385164807134504
point.toString()
// '(5, 2)'

构造器

以上例子中我们只创建一个point对象, 如果想要创建多个, 就需要为这些对象创建工厂. 在类继承编程语言中这种工厂叫做类 classes, 在JavaScript中叫做构造器 constructors. 以函数foo为例: JS中调用函数foo有两种方式:

  • 函数调用: foo(arg1, arg2)
  • 构造器调用: new foo(arg1, arg2)

以下是用以创建多个point对象的构造器Point:

1
2
3
4
5
6
7
8
9
10
function Point(x, y) {
this.x = x;
this.y = y;
this.dist = function () {
return Math.sqrt((this.x*this.x)+(this.y*this.y));
};
this.toString = function () {
return "("+this.x+", "+this.y+")";
};
}

我们执行new Point()时, 构造器的任务是设置一个全新的对象, 然后通过隐式的参数this传入对象的属性. 我们之前使用对象字面量来定义对象的属性, 现在则通过this来定义. 构造器隐式返回全新的对象, 即对象的实例.

1
2
3
var p = new Point(3, 1);
p instanceof Point
// true

和之前一样, 可以调用方法, 获取属性值:

1
2
3
4
5
p.toString()
// '(3, 1)'
p.x
// 3

然而方法被某个实例所特有并不合适, 应该被所有实例共享以节省内存. 可以使用prototype(原型)达到这个目的. 存储在Point.prototype的对象成为Point所产生的所有实例的原型. 对象(“prototypee”)及其原型的工作原理是这样的: prototypee继承所有其原型的属性. 一般来说, 原型只有一个, prototypee可以有多个, 所有prototypee共用原型的属性. 因此, 我们可以在Point.prototype内定义方法:

1
2
3
4
5
6
7
8
9
10
11
12
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype = {
dist: function () {
return Math.sqrt((this.x*this.x)+(this.y*this.y));
},
toString: function () {
return "("+this.x+", "+this.y+")";
}
}

上例中我们通过对象字面量将一个对象赋值给Point.prototype, 这个对象含有两个方法: disttoString. 现在各部分的任务有了明确的分工, 构造器设置实例专属的数据, 原型中包含共享的数据(如方法). 注意JavaScript引擎对原型实现了高度优化, 因此在原型中定义方法不会引起太大的性能问题, 调用方法的方式也与之前无异, 跟在哪里定义方法无关. 不过还是存在一个问题: 对于所有的函数f, 以下声明必须成立(详见参考[1]):

1
f.prototype.constructor === f

上述声明是所有函数的默认行为, 但我们利用对象字面量替换了Point.prototype的默认值, 这样上述声明就不再成立, 为了使上述声明成立, 有两种方法, 一是手动在对象字面量添加constructor属性, 二是通过添加而非替换的方式定义原型中的方法, 这样就不会覆盖原本的默认值:

1
2
3
4
5
6
7
8
9
10
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.dist = function () {
return Math.sqrt((this.x*this.x)+(this.y*this.y));
};
Point.prototype.toString = function () {
return "("+this.x+", "+this.y+")";
};

constructor属性不是特别重要, 主要用于检查特定的实例由哪个构造器创建:

1
2
3
4
5
var p = new Point(2, 2)
p.constructor
// [Function: Point]
p.constructor.name
// 'Point'

扩展对象

在JavaScript中, 扩展一个对象意味着在对象中添加新属性, 但会带来不好的结果: 比如, 我们现在要扩展对象A, 扩展的内容为对象B, 仅仅是将B的属性浅拷贝到A中. JavaScript中有个较不常见的实现这种扩展的方法Object.extend(), 该方法来自于框架”Prototype”. 以下是其实现方式:

1
2
3
4
5
6
7
function extend(target, source) {
// 不要这样做:
for (var propName in source) {
target[propName] = source[propName];
}
return target;
}

上述代码的问题是它使用for-in来遍历对象的所有属性, 包括继承自原型的属性. 见下例:

1
2
3
4
5
extend({}, new Point())
//{ x: undefined,
// y: undefined,
// dist: [Function],
// toString: [Function] }

我们想要扩展的对象获得Point的实例对象本身的属性xy, 但是来自原型的继承属性disttoString方法并不是我们想要的. 继承的属性也被拷贝到第一个对象参数中, 因为for-in会遍历包括继承的属性在内的所有属性. Point继承了多个来自Object对象的属性, 比如valueOf:

1
2
3
var p = new Point(7, 1);
p.valueOf
// [Function: valueOf]

valueOf这类属性没有被拷贝到对象实例中, 因为for-in只会遍历可枚举(Enumerable)(详见参考[2])属性, 而valueOf这类属性不可枚举:

1
2
3
4
p.propertyIsEnumerable("valueOf")
// false
p.propertyIsEnumerable("dist")
// true

为了让extend()方法达到我们预期的效果, 必须确保只有原对象自身的属性被拷贝到目标对象.

1
2
3
4
5
6
7
8
function extend(target, source) {
for (var propName in source) {
if (source.hasOwnProperty(propName)) {
target[propName] = source[propName];
}
}
return target;
}

还有一个问题是: 如果source对象本身含有一个名为”hasOwnProperty”的属性(详见参考[3]), 上述代码就无法成功执行:

1
2
extend({}, { hasOwnProperty: 123 })
// TypeError: Property 'hasOwnProperty' is not a function

失败的原因是source.hasOwnProperty获取的是source对象自身的hasOwnProperty属性, 属性值是一个数字, 而非原型链中的同名属性. 可以通过从原型链中获取这个属性解决该问题:

1
2
3
4
5
6
7
8
9
10
function extend(target, source) {
var hasOwnProperty = Object.prototype.hasOwnProperty;
for (var propName in source) {
// 把this绑定到source对象, 之后再调用hasOwnProperty()方法
if (hasOwnProperty.call(source, propName)) {
target[propName] = source[propName];
}
}
return target;
}

在ECMAScript5或者更旧版本但装载了shim(详见参考[4])的引擎, 使用以下版本的extend()方法更好, 因为这个版本在实现继承(扩展)的同时, 没有改变source对象属性的描述符, 例如可枚举性.

1
2
3
4
5
6
7
8
function extend(target, source) {
Object.getOwnPropertyNames(source)
.forEach(function(propName) {
Object.defineProperty(target, propName,
Object.getOwnPropertyDescriptor(source, propName));
});
return target;
}

设置对象的原型

现在, 我们已经知道如何通过一种不太完美的方式为对象添加属性. 还了解了怎么通过原型添加属性以避免前一种方法带来的缺陷: 令属性只存在于构造器的所创建的实例中, 但不存在于对象本身. 如果这种方式的继承可以更直接地实现, 同时不需要通过构造器设置对象原型, 那就更好了. 由于对象原型是个十分重要, 高度优化的特性, 利用原型创建新对象是实现这种继承唯一标准的方式. 也就是说, 你只有一次机会设置对象的原型, 就是在创建它的时候. 以下代码使用了ECMAScript 5的Object.create()方法创建了一个新对象, 该对象的原型是对象proto.

1
2
3
4
5
var proto = { bla: true };
var obj = Object.create(proto);
obj.foo = 123;
obj.bar = "abc";

新建的对象obj同时包含继承和自身的属性:

1
2
3
4
obj.bla
// true
obj.foo
// 123

ECMAScript 5 shim 则使用与以下类似的代码使Object.create兼容旧浏览器.

1
2
3
4
5
6
7
8
if (Object.create === undefined) {
Object.create = function (proto) {
function Tmp() {}
Tmp.prototype = proto;
// 创建原型为proto的新空对象
return new Tmp();
};
}

以上代码使用了一个临时函数构造器来创建含有特定原型的对象实例. 直到现在, 我们使用Object.create()时, 都没有考虑第二个参数, 在该参数中可以定义新建对象的属性:

1
2
3
4
var obj = Object.create(proto, {
foo: { value: 123 },
bar: { value: "abc" }
});

这些属性是通过property descriptors(Object.defineProperty()方法)定义的. 使用该方法, 不仅能定义属性和属性值, 还可以定义属性的描述符, 比如可枚举性. 现在让我们试着实现一个方法protoChain(), 它是Object.create()方法的简化版本. 不过该简化版本没有实现定义属性描述符的功能, 例:

1
2
3
4
var obj = protoChain(proto, {
foo: 123,
bar: "abc"
});

泛化上述protoChain方法:

1
protoChain(obj_0, obj_1, ..., obj_n-1, obj_n)

我们需要先创建全新的对象才可以为其添加原型. 因此, protoChain()返回obj_n的浅拷贝, obj_n的原型是obj_n-1的浅拷贝, 以此类推. obj_0是唯一一个未被拷贝的从chain返回的对象. 可以这样实现protoChain()方法:

1
2
3
4
5
6
7
8
9
10
function protoChain() {
if (arguments.length === 0) return null;
var prev = arguments[0];
for(var i=1; i < arguments.length; i++) {
// 通过原型 prev 创建 arguments[i] 的拷贝
prev = Object.create(prev);
extend(prev, arguments[i]);
}
return prev;
}

创建二级构造器

subtyping的意思是基于当前存在的构造器创建一个新的构造器. 新的构造器叫做二级构造器(sub-constructor), 当前存在的构造器是super-constructor. 下面的ColorPointPoint的二级构造器:

1
2
3
4
function ColorPoint(x, y, color) {
Point.call(this, x, y);
this.color = color;
}

以上代码为构造器ColorPoint将要创建的实例设置了属性x, ycolor. 这个功能是通过绑定this(ColorPoint的实例)到Point对象里实现的: Point作为函数被调用, 利用call()方法调用Point确保其在正确的执行环境下被调用. 这样的话, Point()函数为我们添加了xy属性, color属性则是由我们自己添加. 然后需要添加方法: 有一部分的方法希望从Point中继承, 有一些则想要自己定义, 可以通过extend()方法实现:

1
2
3
4
5
// function ColorPoint: 见上
extend(ColorPoint.prototype, Point.prototype);
ColorPoint.prototype.toString = function () {
return this.color+" "+Point.prototype.toString.call(this);
};

首先将Point.prototype上的方法拷贝到ColorPoint.prototype, 然后添加自己定义的方法: 以上代码中, 我们修改PointtoString()方法, 在toString()原来的结果前加上ColorPoint的color属性值. 更多调用super-prototype方法的信息详见参考[5]. 执行上述代码后, 调用ColorPointtoString()方法就能得出我们所预期的结果:

1
2
3
var cp = new ColorPoint(5, 3, "red");
cp.toString()
// 'red (5, 3)'

对代码进一步优化, 我们可以通过将Point.prototype设置为ColorPoint.prototype的原型避免添加过多属性到ColorPoint.prototype.

1
2
3
4
5
6
// function ColorPoint: 见上
ColorPoint.prototype = Object.create(Point.prototype);
ColorPoint.prototype.constructor = ColorPoint;
ColorPoint.prototype.toString = function () {
return this.color+" "+Point.prototype.toString.call(this);
};

首行代码中, 我们替换了ColorPoint.prototype的默认值, 因此需要在第二行代码中设置它的constructor属性值. 设置单一的constructor属性在概念上很容易理解, 但是手写代码的步骤较复杂, 因此可以通过辅助函数inherits()简化步骤:

1
2
3
4
5
// function ColorPoint: 见上
ColorPoint.prototype.toString = function () {
return this.color+" "+Point.prototype.toString.call(this);
};
inherits(ColorPoint, Point);

函数inherits()借鉴了Node.js的util.inherits()方法. 该方法能帮助我们创建二级类, 并且保持一般函数构造器的简洁. 使用inherits()时需要注意以下几点:

  • 不必在意添加方法到原型之前还是之后调用inherits()函数.
  • inherits()函数应该确保constructor属性设置正确.

下面是inherits方法的实现:

1
2
3
4
5
6
7
function inherits(SubC, SuperC) {
var subProto = Object.create(SuperC.prototype);
// At the very least, we keep the "constructor" property
// At most, we keep additions that have already been made
extend(subProto, SubC.prototype);
SubC.prototype = subProto;
};

关联父级属性

还有一个方法可以进行优化, ColorPoint.prototype.toString()实际上是调用以下函数:

1
Point.prototype.toString.call(this);

然而这并不理想, 因为我们写死了ColorPoint的父级构造器. 以下是更好的方式:

1
ColorPoint._super.toString.call(this);

为了使上述代码成立, inherits()函数执行以下赋值语句即可:

1
SubC._super = SuperC.prototype;

从这部分开始, inherits()方法与Node.js中的inherits()开始有所差异, 在Node.js中, SubC.super_指向SuperC. 而这里的ColorPoint构造器引用被写死, 指向Point. 想要避免这样的情况, 可以按照以下方式调用:

1
ColorPoint._super.constructor.call(this, x, y);

代码不是很简洁, 但确实达到了目的, 最后的ColorPoint是这样的:

1
2
3
4
5
6
7
8
function ColorPoint(x, y, color) {
ColorPoint._super.constructor.call(this, x, y);
this.color = color;
}
ColorPoint.prototype.toString = function () {
return this.color+" "+ColorPoint._super.toString.call(this);
};
inherits(ColorPoint, Point);

总结

本文相关内容源码GitHub地址: inheritance-by-example

延伸阅读

参考

  1. What’s up with the “constructor” property in JavaScript?
  2. JavaScript properties: inheritance and enumerability
  3. The pitfalls of using objects as maps in JavaScript
  4. es5-shim: use ECMAScript 5 in older browsers
  5. A closer look at super-references in JavaScript and ECMAScript.next