掌握JavaScript继承实现方案

235 阅读8分钟

很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。因为JavaScript函数没有签名,所以实现继承时JavaScript唯一支持的继承方式,而这主要是通过原型链实现的。

1. 原型链继承

构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的指针。

原型链继承的核心将父类的实例作为子类的原型

// 父类
function Animal() {
  this.superValue = 'animal';
  this.colors = ['red', 'green', 'black']
}

Animal.prototype.getSuperValue = function() {
  return this.superValue;
}

// 子类
function Cat() {
  this.subValue = 'cat'
}

// 关键: 创建Animal实例,并把该实例赋值给Cat.prototype
// 相当于Cat.prototype__proto__ = Animal.prototype
Cat.prototype = new Animal();

Cat.prototype.getSubValue = function() {
  return this.subValue;
}

const instance1 = new Cat();
console.log(instance1.getSuperValue()); // animal
console.log(instance1.colors); // [ 'red', 'green', 'black' ]
instance1.colors.push('pink');
console.log(instance1.colors); // [ 'red', 'green', 'black', 'pink' ]

const insatnce2 = new Cat();
console.log(instance1.colors); // [ 'red', 'green', 'black', 'pink' ]

// 子类原型上的constructor属性被重写了,不再指向Cat
console.log(Cat.prototype.constructor === Cat); // false
console.log(Cat.prototype.constructor === Animal); // true

image.png 原型继承方案的特点:

  • 非常纯粹的继承关系,实例是子类的实例,也是父类的实例
  • 父类新增原型方法/属性,子类都能访问到
  • 简单,易于实现

原型链继承方案的缺点:

  1. 来自原型对象的引用属性会被所有实例共享,如果原型对象的属性中有引用类型,在一个实例中修改引用类型的值,在其他实例中也会被修改。
  2. 子类型的原型上的construtor属性被重写了
  3. 给子类原型添加属性和方法必须要早替换原型之后
  4. 创建子类实例时无法向父类构造函数传参

2. 借用构造函数继承

借用构造函数继承的基本思路在子类构造函数的内部调用父类构造函数

本质上是使用父类的构造函数来增强子类实例,等同于复制父类的实例给子类(不使用原型)

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

function Cat() {
  // 继承Animal
  Animal.call(this); // this指向Cat对象(Cat构造函数),执行之后会在Cat对象初始化colors属性
}

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

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

核心代码是Animal.call(this),创建子类实例时调用Animal构造函数,于是Cat的每个实例都会将Animal中的属性复制一份。

借用构造函数继承的特点:

  • 解决了原型链继承中,子类实例共享父类引用属性的问题
  • 创建子类实例时,可以向父类传递参数
  • 可以实现多继承(call多个父类对象)

借用构造函数继承的缺点:

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

3. 组合继承

组合上述两种方法就是组合继承。

核心:使用原型链实现对原型属性和方法的继承,通过借用构造函数实现对实例属性的继承

// 组合继承
function Animal(name) {
  this.name = name;
  this.colors = ['red', 'green', 'blue']
}

Animal.prototype.sayName = function() {
  return this.name;
}

function Cat(name, age) {
  // 继承Animal属性,第二次调用Animal
  Animal.call(this, name);
  this.age = age;
}

// 继承Animal原型上的方法,第一次调用Animal
Cat.prototype = new Animal();
// 重写Cat.prototype的constructor属性,指向自己的构造函数Cat
Cat.prototype.constructor = Cat;
Cat.prototype.sayAge = function(){
  return this.age;
};

const instance1 = new Cat('doudou', 3)
instance1.colors.push('black');
console.log(instance1.colors); // [ 'red', 'green', 'blue', 'black' ]
console.log(instance1.sayName()); // doudou
console.log(instance1.sayAge()); // 3

const instance2 = new Cat('maomao', 1);
console.log(instance2.colors); // [ 'red', 'green', 'blue' ]
console.log(instance2.sayName()); // maomao
console.log(instance2.sayAge()); // 3

组合继承的缺点:

  • 调用两次父类构造函数,一是在创建子类原型的时候,另一次是在子类型构造函数的内部。

4. 原型式继承

原型式继承的基本思路:利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型,返回控对象构造函数的实例

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

// object()对传入其中的对象执行了一次浅复制
const person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

const anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

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

console.log(person.friends);   //"Shelby,Court,Van,Rob,Barbie"

原型式继承的缺点:

  1. 来自原型对象的引用属性会被所有实例共享,如果原型对象的属性中有引用类型,在一个实例中修改引用类型的值,在其他实例中也会被修改。
  2. 无法传递参数

ES5中存在Object.create()的方法,能够代替上面的object方法。

5. 寄生式继承

核心:在原型式继承的基础上,增强对象

function createAnother(original){
  var clone = object(original); // 通过调用 object() 函数创建一个新对象
  clone.sayHi = function(){  // 以某种方式来增强对象
    console.log("hi");
  };
  return clone; // 返回这个对象
}

onst person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};
const anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"

寄生式继承的缺点同原型式继承

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 = Object.create(SuperType.prototype);
SubType.prototype.constructor = SubType;

// 新增子类原型属性
SubType.prototype.sayAge = function(){
  console.log(this.age);
}

const instance1 = new SubType("doudou", 3);
const instance2 = new SubType("maomao", 1);

instance1.colors.push('black')
console.log(instance1.colors) // ["red", "blue", "green", "black"]
console.log(instance2.color) // ["red", "blue", "green"]

这个例子的高效率体现在它只调用了一次SuperType构造函数,并且因此避免了在SubType.prototype上创建不必要的、多余的属性。于此同时,原型链还能保持不变;因此,还能够正常使用instanceofisPrototypeOf()

这是最成熟的方法,也是现在库实现的方法

7. ES6 extends继承

前提知识

/* 类的特点
 * 1. 类的所有方法都定义在类的prototype属性上;类的新方法可以添加在prototype对象上面。
 * 2. 类内部所有定义的方法,都是不可枚举的,这一点与ES5的行为不一致。
 * 3. 一个类必须有constructor()方法,如果没有定义,将会默认添加一个空的constructor()方法。
 * 4. constructor()方法默认返回实例对象(即this),可以指定返回另一个对象。
 * 5. 类必须使用new调用, 否则会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行。
 * 6. 类不存在变量提升,这一点与ES5完全不同,这种规定的原因与继承有关,必须保证子类在父类之后定义。
 * 7. 在类中,在一个方法前,加上static关键字,表示该方法不会被实例继承,而是直接通过类来调用,这就成为“静态方法”。
 *    如果静态方法包含this关键字,这个this指的是类,而不是实例;父类的静态方法可以被子类继承; 静态方法也是可以从super对象上调用的。
 * 8. 实例属性除了定义在constructor()方法里面的this上面,也可以定义在类的最顶层。
 * 9. 目前有一个提案,为class加了私有属性。方法是在属性名之前,使用#表示
 * 10. 子类继承父类时,new.target会返回子类。
*/
  • ES5继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.call(this))
  • ES6的继承机制完全不同,实质上是先将父类实例对象的属性和方法,加到this上(所以必须调用super()), 然后再用子类的构造函数修改this

类主要通过extends关键字来实现继承,使用示例如下:

class SuperType {
  constructor(name) {
    this.name = name
    this.colors = ["red", "blue", "green"]
  }

  sayName() {
    return this.name
  }
}

class SubType extends SuperType {
  constructor(name, age) {
    // 继承父类的属性
    super(name)
    this.age = age
  }
  sayAge() {
    return this.age
  }
}

const instance1 = new SubType("doudou", 3)
const instance2 = new SubType("maomao", 1)
console.log(instance1.sayName()) // doudou
instance1.colors.push("2")
console.log(instance1.colors) // ["red", "blue", "green", "2"]
console.log(instance2.colors) // ["red", "blue", "green"]

extends继承的核心代码如下,其实现和上述的寄生组合式继承方式一样

function _inherits(subType, superType) {
    // 创建对象,创建父类原型的一个副本
    // 增强对象,弥补因重写原型而失去的默认的constructor 属性
    // 指定对象,将新创建的对象赋值给子类的原型
    subType.prototype = Object.create(superType && superType.prototype, {
        constructor: {
            value: subType,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    
    if (superType) {
        Object.setPrototypeOf ? Object.setPrototypeOf(subType, superType) : subType.__proto__ = superType;
    }
}

补充

/* 类继承
 * 1. 子类必须在constructor方法中调用super方法,否则新建实例时会报错。
 *    原因:子类的this对象,必须先通过父类的构造函数完成塑造,得到与父类同样的实例属性何方法,然后再对其进行加工,加上子类自己的实例属性和方法。如果不调用super方法,子类就得不到this对象。

 * 2. 子类没有定义constructor方法,这个方法会被默认添加。也就是,不管有没有显示定义,任何子类都有一个constructor方法。
 * 3. 在子类构造函数中,只有调用了super之后,才可以使用this关键字,否则会报错
 * 4. super这个关键字,既可以当作函数使用,也可以当作对象使用。
 *    4.1 super作为函数调用时,代表父类的构造函数,只能用在子类的构造函数之中,用在其他地方就会报错。
 *    4.2 super作为对象时, 在普通方法中,指向父类的原型对象;在静态方法中,指向父类。
 * 5. Class作为构造函数的语法糖,同时有prototype属性和__proto__属性,因此同时存在两条继承链。
 *    5.1 子类的__proto__属性,表示构造函数的继承,总是指向父类(这一点ES5没有)
 *    5.2 子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性
 */

参考:juejin.cn/post/684490…