JavaScript 多种继承方式

181 阅读10分钟

JavaScript 继承

前言

继承是面向对象语言的重要知识点之一。
每一个实例对象(object) 都有一个私有属性(_proto_) 指向它的构造函数的原型对象(prototype)。该原型对象也有一个自己的原型对象(_proto_),层层向上知道一个对象的原型对象为null。根据定义, null 没有原型,并作为这个原型链中的最后一个环节。

原型链

ECMAScript 中描述了原型链的概念,并将原型链作为实现继承的主要方法。其基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。
简单回顾一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针,而实例都包含一个指向原型对象的内部指针。 那么,假如我们让原型对象等于另一个类型的实例,结果会怎么样呢?显然,此时的原型对象将包含一个指向另一个原型的指针,相应地,另一个原型中也包含着一个指向另一个构造函数的指针。假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实力与原型的链条。这就是所谓原型链的基本概念。

1、基于原型链的继承

这种方式关键在于:子类型的原型为父类型的一个实例对象

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.play = [1, 2, 3];
  this.setName = function(){}
}
Person.prototype.setAge = function(){}

function Student(price) {
  this.price = price;
  this.setScore = function () {}
}

Student.prototype = new Person();
let s1 = new Student(15000);
let s2 = new Student(14000);
console.log(s1, s2);

这种方式实现的本质是通过将子类的原型指向了父类的实例,所以子类的实例就可以通过__proto__访问到 Student.prototype 也就是父类 Person的实例,这样就可以访问到父类的私有方法,然后再通过 _proto_ 指向父类的prototype就可以活的到父类原型上的方法

子类继承父类的属性和方法是将父类的私有属性和公有方法都作为自己的公有属性和方法。

我们都知道在操作基本数据类型的时候操作的是值,在操作引用数据烈性的时候操作的是地址,如果说父类的私有属性中有引用类型的属性,那它被子类继承的时候会作为公有属性,这样子类操作这个属性的时候会影响到另一个子类。

s1.play.push(4);
console.log(s1.play, s2.play);
console.log(s1.__proto__ === s2.__proto__);
console.log(s1.__proto__.__proto__ === s2.__proto__.__proto__);

s1 中 play 属性发生变法,同时,s2 的属性也发生了变化。

另外注意一点:

我们需要在子类中添加新的方法或者重写父类的方法的时候,切记一定要放到替换原型的语句之后。

function Person(name, age) {
  this.name = name;
  this.age = age;
}

Person.prototype.setAge = function() {
  console.log(1);
}

function Student(price) {
  this.price = price;
  this.setScroe = function() {};
}
// Student.prototype.sayHello = function(){} 在这里写子类的原型和属性是无效的
// 因为会改变原型的指向,所以应该放到重新指定之后

Student.prototype = new Person();
Student.prototype.sayHello = function () {}
let s1 = new Student(13000);
console.log(s1);

特点
  • 父类新增原型方法/原型属性,子类都能访问到
  • 简单,易于实现
缺点:
  • 无法实现多继承
  • 来自原型对象的所有属性被所有实例共享
  • 创建子类实例时,无法向父类构造函数传参
  • 要想为子类新增属性和方法,必须在实例化之后才能执行,不能能放到构造器中。

2、借用构造函数继承(伪造对象)

在子类型构造函数中通用call()调用父类型构造函数


function People(name) {
  this.name = name || 'Name';
  this.sleep = function () {
    console.log(this.name + 'is sleeping');
  }
}
People.prototype.eat = function(food) {
  console.log(this.name + food);
}
function Woman(name) {
  People.call(this);
  this.name = name || 'B';
}
let womanObj = new Woman();


<script type="text/javascript">
  function Person(name, age){
    this.name = name,
    this.age = age,
    this.setName = function() {}
  }
  Person.prototype.setAge = function(){}
  function Student(name, age, price){
    Person.call(this, name, age) // 相当于: this.Person(name, age)
    this.price = price;
  }
  let s1 = new Student('Tom', 20, 15000);
</script>

这种方式只能继承父类的属性和方法,不能继承父类的原型的属性和方法。

特点:
  • 解决了原型链继承中子类实例共享父类引用属性的问题
  • 创建子类实例时,可以向父类传递参数
  • 可以实现多继承(call 多个父类对象)
缺点:
  • 实例并不是父类的实例,而是子类的实例
  • 只能继承父类的实例属性和方法,不能继承原型属性和方法
  • 无法实现函数服用,每个子类都有父类实例函数的副本,影响性能。

3、原型链 + 借用构造函数的组合继承

通过调用父类构造,继承父类的属性并保留传承的优点,然后通过将父类实例作为子类原型,实现函数复用。

当调用构造函数创建对象的时候,所有该构造函数原型的属性在新对象上都是可用的。

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.setAge = function(){}
}

Person.prototype.setAge = function() {
  console.log(1)
}

function Student(name, age, price) {
  Person.call(this, name, age);
  this.price = price;
  this.setScore = function() {}
}
Student.prototype = new Person();
Student.prototype.constructor = Student; // 组合继承也是需要秀父构造函数的指向
Student.prototype.sayHello = function() {}
let s1 = new Student('A', 11, 89);
let s2 = new Student('B', 12, 93);

console.log(s1);
console.log(s1.constructor);
console.log(s2);

这种方式融合原型链继承和构造函数的继承的有点,是JavaScript中常用地继承模式。不过也有缺点,无论什么情况下,都会调用两次构造函数:

  • 一次是创建子类型原型的时候
  • 另一次是子类型构造函数的内容,子类型最终会包含父类型对象的全部实例属性,但我们不得不在调用子类构造函数时重写这些属性。
优点
  • 可以继承实例属性/方法,也可以继承原型属性/方法
  • 不存在引用属性共享问题
  • 可传参
  • 函数可复用
缺点
  • 调用了两次父类构造函数,生成了两份实例

4、组合继承优化一

这种方式通过父类原型和子类原型指向同一对象,子类可以集成到父类的公有方法当做自己的公有方法,而且不会初始化两次实例方法/属性,避免了组合继承的缺点。

function Person(name, age) {
  this.name = name;
  this.age  = age;
  this.setAge = function(){}
}
Person.prototype.setAge = function(){
  console.log(1);
}
function Student(name, age, price) {
  Person.call(this, name, age);
  this.price = price;
  this.setScore = function() {}
}
Student.prototype = Person.prototype; // 子类的构造函数和父类的构造函数指向的是同一个对象
Student.prototype.sayHello = function(){}
let s1 = new Student('s1', 12, 33);
console.log(s1);

这种方式没办法辨别:对象是子类还是父类实例化

console.log(s1 instanceof Student, s1 instanceof Person);
console.log(s1.constructor);

优点
  • 不会初始化两次实例方法/属性,避免的组合继承的缺点
缺点
  • 没办法辨别: 实例是子类还是父类创造的,子类和父类的构造函数指向的是同一个。

5、组合继承优化 二 (寄生式继承)

借助原型可以基于已有的对象来创建对象, let B = Object.create(A) 以A 对象为原型,生成了B对象,B对象继承了A的所有属性和方法。

寄生式继承的思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。

function Person(name, age ){
  this.name = name;
  this.age = age;
}
Person.prototype.setAge = function(){
  console.log(1)
}
function Student(name, age, price) {
  Person.call(this, name, age);
  this.price = price;
  this.setScore = function(){}
}

Student.prototype = Object.create(Person.prototype); // 通过调用函数创建一个新对象
Student.prototype.constructor = Student;
let s1 = new Student('A', 12, 88);
console.log(s1);
console.log(s1 instanceof Student, s1 instanceof Person);
console.log(s1.constructor);

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}
function createAnother(original) {
  let clone = object(original);
  clone.sayHi = function() {
    console.log('h1');
  }
 return clone;
}
let person = {
  name: 'hi',
  friends: ['A', 'B', 'C']
}
let anotherPerson = createAnother(person);
anotherPerson.sayHi()

在这个例子中,createAnother() 函数接受一个参数,也就是将要作为新对象基础的对象。然后,把这个对象original 传递给 objectt()函数,将返回的 结果赋值给clone, 再为 clone 对象添加一个新方法 sayHi(), 最后返回clone对象。 这个例子中的代码就是基于person 返回一个新对象 -- anotherPerson, 新对象不仅具有 person的所有属性和方法,而且还有自己的sayHi方法。

在主要考虑对象而不是自定义类型和构造函数的情况下,寄生式继承也是一种有用的模式。

使用寄生式继承来为对象添加函数,会由于不能做到函数复用而降低效率,这一点与构造函数模式类似。

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); // 第二次调用SuperType
  this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用SuperType
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
  console.log(this.age);
}

上述代码中,第一次调用SuperType 构造函数时,SubType.prototype 会得到两个属性:name 和 colors,它们都是 SuperType的实例属性,只不过现在位于SubType 的原型中。当调用SubType 构造函数时,又会调用一次SuperType 构造函数,这一次又在新对象上创建了实例属性name 和 colors。 于是,这两个属性就屏蔽了原型中的两个同名属性。

所谓寄生组合式继承,即通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。其背后的基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需要的无非就是超类型原型的一个副本而已。本质上,就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

7、ES6 中 class 的继承

Class 之间可以通过 extends 关键字实现继承,还可以通过static关键字定义类的静态方法,这比ES5 通过修改原型链实现继承要清晰和方便很多。

class ColorPoint extends Point {}

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

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  toString() {
    return this.color + ' ' + super.toString(); // 调用父类的toString() 方法
  }
}

super关键字,代指父类的实例(即父类的this 对象)

子类必须在constructor 方法中调用super 方法,否则新建实例时,会报错。这是因为子类没有自己的this对象,而是继承了父类的 this 对象,然后对其进行加工。如果不调用 super 方法,子类就得不到this 对象。

如果子类没有定义 constructor 方法,那么这个方法会被默认添加。并且,只有在调用 super 之后,才可以使用 this 关键字,否则会报错。这是因为子类实例的构建基于对父类实例的加工,只有 super 方法才能返回父类实例。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    // this.color = color; // ReferebceError
    super(x, y);
    this.color = color;
  }
}