JS面向对象(三)继承

128 阅读9分钟

继承的4个主要目标

只要满足以下4个主要目标,就是完成了继承:

  1. 继承实例属性

且每个子类的实例应该创建自己的实例属性。

  1. 继承静态属性
  2. 继承原型属性

子类可以访问和使用父类原型及其原型链上的属性。

  1. 保持子类的类型
  • 子类实例应该被认为是子类类型
  • 同时也能够被正确地识别为父类的实例。

所谓继承,就是想法设法实现以上4个目标。因此理解一种继承方式的时候,可从这4点入手,看其是如何实现的,从而理解其原理。

常见的继承方式

如果语言本身对于继承有良好的支持,当然最好,否则的话,开发者只能自己hack各种方式。在JS引入extends关键字之前,开发者们hack了很多种继承方式。下面我们用上面的4个条件来检验几个继承方式,并指出其中存在的问题。

Extends继承

这是目前最推荐的方式,因为它有语言层面的支持,优雅好理解。

class Parent {
  static attr = "parent";

  constructor(type) {
    this.type = type;
    this.colors = ["red"];
  }

  sayType() {
    return this.type;
  }
}

class Child extends Parent {
  constructor(name) {
    super(name);
  }
}

const parent = new Parent("parent");
const child = new Child("child");
const child2 = new Child("child2");

// 1. 测试静态属性
console.log(Child.attr === "parent");

// 2. 测试实例属性
console.log(child.type === "child");

// 3. 测试原型属性
console.log(child.sayType() === "child");
console.log(child.__proto__ === Child.prototype);
console.log(Child.prototype.__proto__ === Parent.prototype);

// 4. 测试类型
console.log(child instanceof Child); // 是 Child 类型
console.log(child instanceof Parent); // 原型链中有 Parent
console.log(child.__proto__.constructor === Child);

// 5. 测试是否共享 colors 数组
console.log(child.colors === child2.colors); // false

达成的继承目标: 4个

通过上面的测试代码可知,5个继承目标全部达成。下面解释下如何达成的目标:

  1. 继承静态属性
Child.__proto__ = Parent

要时刻牢记:类本质上是函数,函数本质上是对象,对象一般都有 __proto__ 属性。extends背后做的工作之一就是将Child__proto__指向Parent对象。 这样Child上没有attr属性时,就会接着查找Child.__proto__.attr,即Parent.attr

另外注意:Child.__proto__原本是指向Function.prototype。因为Child是一个对象,其__proto__指向其构造函数的prototype

  1. 继承实例属性

这一点是通过调用Parent构造函数,并以Child的实例对象作为this实现的。调用super函数的过程也是如此。

  1. 继承原型属性

这一点通过Child原型对象的原型来实现的:

  • 实例对象child上没有sayType方法
  • 然后沿着child.__proto__找到Child.prototy,也没有
  • 接着沿着child.__proto__.__proto__找到Child.prototy.__proto__,即Parent.prototype,找到了!
  1. 保持子类的类型
  • Child类型
  • Parent的实例

原型链中有Parentinstanceof就会判断实例对象childParent的实例。对此有疑问的读者可查看MDN文档

小结

extends继承因为是语言层面的支持,因此是目前最简洁、优雅的继承方式,也是最推荐的方式,没什么明显缺点。其本身是一个“语法糖”,背后的继承机制就是我们上面介绍的这4点:在原型链的基础上各种补丁操作,其中很多操作开发者们早已hack出来了。

原型链继承

将子类的原型指向父类的实例来实现继承

// 父类
function Parent() {
  this.name = "Parent";
  this.colors = ["red", "blue", "green"];
}

Parent.prototype.sayHello = function () {
  console.log("Hello");
};

Parent.type = "parent";

// 子类
function Child() {}

// 将子类的原型指向父类的实例
Child.prototype = new Parent();

// 将子构造函数对象指向父构造函数对象
Child.__proto__ = Parent;

// 创建子类实例
let child1 = new Child();
let child2 = new Child();

// 1. 测试实例属性
console.log(child1.name); // 输出: "Parent"
child1.sayHello(); // 输出: "Hello"

// 2. 测试原型属性 & 测试共享原型属性
child1.colors.push("black");
console.log(child2.colors); // 输出: ["red", "blue", "green", "black"]

// 3. 测试类型
console.log(child1 instanceof Child); // 输出: true
console.log(child1 instanceof Parent); // 输出: true,因为子类的原型链上有父类的原型
console.log(child1 instanceof Object); // 输出: true,因为所有对象最终都是 Object 的实例

// 4. 测试静态属性
console.log(Child.type === "parent");

达成的继承目标:3.5个

  1. 继承实例属性(有缺陷)

实现机制是,访问child1.name没找到,尝试查找child1.__proto__.name,即Child.prototypr.name,即Parent的一个实例对象。如此实现实例属性的继承。

缺陷是: 像colors这样的对象属性,在父实例中,是每个实例单独有一个。而在子类实例中,所有实例通过原型链的方式共享同一个对象属性,一旦其中一个实例更改,其他实例都受影响。

  1. 继承原型属性

原型属性的查找比上面“继承实例属性”多一步查询,机制类似。

  1. 保持对象的类型

实现机制与extends继承一样。

  1. 继承静态属性

extends继承一样,通过Child.__proto__ = Parent实现(一般没有静态属性时,这一步省略不写)。

问题

  • 本应是实例属性,却通过原型链继承:如上第1点所述,colors属性,本应所有子类实例单独有一个,但是却成为通过原型链继承的属性,很容易相互影响。

构造函数继承(经典继承)

通过在子类构造函数中调用父类的构造函数来实现属性继承

// 父类
function Parent(name) {
  this.name = name || 'Parent';
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.sayHello = function() {
  console.log('Hello');
};

// 子类
function Child(name) {
  Child.__proto__ = Parent;
  Parent.call(this, name);
}

// 创建子类实例
let child1 = new Child('Child 1');
let child2 = new Child('Child 2');

// 测试1:继承实例属性
console.log(child1.name); // 输出: "Child 1"
console.log(child2.name); // 输出: "Child 2"

// 测试2:不继承原型属性和方法
child1.sayHello(); // 报错: child1.sayHello is not a function

// 测试3:子对象类型
console.log(child1 instanceof Child); // true
console.log(child1 instanceof Parent); // false
console.log(child1 instanceof Object); // true

达成的继承目标:2.5个

  1. 继承实例属性

与extend继承一样,通过执行父类函数,并将this指向子类实例实现。

  1. 保持子类实例类型(部分达成)

子对象被识别为Child的实例,不能被识别为Parent的实例,如测试3。原因是Parent类不在Child类的原型链上。

  1. 继承静态属性

通过Child.__proto__ = Parent实现(没有静态属性时,可省略)

问题

  • 不能继承原型属性
  • 子类实例不能被识别为父类的实例

组合继承

组合继承结合了原型链继承和构造函数继承的优点,解决了单一继承的问题。通过调用父类构造函数继承实例属性,通过将子类的原型对象设置为父类的实例,实现原型属性的继承。

// 父类
function Parent(name) {
  this.name = name || 'Parent';
  this.colors = ['red', 'blue', 'green'];
}

Parent.prototype.sayHello = function() {
  console.log('Hello');
};

// 子类
function Child(name) {
  // 第2次调用父构造函数
  Parent.call(this, name); // 继承实例属性
}

// 第一次调用父构造函数
Child.prototype = new Parent(); // 继承原型属性和方法
Child.prototype.constructor = Child; // 修复构造函数指向

// 创建子类实例
let child1 = new Child('Child 1');
let child2 = new Child('Child 2');

// 测试1:继承实例属性
console.log(child1.name); // 输出: "Child 1"
child1.sayHello(); // 输出: "Hello"

// 测试2:继承原型属性
child1.colors.push('black');
console.log(child2.colors); // 输出: ["red", "blue", "green"]

// 测试3:子对象类型
console.log(child1 instanceof Child); // true
console.log(child1 instanceof Parent); // true
console.log(child1 instanceof Object); // true

// 测试4:静态属性
// 这一点最容易达成,不再赘述

达成的继承目标:4个

  1. 继承实例属性

通过调用父类函数,并指定this实现。(解决了原型链继承将实例属性作为原型属性继承的缺陷)

  1. 继承原型属性

通过Child.prototype = new Parent(),将Parent的原型链嵌入到Child上。(解决了构造函数继承无法继承原型属性的问题)

  1. 继承静态属性

这一点最容易达成,不再赘述。

  1. 保持子类类型

通过原型链来保证子类实例的类型,ChildParent都在子类实例的原型链上。(解决了构造函数继承,子类实例不是父类实例的问题)

问题

调用了2次父构造函数: 一次是为了继承实例属性,一次是为了继承原型属性。

原型式继承

通过一个已有对象创建新对象,并继承其属性和方法

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

// 父对象
function Parent() {
  this.name = "Parent";
  this.colors = ["red", "blue", "green"];
}
const parent = new Parent();

// 子对象
let child = inheritObject(parent);

// 测试1:继承实例属性
console.log(child.name); // 输出: "Parent"

// 测试2:继承原型属性
parent.sayHello = function () {
  console.log("Hello");
};
child.sayHello(); // 输出: "Hello"

// 测试3:实例类型
console.log(child instanceof Parent); // true
console.log(child instanceof Object); // true

达成的继承目标:3.5个

  1. 实例属性(有缺陷)

跟原型链继承一样,本来实例属性,每个实例应该各自创建一套,但是这种继承方式,是将实例属性放到了原型链上。

  1. 原型继承

通过原型链机制实现。

  1. 实例类型

通过原型链机制实现。

  1. 静态属性

问题

  • 无法传递参数
  • 实例属性是用原型属性的继承方式继承的

引入F函数的作用

主要作用是子实例对象,不必再有自己的构造函数(父对象实例也不必有)。这是“对象实例”与“对象实例”之间的继承,而非构造函数之间。更符合JS的原型链继承的本质。

与原型链继承的区别

更推荐的办法

  • 使用Object.create
function inheritObject(o) {
  return Object.create(o);
}

总结

本文用继承的4个目标,对常见的继承方式做了衡量,并说明了背后的机制,希望可以帮助读者更好地理解、记忆继承。

JS的继承是建立在原型之上的,所以读者们理解继承时,要牢记以下几点:

  1. 原型式语言的继承,总是围绕原型链做文章

原型链本质是链表,从数据结构上就表明,我们可以随意“打断”“连接”原型链,__proto__就是指针。继承的过程,往往就是如何“连接”原型链。而方式就是修改一个对象的__proto__属性。原型链是对象实例类型,及继承原型属性的关键。

  1. 万物皆对象,皆可__proto__

构造函数也是对象,那么就可以/应该有__proto__属性。一般情况下,函数的__proto__属性指向Function.prototype,但我们可以“断链、改链”,将其指向父构造函数,从而完成静态属性的继承。

遇到一个对象,应该条件反射地想一想:它的__proto__是什么,__proto__.__proto__又是什么。

目前推荐的继承方式是:extends继承和Object.create继承。


对于我们大多数人来说,限制是好事,真有多种选择, 反而让人无所适从。