zhouweicsu

JavaScript 基础:继承

基础概念

要了解 JavaScript 继承,需要先弄明白相关的4 个概念,3 个关系,3 个属性以及 1 个操作符。

4 个概念

构造函数:任何函数,只要能通过 new 操作符来调用,那它就可以作为构造函数;构造函数本质就是一个函数;
原型对象:通过调用构造函数而创建的那个对象实例的原型对象;该对象包含由特定类型的所有实例共享的属性和方法;
实例:通过 new 操作符调用构造函数得到的对象;
原型链:每个构造函数都有一个原型对象,我们如果将这个原型对象改为另一个对象的实例,就会形成原型链。

P.S. 原型链后面会详细讲解,所以没有理解没关系。

代码示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Point(x, y) { //构造函数
this.x = x || 10;
this.y = x || 10;
this.show = function() {
console.log('x: ' + this.x + ', y:' + this.y);
}
}
function ColorPoint(color) {
this.color = color || 'red'
}
// ColorPoint 继承了 Point
// ColorPoint.prototype 原型对象
// new Point() 实例
ColorPoint.prototype = new Point();

3 个关系

3 个关系是构造函数,原型对象与实例之间的关系:

  • 每个构造函数都有一个原型对象
  • 原型对象都包含一个指向构造函数的指针
  • 每个实例都包含一个指向原型对象的内部指针

根据代码示例 1 中 Point 类的定义,我们可以得到如下关系图:

Point 类构造函数,原型对象与实例之间的关系图

图 1:Point 类构造函数,原型对象与实例之间的关系图

原型链:构造函数 Point 和 ColorPoint,我们知道这两个构造函数都会有默认的原型对象。上面我们说到如果我们将 ColorPoint 的原型对象改为 Point 的实例,我们就会得到原型链。那原型链是如何形成的呢?就是通过原型对象与实例之间的这个关系形成的。具体形成过程我们需要再了解一下关系中涉及到的 3 个属性。

3 个属性

3 个属性与 3 个关系相关联:prototypeconstructor__proto__

  • prototype:构造函数中指向原型对象的指针
  • constructor:原型对象中指向构造函数的指针
  • __proto__:实例中指向原型对象的内部指针

根据代码示例 1 中 Point 与 ColorPoint 的定义,我们可以得到如下关系图:

ColorPoint、Point、Object 之间的关系图

图 2:ColorPoint、Point、Object 之间的关系图

原型链:本来构造函数 Point 和 ColorPoint 是两个独立的函数,之间没有关系。如果我们将 ColorPoint 的原型对象重写,即 ColorPoint.prototype = new Point()。那 prototype 属性就指向了 Point 的实例,而从上面我们已经知道了每个实例都有指向原型对象的内部指针 __proto__,到这里,我们可以得到一个原型链:

  1. ColorPoint 的实例中 __proto__ 指向 ColorPoint 的原型对象,即 Point 的实例;
  2. 而 Point 的实例也有一个 __proto__ 指向 Point 的原型对象;
  3. 而 JavaScript 中任何对象都是继承自 Object,所以 Point 的原型对象也有一个 __proto__ 指针指向 Object 的原型对象;
  4. 直到 Object 的原型对象的 __proto__ 被指向 null;
  5. 根据定义,null没有原型,并且作为这个原型链 prototype chain 中的最终链接。

通过 __proto__ 指针所形成这个实例与原型的链条就是原型链,即图 2 中蓝色链条,基于原型链的属性和方法查找就是按照这个蓝色链条层层往上的。

1 个操作符

前面说过,new 操作符是区别构造函数与普通函数的关键,我们看一下new 操作符执行的步骤:

  • 创建一个新对象;
  • 将构造函数的作用域赋给新对象(因此 this 就指向了这个新对象);
  • 执行构造函数中的代码(为这个新对象添加属性);
  • 返回新对象。

继承的方法

继承的本质就是通过重写原型对象实现原型链,使得子类型可以通过原型链找到父类型中的属性和方法。有了上面的基础,我们看一下 JavaScript 中如何实现继承,讨论每种方法的优缺点。

基于原型链

代码实例 1 中通过重写 ColorPoint 的原型对象,形成一条原型链实现了继承。原来存在于 Point 的实例中的属性 xy 和方法 show 现在也存在于 ColorPoint.prototype 中。但这种实现存在两个问题:

  • 父类中如果存在引用类型的属性(例如数组),那么子类的所有实例都会共享这个属性;
    比如,如果 Point 中有一个数组属性 shapes,那在所有的 ColorPoint 的实例都会共享这个数组 shapes,任意一个实例修改 shapes,都会影响其他实例。

代码示例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Point(x, y) { //构造函数
this.x = x || 10;
this.y = x || 10;
this.shapes = ['square','rectangle'];
this.show = function() {
console.log('x: ' + this.x + ', y:' + this.y);
}
}
function ColorPoint(color) {
this.color = color || 'red'
}
// ColorPoint 继承了 Point
ColorPoint.prototype = new Point();
var cp1 = new ColorPoint();
cp1.shapes.push('circle');
console.log(cp1.shapes); // ["square", "rectangle", "circle"]
var cp2 = new ColorPoint();
// cp2 被影响了
console.log(cp2.shapes); // ["square", "rectangle", "circle"]

  • 在创建子类型的实例时,不能向父类型的构造函数传递参数。
    我们还看到,在实现继承的过程中,我们直接将 new Point() 替换了 ColorPoint 的原型。在 new 的过程中没有传入任何参数,即使我们传入参数,也把子类 ColorPoint 的 x 和 y 属性固定死了。这会导致 ColorPoint 在创建实例时,无法传递 x 和 y 属性。

所以原型链这种方式只是实现了继承而已,并不能满足实际工作的需求。

使用 call 和 apply —— 构造函数绑定

为了解决上面说的两个问题,我们可以通过 call() 和 apply() 在子类的构造函数中将 this参数传递给父类构造函数。

代码示例 3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Point(x, y) { //构造函数
this.x = x || 10;
this.y = x || 10;
this.shapes = ['square','rectangle'];
this.show = function() {
console.log('x: ' + this.x + ', y:' + this.y);
}
}
function ColorPoint(x, y, color) {
Point.call(this, x, y);
this.color = color || 'red';
}
var cp1 = new ColorPoint();
cp1.shapes.push('circle');
console.log(cp1.shapes); // ["square", "rectangle", "circle"]
cp1.show() //x: 10, y: 10
var cp2 = new ColorPoint(100, 100);
console.log(cp2.shapes); // ["square", "rectangle"]
cp1.show() //x: 100, y: 100

虽然解决了原型链中的两个问题,但这种借用构造函数的方法还是存在其他问题,就是函数复用的问题。父类 Point 中的 show 方法无法复用,子类 ColorPoint 的所有实例都会重新创建 show 方法。所以这种继承方式在实际工作中也是很少用的。

组合继承 —— 最常用

为了解决上一小节中函数复用的问题,我们可以借用原型对象中的属性是共享这一特性。思路是使用原型链实现对原型属性和方法的继承,而通过借用构造函数来实现对实例属性的继承。

代码示例 4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Point(x, y) { //构造函数
this.x = x || 10;
this.y = x || 10;
this.shapes = ['square','rectangle'];
}
Point.prototype.show = function() {
console.log('x: ' + this.x + ', y:' + this.y);
}
function ColorPoint(x, y, color) {
Point.call(this, x, y); // 继承属性
this.color = color || 'red';
}
ColorPoint.prototype = new Point(); // 继承方法
ColorPoint.prototype.showColor = function() {
console.log('color: ' + this.color);
}
var cp1 = new ColorPoint();
cp1.shapes.push('circle');
console.log(cp1.shapes); // ["square", "rectangle", "circle"]
cp1.show(); //x: 10, y:10
cp1.showColor(); //color: red
var cp2 = new ColorPoint(100, 100, 'yellow');
console.log(cp2.shapes); // ["square", "rectangle"]
cp2.show(); //x: 100, y:100
cp2.showColor(); //color: yellow

根据代码示例 4 ,我们可以得到如下关系图:

ColorPoint、Point、Object 之间新的关系图

图 3:ColorPoint、Point、Object 之间新的关系图

这种继承的实现方式,解决了前两个小节中出现的问题,但也有一些小瑕疵,就是父类的构造函数会被调用两次:一次是创建子类型的原型对象, 还有一次是子类型的构造函数中的 call。虽然存在这个问题,但影响不是特别大,所以这种方式还是 JavaScript 最常用的继承模式。

Class extends

ES6 提供了更接近传统面向对象语言的继承写法,引入了 Class 的概念, Class 之间可以通过 extends 继承。

代码示例 5:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point{
constructor(x,y) {
this.x = x;
this.y = y;
}
}
class ColorPoint extends Point {
constructor(x,y,color) {
super(x, y);
this.color = color;
}
}
var p1 = new Point(2,3);
var p2 = new ColorPoint(3,4, 'green');

这段代码中各个类之间的原型链图可以参考另一篇博客《图解 JavaScript 中的 __proto__ 与 prototype》

ES6 虽然有 Class 的概念,但是其本质还是基于原型链的,我们可以把 Class 看作一个语法糖。ES6 中关于 Class 的关键字除了 class 和 extends,还新增了 constructor, static 和 super。关于 Class 的详细知识可以参考阮一峰老师的《ES6 标准入门》中的 Class 章节

总结

除了上面提到的继承方法,大神 Douglas Crockford 还推荐了两种实现继承的方法: 原型式继承寄生式继承。还有 YUI 的 YAHOO.lang.extend() 采用的基于寄生式继承和组合继承的 寄生组合式继承,该继承方法可以解决组合继承中父类构造函数被调用两次的问题。这三种继承方法在红宝书《JavaScript 高级程序设计》第 6 章中都有详细介绍。

书该读还是得读,常读常新,需要了解的基础都在里面。

参考文章