【js基础巩固计划】js实现继承的多种方式

225 阅读8分钟

努力让学习成为一种习惯,自信来源于充分的准备

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

前言

对于js来说,继承基于原型链构造函数原型链核心基本思想是通过原型继承多个引用类型的属性和方法

js中有很多种继承方式,掌握这些方式,并清楚多种方式的特点,优缺点。对于我们无论是日常编程,编写封装更高质量代码还是阅读源码都是非常有帮助的,接下来让我们一起看看。

ps: 本文不会过多的介绍原型原型链相关的知识。对原型原型链还不太清楚的小伙伴可以先阅读这篇文章:带你重新认识原型、原型链

原型链继承

function Parent() {
  this.name = 'kk'
}

Parent.prototype.getName = function () {
  return this.name
}

function Child() {}
Child.prototype = new Parent()
const child = new Child()

console.log(child.name); // kk
console.log(child.getName()); // kk

原型链继承的核心代码是:Child.prototype = new Parent()

我们知道通过new方式调用的函数(构造函数)内部会返回一个对象,其对象的[[prototype]]属性指向函数的prototype原型,即存在以下关系:

Object.getPrototypeOf(child) === Child.prototype
Object.getPrototypeOf(Child.prototype) === Person.prototype

// 用更加直观但是不标准的方式表示
child.__proto__.__proto__ === Parent.prototype

有关new的详细细节可以参考:实现自己的new

基于原型链查询规则,所有Child实例都可以访问到Parent实例里的属性、以及原型上的方法,它自身并没有这些属性、方法。从表象看我们可以称:Child继承了Parent

Object.hasOwn(child, 'name') // false
Object.hasOwn(child, 'getName') // false

但是"成也共享,败也共享",Child.prototype原型对象中的属性被其所有实例共享。如果属性是引用类型则会有问题

function Parent() {
  this.fruits = []
}

Parent.prototype.addFruit = function (fruit) {
  this.fruits.push(fruit)
}

function Child() {}
Child.prototype = new Parent()
const child = new Child()
const child2 = new Child()

child.addFruit('apple')
child2.addFruit('banana')

console.log(child.fruits); //  ['apple', 'banana']
console.log(child2.fruits); //  ['apple', 'banana']

可以看到,任何一个实例调用addFruit方法,操作后的结果会影响到其他所有实例。这显然不是我们所期望的😨,另外子类在实例化时不能给父类的构造函数传参

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

经典继承的核心思路是:在子类构造函数中调用父类构造函数,运用applycall显示绑定父类构造函数的this对象为子类构造函数中传入的对象

有关callapply的详细细节可以参考:实现自己的call、apply

function Parent(name) {
  this.name = name
  this.fruits = []
}

function Child(name) {
  Parent.call(this, name)
}

const child = new Child('child')
child.fruits.push('apple')

const child2 = new Child('child2')
child2.fruits.push('banana')

console.log(child.name); // child
console.log(child.fruits); // ['apple']

console.log(child2.name); // child2
console.log(child2.fruits); // ['banana']

上面例子中:子类的实例属性都是独有的,不存在各实例共享属性的情况,并且我们可以在子类构造函数内给父类构造函数传参

但是我们必须在构造函数中定义方法:this.xxx = function() {xx},这样一来,函数不能在各个实例中复用,每生成一个子类实例都需要创建一次方法,而且子类也无法访问父类原型对象上的方法

组合继承(原型链继承➕经典继承)

这里直接引用红宝书的定义

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性

function Parent(name) {
  this.name = name
}

Parent.prototype.getAge = function () {
  return this.age
}

function Child(name, age) {
  Parent.call(this, name)
  this.age = age
}

Child.prototype = new Parent()
const child = new Child('child', 11)
const child2 = new Child('child2', 12)
 
console.log(child.name); // child
console.log(child2.name); // child2

console.log(child.getAge()); // 11
console.log(child2.getAge()); // 12

组合继承弥补了原型链和盗用构造函数的不足,是JavaScript中使 用最多的继承模式。而且组合继承也保留了 instanceof 操作符和isPrototypeOf() 方法识别合成对象的能力

完美,太完美了,双剑合璧

那么,就,,,完了吗🤔

仔细观察👀,我们会发现上面代码console.log('Parent be called')会额外多调用一次:创建子类原型调用,总感觉有些不舒服,如鲠在喉的感觉😓。这个问题在下面会得到解决

原型式继承

function createObj(o) {
  function F() {}
  F.prototype = o
  return new F()
}

这段代码是不是有种似曾相识的感觉🤔,没错,它和我们之前的原型链继承非常的相似。只不过这里将构造函数的原型F.prototype直接引用了传入的现有对象

这种方式的优点是把构造函数生成的过程给内化了。我们只需要传入一个对象即可生成一个新的对象并继承它,缺点与原型链继承的缺点一样:实例会共享原型对象中的属性,另外我们并不好对新返回的对象拓展属性、方法

const source = {
  name: 'source',
  fruits: []
}

const obj = createObj(source)
const obj2 = createObj(source)

console.log(obj.name); // source
console.log(obj2.name) // source

obj.fruits.push('apple')
console.log(obj.fruits); // ['apple']
console.log(obj2.fruits); // ['apple']

ECMAScript 5通过增加Object.create()方法将原型式继承的概念规范化了

寄生式继承

引入红宝书的定义

创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象

这里其实和上面的原型式继承相似,我们可以进一步对增强后的对象进行扩展

function createObj(o) {
  function F() {}
  F.prototype = o
  return new F()
}

const source = {
  name: 'source',
  fruits: []
}

function createAnother(o) {
   const t = createObj(o) // createObj并不是寄生模式所必需的,任何返回对象的函数都可以在这里使用
   t.name = 'kk'
   t.sayName = function () {
     return this.name
   }
   
   return t
}

const obj = createAnother(source)
console.log(obj.name); // kk, 这里将source的name属性给覆盖了
console.log(obj.sayName()); // kk

我们可以借助Object.defineProperties来批量扩展属性

function createAnother(o, options) {
   const t = createObj(o)
   Object.defineProperties(t, options)
   return t
}

const obj = createAnother(source, {
  name: {
    value: 'kk',
    writeable: true,
    configurable: true,
    enumerable: true
  },
  sayName: {
    value: function () { return this.name },
    writeable: true,
    configurable: true,
    enumerable: true
  }
})

它的缺点和构造函数模式(经典继承)一样。每创建一个对象,对需要重新定义方法,方法不能复用

寄生式组合继承

在前面介绍组合继承的末尾提及了其缺点:父类的构造函数至少会被调用两次

我们再回顾下组合继承的例子

function Parent(name) {
  this.name = name
}

Parent.prototype.getName = function () {
  return this.name
}

function Child(name) {
  // 第一次子类构造函数内调用
  Parent.call(this, name)
}

// 第二次 创建子类原型调用
Child.prototype = new Parent()
const child = new Child('child', 11)

这里Child.prototypechild都会有name属性

image.png

这里通过new的方式生成Child的原型对象,等等!我们上面的寄生式组合继承不就可以生成一个以传入对象为原型的对象,换句话说,我们直接把父类的原型对象传入就OK了。这样就不需要额外调用一次父类构造函数本身了,我们来看看具体代码

function createObj(o) {
  function F() {}
  F.prototype = o
  return new F()
}

function inheritPrototype(o, options) {
   const t = createObj(o)
   Object.defineProperties(t, options)
   return t
}

function Parent(name) {
  this.name = name
}

Parent.prototype.getName = function () {
  return this.name
}

function Child(name, age) {
  // 第一次子类构造函数内调用
  Parent.call(this, name)
  this.age = age
}

// 这里是核心改造
// 使用寄生组合继承的方式- 继承并增强
Child.prototype = inheritPrototype(Parent.prototype, {
  constructor: {
    value: Child,
    configurable: true,
    writable: true,
    enumerable: false
  }
})

const child = new Child('child', 11)
console.log(child.name); // child
console.log(child.age); // 11
console.log(child.getName()); // child

这种方式与组合继承的核心区别在于:

组合继承是创建了父类实例,通过它串联原型链,生成父类实例就需要额外调用一次父类构造函数

寄生式组合继承是自己临时建立一个函数并生成实例。手动将子类构造函数原型对象指向临时函数的实例,而临时函数的原型再指向父级构造函数原型,这样实现了子类实例可以访问父类实例以及原型上的属性。修改子类构造函数原型时也不会影响到父类

两者都调用了一次函数。后者则无关紧要,寄生式组合继承可以算是引用类型继承的最佳模式

最后

上面介绍的继承方式只使用es5的特性来模拟类似于 类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协

最终的优化方式是采用构造函数方式实现子类实例属性的独立采用原型链➕寄生的方式实现父类原型对象上方法、属性的复用,但不管怎么说,实现继承的代码也显得非常冗长和混乱

在es6中引入了class关键字,让其有了正式定义类的能力。看似有了面向对象的能力。但本质是个语法糖,背后的原理依旧是基于原型与构造函数,不过还是会有些不同。这个也会在后续专门开篇文章详细介绍

到这里,就是本篇文章的全部内容了

如果你觉得该文章对你有帮助,欢迎大家点赞关注分享

如果你有疑问或者出入,评论区告诉我,我们一起讨论