JavaScript中实现继承的方式总结

我们在对象创建模式中讨论过,对象创建的模式就是定义对象模板的方式。有了模板以后,我们就可以轻松地创建多个结构相同的对象了。
继承就是对象创建模式的扩展,我们需要在旧模板的基础上,添加新的特性,使之成为一个新的模板。

为了更加迎合程序员的“直觉”,本篇文章有时使用“父类”来指代“父对象的模板”。但是读者应该明确:JavaScript没有类系统。

1. 纯原型链继承

纯原型链继承的思想就是让所有需要继承的属性都在原型链上(而不会在实例对象自己身上)。
原型链是实现继承的重要工具,以下是单纯使用原型链实现继承的方式:

function SuperType() {
    this.property = 1;
}
// 将方法放在原型上,以便所有实例共用,避免重复定义
SuperType.prototype.getSuperValue = function() {
    return this.property;
};

function SubType() {
    this.subproperty = 2;
}
SubType.prototype = new SuperType();
// 将方法放在原型上,以便所有实例共用,避免重复定义
SubType.prototype.getSubValue = function() {
    return this.subproperty;
};
var instance = new SubType();
console.log(instance.getSuperValue());  // 1
console.log(instance instanceof SubType);   // true
console.log(instance instanceof SuperType); // true

虽然上面使用了构造函数与new关键字,但是在纯原型链继承中,继承属性的原理与它们一点关系也没有,父类的所有属性都在原型链上。使用构造函数只是为了代码简洁,同时还可以让我们使用instanceof进行对象识别(详见上一篇文章)。
实际上,我们可以不使用构造函数与new关键字来实现纯原型链继承:

// 定义父对象的原型
var superPrototype = {
    getSuperValue: function() {
        return this.property;
    }
};
// 定义父对象的工厂方法
function createSuper() {
    var superInstance = Object.create(superPrototype);
    superInstance.property = 1;
    return superInstance;
}
// 定义子对象的原型
var subPrototype = createSuper();
subPrototype.getSubValue = function() {
    return this.subproperty;
};
// 定义子对象的工厂方法
function createSub() {
    var subInstance = Object.create(subPrototype);
    subInstance.subproperty = 2;
    return subInstance;
}
// 创建子对象
var instance = createSub();
console.log(instance.getSuperValue());  // 1
console.log(instance.getSubValue());  // 2

通过这种方式实现纯原型链继承的作用完全相同,原型链也完全相同,只不过不能使用instanceof和constructor指针了(因为它们需要构造函数)。

所有需要继承的属性都在原型链上(而不会在实例对象自己身上),这是纯原型链继承与后面继承方式的最大不同。

纯原型链继承的缺陷

  • 在使用构造函数的实现方式中,我们无法给父类的构造函数传递参数。也就是上例中的这条语句:SubType.prototype = new SuperType();,这句话是在创建子对象之前就执行的,所以我们无法在实例化的时候决定父类构造函数的参数。
  • 如果不选择构造函数的那种实现方式,会出现与创建对象模式相同的对象识别问题。
  • 原型链的改变会影响所有已经构造出的实例。这是一种灵活性,也是一种危险。如果我们不小心通过实例对象改变了原型链上的属性,会影响所有的实例对象。
  • 父类可能有一些属性不适合共享,但是纯原型链继承强迫所有父类属性都要共享。比如People是Student的父类,People的name属性显然是每个子对象都应该独享的。如果使用纯原型链继承,我们必须在每次得到子类对象以后手动给子对象添加name属性。

2. 纯构造函数继承

这种技术通过在子类构造函数中调用父类的构造函数,使所有需要继承的属性都定义在实例对象上

function SuperType(fatherName) {
    this.fatherName = fatherName;
    this.fatherArray = ["red", "blue", "green"];
}

function SubType(childName, fatherName) {
    SuperType.call(this, fatherName);
    this.childName = childName;
}
var child1 = new SubType('childName1', 'fatherName1');
var child2 = new SubType('childName2', 'fatherName2');

child1.fatherArray.push("black");
console.log(child1.fatherArray); //"red,blue,green,black"
console.log(child2.fatherArray); //"red,blue,green"

由上例可知,纯构造函数继承不会出现纯原型链继承的问题:

  • 不存在“原型链的改变影响所有已经构造出的实例”的问题。这是因为不管是子类的属性还是父类的属性,在子对象上都有一个副本,因此改变一个对象的属性不会影响另一个对象。
  • 可以在构造子对象实例的时候给父类构造函数传递参数。在纯构造函数继承中,父类构造函数是在子类构造函数中调用的,每次调用时,传递给父构造函数的参数可以通过子构造函数的参数控制。

纯构造函数继承的缺陷

  • 创建对象的构造函数模式相同,低效率,函数重复定义,无法复用。
  • 如果子类要使用这种继承方式,父类必须也要使用这种继承方式。因为使用这种方式的前提就是所有需要继承的属性都在父构造函数上。如果父类有一些属性是通过原型链继承来的,那么子类仅仅通过调用父构造函数无法得到这些属性。
  • 对象识别功能不全。无法使用 instanceof 来判断某个对象是否继承自某个父类。但是还是有基本的对象识别功能:可以使用 instanceof 来判断某个对象是不是某个类的实例。

3. 组合继承

组合继承就是同时使用原型链和构造函数继承:

  • 对于那些适合共享的属性(一般是函数),将它们放在原型链上。
  • 对于需要每个子对象独享的属性,在构造函数中定义。
// 定义父类
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
    console.log(this.name);
};

// 定义子类
function SubType(name, age) {
    // 这里调用了父对象的构造函数(构造函数继承)
    SuperType.call(this, name);
    this.age = age;
}
// 这里创建了一个父对象来当作原型(原型链继承)
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
    console.log(this.age);
};

var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);

instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29

console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27

使用原型链的时候,我们要作出合理的选择:

  • 哪些属性是每个实例对象独享的,需要在每次实例化的时候添加到实例对象上
  • 哪些属性是所有实例共享的,只需要定义在原型对象上。这可以减少资源的浪费。

组合继承的代码与纯原型链继承的代码看起来很相似,它们的核心区别在于:组合继承要在子类构造函数中调用父类构造函数。

组合继承避免了两者的缺陷,结合了两者的优点,并且可以使用instanceof进行对象识别,因此组合继承在JavaScript中经常被使用。

组合继承的缺陷

  • 得到一个子对象要执行两次父构造函数,重复定义属性。在组合继承中,我们要执行两次构造函数
    1. 在定义原型链的时候创建原型对象
    2. 在子类的构造函数中调用父构造函数来初始化新对象的属性。

调用两次构造函数的结果就是重复定义了父类的属性,第一次定义在原型链上,第二次定义在子对象上。

如果你在Chrome控制台中打印上面例子的instance2,你会发现,在原型对象和子对象上都有"name"和"colors"属性。


这样的重复定义显然不够优雅,后面的寄生组合式继承解决了这个问题。

4. 原型式继承

原型式继承的核心是以父对象为原型直接创建子对象
原型式继承(Prototypal Inheritance)是由Douglas Crockford在他的一篇文章Prototypal Inheritance in JavaScript中提出的。js是一种基于原型的语言,然而Douglas却发现,当时没有操作符能够方便地以一个对象为原型,创建一个新对象。js的原型的天性被构造函数给掩盖了,因为构造函数被很多人当作“类”来使用。因此Douglas提出一个简单的函数,以父对象为原型直接创建子对象,没有类、没有构造函数、没有new操作符,只有对象继承对象,回归js的本质:

function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}

在原型式继承被提出的时候,Object.create方法还没有引入。ES5引入的Object.create()方法实际就是object函数的规范化。

有了object函数以后,为了能够方便地创建对象,你可能需要使用工厂模式,来为得到的子对象增加属性:

// 父对象
var superObject = {
    superValue:1,
    showSuperValue: function() {
        console.log(this.superValue);
    }
};

// 子对象的工厂方法
function createSub() {
    var instance = Object.create(superObject);
    instance.subValue = 2;
    return instance;
}

var subObject = createSub();
subObject.showSuperValue();  // 1

我们有时不一定要大量制造子对象,此时就没必要定义一个工厂函数了,直接使用Object.create()就好。原型式继承不一定要定义一个工厂函数,只要以父对象为原型直接创建子对象,就是一种原型式继承

原型式继承是一种纯原型链继承,因为原型式继承也将所有要继承的属性放在了原型链上,我们在纯原型链继承中的第二种实现方式,实际上也是原型式继承。

如果要给子对象添加函数,不要在工厂函数中定义它,而要将函数放在原型对象上,避免低效率代码。我们在纯原型链继承中的第二种实现方式就是一个值得学习的例子。

5. 寄生式继承

寄生式继承的思想是增强父对象,得到子对象。新特性“寄生”在父对象上。
寄生式继承使用工厂函数封装了以下步骤:

  1. 使用父对象的创建方法(工厂函数或构造函数),创建父对象
  2. 增强父对象(给它增加属性)
  3. 返回这个对象,它就是子对象
function Super() {
    this.superProperty = 1;
}
Super.prototype.sayHi = function() {
    console.log('hi');
};


function subFactory() {
    var instance = new Super();
    instance.subProperty = 2;
    instance.sayGoodBye = function() {
        console.log('goodbye');
    };
    return instance;
}

var sub = subFactory();

继承得到的属性可能在子对象上,也可能在子对象的原型上,这取决于父对象的属性在不在原型链上。

寄生式继承的缺陷

  • 如果在工厂函数中为对象添加函数(如上面的例子),那么会出现与纯构造函数继承一样的低效率问题,函数重复定义。
  • 不能使用进行对象识别,因为子对象并不是通过构造函数创建的。

6. 寄生组合式继承

我们前面说过,虽然组合继承很常用,但是它也有自己的不足:调用两次父构造函数,重复定义父属性。第一次是在定义原型链的时候创建原型对象,第二次是在子类的构造函数中调用父构造函数来初始化新对象的属性。以下是我们给出过的组合式继承

// 定义父类
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
    console.log(this.name);
};

// 定义子类
function SubType(name, age) {
    // 第二次调用父构造函数
    SuperType.call(this, name);
    this.age = age;
}
// 第一次调用父构造函数
SubType.prototype = new SuperType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
    console.log(this.age);
};

var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);
// instance的本身和原型对象上都有“name”和“colors”属性

instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29

console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27

我们显然应该舍弃其中的一次。
可以舍弃第二次调用吗?不行,那样就变成纯原型链继承了,你可以回去看看这种继承的缺陷。
既然如此,我们只能舍弃第一次调用了。第一次调用构造函数只是为了获得父对象原型链上的属性,父对象实例上的属性交给第二次调用来添加了。实际上我们不需要创建一个父对象也可以获得父对象的原型链:

也就是说,我们可以把
SubType.prototype = new SuperType();
改成
SubType.prototype = Object.create(SuperType.prototype);
创建上图中的蓝色空对象,并将它作为子类的原型。子对象一样可以继承到父对象原型链上的所有属性

因此,寄生组合式继承的代码就是这样:

// 定义父类
function SuperType(name) {
    this.name = name;
    this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
    console.log(this.name);
};

// 定义子类
function SubType(name, age) {
    // 获得父对象实例上的属性
    SuperType.call(this, name);
    this.age = age;
}
// 获得父对象原型链上的属性
SubType.prototype = Object.create(SuperType.prototype);
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
    console.log(this.age);
};

var instance1 = new SubType("Nicholas", 29);
var instance2 = new SubType("Greg", 27);

instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
instance1.sayName(); //"Nicholas";
instance1.sayAge(); //29

console.log(instance2.colors); //"red,blue,green"
instance2.sayName(); //"Greg";
instance2.sayAge(); //27

现在我们在Chrome控制台查看instance2的原型,发现原型链上已经没有“name”和“colors”属性了。寄生组合式继承十分完美!

为什么不直接SubType.prototype = SuperType.prototype;呢?因为我们后面还要在SubType.prototype上添加子对象的共享函数,如果使用Object.create创建一个新对象,会改变父对象的原型链!


寄生组合式继承真的与“寄生式继承”有关吗?对于这一点我持怀疑态度。按照《JavaScript高级程序设计(第3版)》的说法,因为继承的过程可以封装成以下函数:

function inheritPrototype(subType, superType) {
    var prototype = Object.create(superType.prototype); // 创建对象
    prototype.constructor = subType; // 增强对象
    subType.prototype = prototype; // 指定对象
}

而作者认为这个函数是一种“寄生式继承”:prototype“寄生式继承”自superType.prototype。

我认为这是一种原型式继承。不能因为在工厂函数中获取对象、增强对象,就认为它是寄生式继承。原型式继承一样可以用工厂函数来封装。原型式继承与寄生式继承的区别在于获取对象的方式

  • 通过object函数或Object.create,以父对象为原型直接创建一个空对象。这是原型式继承。
  • 通过父类的构造函数或工厂函数获得对象。这是寄生式继承。

在Douglas Crockford的文章Prototypal Inheritance in JavaScript中,在介绍原型式继承的时候他说到:
"For convenience, we can create functions which will call the object function for us, and provide other customizations such as augmenting the new objects with privileged functions. I sometimes call these maker functions. If we have a maker function that calls another maker function instead of calling the object function, then we have a parasitic inheritance pattern."
原型式继承一样可以使用工厂函数。当调用另一个工厂函数而不是object函数的时候,原型式继承才变成寄生式继承。

名字叫什么并不重要,能够将这些模式中的编程思想融会贯通就够了。将来我们不一定有机会写“形式完全规范”的某种模式,但是其中的思想肯定会在一些零零散散的地方用上。

推荐阅读更多精彩内容