「JavaScript进阶」一文吃透常见继承模式

556 阅读8分钟

前言

本文全面系统的介绍 JavaScript 中的 原型链继承、构造函数继承、组合继承、原型式继承、寄生式继承、寄生组合式继承、extends 类继承 7种常见的继承模式。建议用浏览器把文章中的案例运行一遍,有助于更好的理解。

1.原型链继承

如果对原型链还不是很清楚的,可以先看看我的这一篇文章 什么是原型链?

什么是原型链继承?

本质:重写原型对象,代之以一个新类型的实例。

通俗理解:原型链继承就是将构造函数 Super 的对象赋值给另一个构造函数 Sub 的原型 prototype,则 Sub 除了保留自己的属性及方法,还继承了 Super 的属性和方法。

我们来看个例子,有两个构造函数 Super 和 Sub,通过原型链的方式实现 Sub 继承 Super。

// 父类
function Super() {
  this.name = '谷底飞龙',
  this.getAge = function (){
    return 28;
  }
}
// 子类
function Sub() {
  this.country = '中国'
  this.name = '天下无敌',
}
// 将 Super 的对象赋值给 Sub 的原型 prototype
Sub.prototype = new Super();
const instance = new Sub();
// 输出 Sub 的属性和方法
console.log(
`my name is ${instance.name}, 
 my age is ${instance.getAge()},
 my country is ${instance.country}`
)

打印出my name is 谷底飞龙, my age is 28, my country is 中国,可以看到,通过原型链继承 Sub.prototype = new Super()的方式, Sub 除了自身属性 county 外,同时继承了 Super 的属性 name 和方法getAge()

原型链继承的缺点及注意事项

1、多个子类实例时,对父类属性进行引用类型操作会出现数据篡改问题

这是由原型链继承中由共享数据引起的最大问题。我们先来看个例子

// 父类
function Super() {
  this.name = ['谷底飞龙']
}
// 子类
function Sub() {}
// 将 Super 的对象赋值给 Sub 的原型 prototype
Sub.prototype = new Super();
// 创建子类的两个实列
const instance1 = new Sub();
const instance2 = new Sub();
// 对实例 instance1 进行引用操作(push)
instance1.name.push('天下无敌');
// 打印实例 instance2 的属性 name
console.log(`my name is ${instance2.name}`)

这个例子中,对实例 instance1 的属性 name 进行引用操作(push),会发现实例 instance2 的属性 name 也会发生改变,这里打印出结果my name is 谷底飞龙,天下无敌

2、子类原型 prototype 的 constructor 会被重写

我们来运行下案例

// 父类
function Super() {
  this.name = ['谷底飞龙']
}
// 子类
function Sub() {}
// 将 Super 的对象赋值给 Sub 的原型 prototype
Sub.prototype = new Super();
// 创建子类的两个实列
const instance = new Sub();
// 子类原型的 constructor 指向父类 Super
console.log(Sub.prototype.constructor === Super);

显然,原型链继承( Sub.prototype = new Super())实际上就是让子类的原型指向父类的实例,所以子类原型(Sub.prototype)的 constructor 也会被修改,指向的是父类(Super)。所以,这里执行 Sub.prototype.constructor === Super 的结果是 true

3、如果子类与父类有相同属性和方法,则子类覆盖父类

构造函数初始化时,如果子类与父类有相同的属性和方法,子类的优先级要高(注意与第4点区分),我们先来运行以下案例

// 父类
function Super() {
  this.name = '谷底飞龙'
  this.getAge = function (){
    return 28;
  }
}
// 子类
function Sub() {
  this.name = '天下无敌';
  this.getAge = function (){
    return 35;
  }
}
// 将 Super 的对象赋值给 Sub 的原型 prototype
Sub.prototype = new Super();
// 输出 Sub 的属性和方法
const instance = new Sub();
console.log(`my name is ${instance.name}, my age is ${instance.getAge()}`)

这里,子类 Sub 和父类 Super 具有相同的初始化属性 name 和方法 getAge(),最终表现的是子类的属性和方法。所以打印出my name is 天下无敌, my age is 35

4、子类原型添加属性和方法应该在原型继承之后

这一点很好理解,从第2点也能看出来,因为原型链继承,父类的对象会覆盖子类的原型,所以需要在原型继承之后给子类原型添加属性和方法才有效。

  • 注:这里注意与上面的第3点区分

我们来看个例子吧

// 父类
function Super() {
  this.name = '谷底飞龙'
}
// 子类
function Sub() {};
// 子类原型添加属性 age
Sub.prototype.name = '天下无敌';

// 将 Super 的对象赋值给 Sub 的原型 prototype
Sub.prototype = new Super();
// 输出 Sub 的属性和方法
const instance = new Sub();
console.log(`my name is ${instance.name}`)

这里是在原型之前给子类(Sub)原型添加的属性 name,所以会被父类(Super)覆盖,打印出my name is 谷底飞龙

5、创建子类实例时无法向父类的构造函数传参

这个很好理解,看前面的案例就很清楚了,这里就不赘述了。

2.构造函数继承

对构造函数还不是很理解的,可以先看看我这篇文章 什么是构造函数?

什么是构造函数继承?

通俗理解:构造函数继承,实际上就是在子类中通过 call/apply 将父类(Super)的 this 指向子类(Sub)。

我们来先看个例子

// 父类
function Super(name) {
  this.name = '谷底飞龙'
  this.getAge = function() {
    return 28;
  };
}
// 子类
function Sub() {
  // 通过 call 将子类 Sub
  Super.call(this, '谷底飞龙')
};
// 输出 Sub 的属性和方法
const instance = new Sub();
console.log(`my name is ${instance.name}, my age is ${instance.getAge()}`)

运行上面案例,会打印出my name is 谷底飞龙, my age is 28,显然,这里通过 call 实现了构造函数继承,使子类 Sub 继承了父类 Super 的属性 name 和方法 getAge()

  • 注: 把案例里的 call 换成 apply 的方式也是可以的。对 call/apply 还不是很清楚的,可以看看我这篇文章 全面解析 call 和 apply

构造函数继承的优缺点及注意事项

1、优点:前面提到,原型链继承有多个实例对引用类型的操作会被篡改创建子类实例时无法向父类的构造函数传参的缺陷,通过构造函数继承的方式就可以避免这两个问题

我们把原型链继承的案例改成构造函数继承

// 父类
function Super(name) {
  this.name = [name]
}
// 子类
function Sub() {
  // 使用构造函数继承
  Super.call(this, '谷底飞龙')
}

// 创建子类的两个实列
const instance1 = new Sub();
const instance2 = new Sub();

// 对实例 instance1 进行引用操作(push)
instance1.name.push('天下无敌');
// 打印实例 instance2 的属性 name
console.log(`my name is ${instance2.name}`)

从案例里可以看到,通过构造函数继承的方式

  • 子类可以传递参数(谷底飞龙)到父类的构造函数
  • 改用构造函数继承后,对实例 instance1 的属性 name 进行引用类型操作(push),实例 instance2 的属性 name 不会被修改,因此,这里会打印出my name is 谷底飞龙

2、注意事项:构造函数继承中,子类增加属性和方法应该在构造函数继承之后

这一点可以与原型链继承中的第3和第4点进行比较理解。我们还是来结合例子来理解吧

// 父类
function Super(name) {
  this.name = '谷底飞龙';
  this.age = 28;
}
// 子类
function Sub() {
  // 在继承之前定义属性 
  this.name = '天下无敌'
  
  // 使用构造函数继承
  Super.call(this);
  
  // 在继承之后定义
  this.age = 68;
}

// 打印子类实例 instance 的属性 name 和 age
const instance = new Sub();
console.log(`my name is ${instance.name}, my age is ${instance.age}`)
  • 子类在构造函数继承之前定义的属性和方法,如果与父类同名,会被父类的属性和方法覆盖掉;如果与父类不同名,则没有影响
  • 子类在构造函数继承之后定义的属性和方法,无论是否与父类同名,都不会受父类的影响

因此,这里应该打印出my name is 谷底飞龙, my age is 68

3、缺点:父类原型 prototype上定的方法,在子类中是不可见的

// 父类
function Super(name) {}
// 在父类 Super 原型上定义方法 getName
Super.prototype.getName = function() {
   return '谷底飞龙';
}

// 子类
function Sub() {
  // 使用构造函数继承
  Super.call(this);
}
const instance = new Sub();
console.log(`my name is ${instance.getName()}`)

在这个案例中,无法子类实例 instance 无法访问父类 Super 原型上定义的方法 getName(),因此,这里打印会报错Uncaught TypeError: instance.getName is not a function

  • 注:在父类构造函数内部定义的方法,子类是可以访问的

3.组合继承

前面提到纯原型链继承和纯构造函数继承都存在缺陷,因此在实际开发中,一般会混合使用,这就引申出组合继承的概念

什么是组合继承?

利用原型链继承来继承父类原型 prototype的属性和方法;利用构造函数继承来实行实例属性的继承

其实就是把原型链继承构造函数继承结合起来,发挥各自的优点来弥补对方的缺点,达到既实现了函数的复用,又能使每个对象具有属性的效果。

// 父类
function Super(name) {
  this.name = '谷底飞龙';
  this.hobby = ['读书']
}
// 定义父类 Super 原型方法
Super.prototype.getAge = function() {
   return 28;
}

// 子类
function Sub() {
  // 使用构造函数继承
  Super.call(this);
  
  // 子类独有属性
  this.country = '中国'
}

// 对子类 Sub 使用原型链继承
Sub.prototype = new Super()

const instance1 = new Sub();
const instance2 = new Sub();

// 对实例 instance1 进行引用类型操作(push)
instance1.hobby.push('投资')

console.log(
`实例 instance1:
 my name is ${instance1.name}, 
 my age is ${instance1.getAge()},
 my country is ${instance1.country},
 my hobby is ${instance1.hobby}`)

console.log(
`实例 instance2:
 my name is ${instance2.name}, 
 my age is ${instance2.getAge()},
 my country is ${instance2.country},
 my hobby is ${instance2.hobby}`)

先来看下打印结果: image.png

从上面的打印结果,可以看出,通过组合继承的方式

  • 子类 Sub 继承了父类 Super 构造函数内部的属性 name
  • 子类 Sub 可以访问父类 Super 原型 prototype上的方法 getAge()
  • 子类 Sub 保留了自身属性 country
  • 解决了多实例对属性进行引用类型操作篡改的问题(实例 instance2 的属性 hobby 不受实例 instance1 对父类属性 hobby 进行引用类型操作的影响)

组合继承的优缺点

  • 优点: 解决了原型链继承存在多实例对属性进行引用类型操作篡改的问题,以及构造函数继承存在的不能访问父类的原型方法和属性的问题。

  • 缺点: 在使用子类创建实例对象时,其原型中会存在两份相同的属性/方法。

4.原型式继承

注意不要与前面说的原型链继承混淆。

什么是原型式继承?

利用一个空对象作为中介,将某个对象直接赋值给空对象构造函数的原型 prototype,并返回这个空对象作为新对象,则这个新对象就继承了原对象的属性和方法。

// 原型式继承函数
function create(obj){
   // 创建一个新构造函数
   function F() {};
   // 将新构造函数的原型指向已有对象 obj
   F.prototype = obj;
   // 返回一个新对象,该对象共享原对象 obj 的属性和方法
   return new F()
}

function Person() {
   this.name = '谷底飞龙';
   this.getAge = function(){
      return 28;
   }
}
// 调用原型式函数生成一个新对象
const instance = create(new Person())
console.log(`my name is ${instance.name}, my age is ${instance.getAge()}`)

创建的原型式继承函数create()对传入的构造函数对象new Person()进行浅复制。 这里打印出结果my name is 谷底飞龙, my age is 28,可以看出通过原型式继承的方式,可以利用已有对象借助原型创建一个新对象,使得新对象与已有对象共享属性和方法。

  • 在 ES5 中新增了 Object.create() 方法来规范化原型式继承,接收两个参数,第一个为原型对象,第二个为要混合进新对象的属性。原理是一样的

原型式继承的缺陷及注意事项

与原型链继承很相似,原理也差不多,可以对比理解下。缺点:

1、原型式继承多个实例的引用类型属性指向相同,存在篡改的可能

2、无法传递参数

// 原型式继承函数
function create(obj){
   // 创建一个新构造函数
   function F() {};
   // 将新构造函数的原型指向已有对象 obj
   F.prototype = obj;
   // 返回一个新对象,该对象共享原对象 obj 的属性和方法
   return new F()
}

function Person() {
   this.name = ['谷底飞龙'];
}
// 调用原型式函数生成一个新对象
const instance1 = create(new Person())
const instance2 = create(new Person())

// 对 instance1 的属性 name 进行引用操作(push)
instance1.name.push('天下无敌');

// instance1 的属性 name 变为"谷底飞龙、天下无敌"
console.log(
`instance1:
 my name is ${instance1.name}`
)

// instance2 的属性 name 不变,仍为`谷底飞龙`
console.log(
`instance2:
 my name is ${instance2.name}`
)

执行上面的案例,打印结果如下: image.png

从结果可以看到,虽然原型式继承也共享数据,但是不一定有原型链继承中的多实例对属性进行引用操作导致数据篡改的问题

5.寄生式继承

什么是寄生式继承?

原型式继承的基础上,增强对象,返回构造函数

// 原型式继承函数
function create(obj){
   function F() {};
   F.prototype = obj;
   return new F()
}

// 寄生式继承函数
function createAnother(obj){
   // 利用原型式继承函数 create() 创建一个新对象
   var clone = create(obj)
   // 给对象增加新的属性和方法
   clone.name = '谷底飞龙'
   clone.getAge = function(){
     return 28;
   } 
   return clone
}

function Person(){
   this.country = '中国'
}
const instance = createAnother(new Person());
console.log(
`my name is ${instance.name},
 my age is ${instance.getAge()},
 my country is ${instance.country}`
 );

从代码里,可以看到,寄生式继承,其实就是通过原型式继承函数create()创建一个新对象clone,使其继承了原构造函数Person的属性country,同时又增加了属性name和方法getAge(),所以打印出结果如下:

image.png

寄生式继承的缺陷及注意事项

缺点与原型式继承的缺点相同

6.寄生组合式继承

寄生式继承原型式继承原型链继承都是基于原型 prototype的继承,因此都具有不能传递参数给父类的构造函数,共享数据可能带来引用类型操作的数据篡改问题,所以与组合继承类似,通过结合构造函数继承来解决这些问题。

什么是寄生组合式继承?

寄生组合式继承的实质就是发挥构造函数继承寄生式继承各自的优点来弥补对方的不足,即使用构造继承来传递参数和避免篡改,使用寄生式继承来增加原型方法

function inheritPrototype(subType, superType){
  var prototype = Object.create(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("name:"+this.name);
};

// 借用构造函数传递增强子类实例属性(支持传参和避免篡改)
function SubType(name, age){
  SuperType.call(this, name);
  this.age = age;
}

// 将父类原型指向子类
inheritPrototype(SubType, SuperType);

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

var instance1 = new SubType("谷底飞龙", 23);
var instance2 = new SubType("天下无敌", 24);

instance1.colors.push("2"); // ["red", "blue", "green", "2"]
instance2.colors.push("3"); // ["red", "blue", "green", "3"]

寄生组合式继承的优点

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

1、原型链保持不变,能够正常使用instanceof 和isPrototypeOf()

2、只调用了一次SuperType 构造函数,并且因此避免了在SubType.prototype 上创建不必要的、多余的属性

3、创建子对象时,支持传参数给父类

4、避免多对象因数据共享引起的数据篡改的问题

7.extends 类继承(推荐)

ES6 才有extends继承,这是我们目前开发中最推荐使用的继承方式

什么是 extends 类继承?

  • 子类通过extends继承父类
  • 父类和子类都有自己的构造函数 constructor(),通过构造函数来获取创建实例时传递的参数。一个类中只能有一个构造函数
  • 在子类的构造函数中,通过super()来传递参数给父类
// 定义一个父类 Person
class Person {
  // 构造函数
  constructor(name, age){
     this.myName = name;
     this.age = age;
  }
  // getter 获取属性
  get name(){
    return this.myName;
  }
  // 方法
  getAge() {
    return this.age;
  }
}

// 通过 extends 继承父类 Person
class Sub extends Person {
  // 子类构造函数需要在使用“this”之前首先调用 super()
  constructor(content){
    // 传递参数给父类
    super(content, 28)
    this.country = '中国'
  }
  get info(){
     return `my name is ${this.name},
     my age is ${this.getAge()}, 
     my country is ${this.country}`
  }
}

const instance = new Sub('谷底飞龙');
console.log(instance.info)

在这个例子中,子类 Sub 通过extends继承父类 Person,在子类中 Sub 就可以访问父类的属性 name 和方法 getAge(),因此,这里打印的结果是my name is 谷底飞龙, my age is 28, my country is 中国

  • 注:子类如果与父类具有相同属性和方法,则子类会覆盖父类

为什么子类要调用 super ?

从前面我们知道,子类在构造函数 constructor() 中调用需要先调用 super(),而且必须在使用 this 之前调用,为什么呢?

  • 一方面可以通过super()给父类构造函数传参数
  • 主要是因为子类没有自己的this,所以需要先调用super来创建父类的this对象,再用子类的构造函数修改 this

如果没有调用super(),新建实例会报错,运行下面案例验证下

// 定义一个父类 Person
class Person {
  // 构造函数
  constructor(){
     this.myName = '谷底飞龙';
  }
  // getter 获取属性
  get name(){
    return this.myName;
  }
}

// 通过 extends 继承父类 Person
class Sub extends Person {
  // 子类构造函数不调用 super()
  constructor(){
     this.country = '中国'
  }
  get info(){
     return `my name is ${this.name}`
  }
}
const instance = new Sub();
console.log(instance.info)

报错日志如下: image.png

  • 注:构造函数继承比较:构造函数继承是先创建子类的实例对象,再将父类的方法添加到子类this上(在子类构造函数中调用 Super.call(this)

扩展

1、函数声明和类声明的区别

函数声明会提升,类声明不会。首先需要声明你的类,然后访问它,否则像下面的代码会抛出一个ReferenceError。

let p = new Rectangle(); 
// ReferenceError

class Rectangle {}

2、ES5继承和ES6继承的区别

  • ES5的继承实质上是先创建子类的实例对象,然后再将父类的方法添加到this上(Parent.call(this)
  • ES6的继承有所不同,实质上是先创建父类的实例对象this,然后再用子类的构造函数修改this。因为子类没有自己的this对象,所以必须先调用父类的super()方法,否则新建实例报错。

更多精彩

欢迎去我的博客全家福逛逛: 谷底飞龙的大前端技术博客集

参考文档:# JavaScript常用八种继承方案