前端知识总结系列笔记四:js各种继承方式以及优缺点

225 阅读8分钟

前言

与原型和原型链一样,继承也是面试中的常考点之一,工作中也用得比较多~,子类继承自父类,可以共享父类里封装好的方法。本文尝试根据红皮书总结并整理各种继承方式的继承思想以及优缺点分析,最终选出相对最优的一种继承方案。

一、原型链继承

上文我们介绍了原型和原型链的关系,知道了访问一个对象的属性和方法是沿着原型链一层一层向上查找的,那么如果一个对象要继承另外一个对象原型上的属性和方法,一个最简单的方式就是让这个对象的原型指向另外一个对象。 因此,原型链继承实现的本质就是重写对象,代之以一个新类型的实例。

function Father() {
    this.fatherName = 'papa';
}

Father.prototype.getFatherName = function() {
    return this.fatherName;
}

function Son() {
    this.sonName = 'baby';
}

// 创建Father的实例,赋值给Son的原型,这样son就具有了Father构造函数上的属性,也能访问Father原型上的方法,这种就是原型链继承。
Son.prototype = new Father();

Son.prototype.getSonName = function() {
    return this.sonName;
}

const son = new Son();
son.getFatherName(); // Output:papa

原型链继承存在缺点:包含引用类型值的原型可能会被篡改

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {
}
// 继承SuperType
SubType.prototype = new SuperType();

const instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors); // ['red', 'blue', 'green', 'black'];

const instance2 = new SubType();
console.log(instance1.colors); // ['red', 'blue', 'green', 'black'];

原型链继承还存在另外一个问题:没有办法在不影响所有对象实例的情况下,向超类型的构造函数中传递参数。有鉴于此,加上刚才所说的包含引用类型值的原型可能被篡改,实际开发中,很少会单独使用原型链继承。

二、借用构造函数

为了解决原型链继承带来的问题,开发人员开始使用一种叫做借用构造函数(也叫伪造对象或经典继承)的技术。基本思想是在子类型构造函数内部调用超类型构造函数,复制超类型的实例属性给子类。

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}

function SubType() {
    // 继承了SuperType
    SuperType.call(this); // 这里是关键
}

const instance1 = new SubType();
instance1.colors.push('black');
console.log(instance1.colors); // ['red', 'blue', 'green', 'black'] 

const instance2 = new SubType();
console.log(instance2.colors); // ['red', 'blue', 'green']

核心代码是SuperType.call(this),创建子类实例时调用SuperType构造函数,于是复制了SuperType的属性colors给子类SubType,很好地解决了原型链继承中包含引用类型值的原型可能会被篡改的缺点。 除此之外,借用构造函数继承还解决了原型链继承的另外一个缺陷:无法向超类型的构造函数中传递参数。在子类型构造函数内部运用call()方法(或apply())的时候就可以给超类型的构造函数传递参数。

如果单单使用借用构造函数实现继承,也是会存在问题的:

  • 无法实现函数复用,只能继承父类的实例属性和方法,不能继承父类原型中定义的方法。
  • 子类的每个实例都有父类实例函数的副本,影响性能。

三、组合继承

以上两种继承方式都有各自的优缺点,原型链继承能继承父类原型上的属性和方法,借用构造函数继承能继承父类的实例属性和方法,组合以上的两种继承,即为组合继承。

其核心思想就是使用原型链实现对原型属性和方法的继承,通过借用构造函数实现对实例属性的继承。

function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayName = function () {
    alert(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() {
    alert(this.age);
}

const instance1 = new SubType('jiaxin', 16);
instance1.colors.push('black');
console.log(instance1.colors); // ['red', 'blue', 'green', 'black'];
instance1.sayName(); // jiaxin
instance1.sayAge(); // 16

const instance2 = new SubType('jingjing', 19);
console.log(instance2.colors); // ['red', 'blue', 'green']
instance2.sayName(); // 'jingjing'
instance2.sayAge(); // 19

优点:

融合了原型链继承和借用构造函数继承的优点,避免了他们的缺陷。

缺点: 在把SuperType父类的实例赋予给SubType子类的原型的时候,就在子类的原型链(proto)上继承了父类的属性,而子类的实例又在其内部继承了父类的属性和方法,相当于继承了两份相同的属性和方法,一份存在原型中,另一份存在子类实例的内部。

我们把实例打印出来可更直观的观察(由于属性遮蔽,其原型上的存在的同名属性/方法colors,name将不会被访问到):

四、原型式继承

核心思想:基于已有的对象创建一个新对象,该新对象的原型指向已有的对象,原理和es5的Object.create()大同小异。用new方式实现如下:

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

从本质上讲,object()对传入其中的对象执行了一次浅复制。

const person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

// 创建了一个新对象anotherPerson,其原型指向person
const anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);   // ['Shelby', 'Court', 'Van', 'Rob', 'Barbie']

由上述代码可以看出,原型式继承的缺点跟原型链继承是一样的:

  • 多个实例会共享原型上的属性/方法,如果该属性/方法包含引用类型,则存在被篡改的可能。
  • 无法传递参数。

五、寄生式继承

其核心思想是在原型式继承的基础上,以某种方式来增强对象,返回对象。

function createAnother(original) {
    const clone = object(original);
    clone.sayHi = function() {
        console.log('hi~');
    };
    return clone;
}

该函数的作用是给返回的对象新增属性或方法,以增强函数。

const person = {
    name: 'Nicholas',
    friends: ['Shelby', 'Court', 'Van']
};

const anotherPerson = createAnother(person);
anotherPerson.sayHi(); // hi~

缺点(同原型式继承一样):

  • 原型上的属性/方法存在篡改的可能;
  • 无法传递参数,无法做到函数复用而降低效率。

六、寄生组合式继承

核心思想:寄生式继承和组合继承的结合,即通过借用构造函数继承属性,通过原型链的混合形式来继承方法。

前面提到,组合式继承最大的缺点就是会调用两次超类型构造函数,在new一个子类实例时,copy了两份一样的数据,一份存在实例中,一份存在实例的原型上,因此原型中的同名属性就会被屏蔽。而解决这个问题的方法就是寄生组合式继承。

下面的inheritPrototype函数是实现寄生组合是继承的最简单形式。

function inheritPrototype(subType, superType) {
    const prototype = Object(superType.prototype); // 创建对象,创建超类型原型的一个副本
    prototype.constructor = subType; // 增强对象,弥补因重写原型而失去的默认的constructor属性
    subType.prototype = prototype; // 指定对象,将新创建的对象赋值给子类型的原型
}

使用:

function SuperType(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green'];
}

SuperType.prototype.sayName = function() {
    console.log(this.name);
}

// 借用构造函数传递增强子类实例属性
function SubType(name, age) {
    // 继承父类的属性/方法,此处指name、colors(支持传参、避免篡改)
    // 只调用了一次SuperType构造函数
    SuperType.call(this, name);
    this.age = age;
}

// 继承父类的原型上的方法/属性,此处指sayName
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
    console.log(this.age);
}

const instance1 = new SubType('jiaxin', 16);
instance1.colors.push('pink');

const instance2 = new SubType('xiaohua', 30);
console.log(instance2.colors); // ['red', 'blue', 'green']

我们把子类的实例(instance1)打印出来看看:

这种继承方式的高效率体现在它只调用了一次SuperType构造函数,因此避免了再SubType.prototype上创建不必要的、多余的属性。与此同时,原型链还能保持比变,还能够正常使用instanceof和isPrototypeOf()。因此寄生组合继承是引用类型最理想的继承方式。

七、类Class继承

ES6引入了Class的概念,Class继承通过关键字extends实现。

class SuperType {
    // 定义父类的实例方法/属性
    constructor(name, age) {
        this.name = name;
        this.age = age;
        this.colors = ['red', 'blue', 'green'];
    }
    // methods,定义父类原型上的方法
    sayHello() {
        console.log('hello~');
    }
}
class SubType extends SuperType {
    constructor(name, age, gender) {
        // super在这相当于SuperType.prototype.constructor.call(this, name,age)
        super(name, age);//super方法调用父类实例,只有调用super,才可使用this关键字
        this.gender = gender;
        super.sayHello();
    }
    
    // methods, 定义在子类原型上的方法
    sayAge() {
        console.log(this.age);
    }
}
const instance1 = new SubType('jiaxin', 16, 'female');
instance1.sayHello(); // hello~
instance1.sayAge(); // 16
instance1.colors.push('pink');
instance1.colors; // ['red', 'blue', 'green', 'pink'];

const instance2 = new SubType('xiaohua', 30, 'female');
instance2.colors; // ['red', 'blue', 'pink']

我们把子类的实例(instance1)打印出来看看:

因此,我们可看到,ES6类继承其实跟ES5的寄生组合式继承差不多,子类的多个实例共享父类的引用类型值时,可避免被篡改。

区别是ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

八、Mixin模式实现多个对象的继承

Mixin 指的是多个对象合成一个新的对象,新对象具有各个组成成员的接口。 简单实现就是使用Object.assign();

const a = {
  a: 'a'
};
const b = {
  b: 'b'
};
const c = Object.assign(a, b); // {a: 'a', b: 'b'}

下面是一个比较完备的实现:

function mix(...mixins) {
  class Mix {
    constructor() {
      for (let mixin of mixins) {
        copyProperties(this, new mixin()); // 拷贝实例属性
      }
    }
  }

  for (let mixin of mixins) {
    copyProperties(Mix, mixin); // 拷贝静态属性
    copyProperties(Mix.prototype, mixin.prototype); // 拷贝原型属性
  }

  return Mix;
}

function copyProperties(target, source) {
  for (let key of Reflect.ownKeys(source)) {
    if ( key !== 'constructor'
      && key !== 'prototype'
      && key !== 'name'
    ) {
      let desc = Object.getOwnPropertyDescriptor(source, key);
      Object.defineProperty(target, key, desc);
    }
  }
}

上面代码的mix函数,可以将多个对象合成为一个类。使用的时候,只要继承这个类即可。

class Child extends mix(Parent, GrandParent) {
  // TO DO
}

参考:

《javascript高级程序设计》6.3 继承

阮一峰ECMAScript 6 入门 class的继承

木易杨说 JavaScript常用八种继承方案