白话 JS 中的继承

811 阅读9分钟

本文是在复习《JavaScript 高级程序设计》中的继承那章后,整合思路的总结。特意使用流水线不分目录的叙述方式,方便更好地串起知识点。

思路:继承是什么 >> JS 继承依赖原型 >> 原型 >> 原型式继承 >> 原型链继承 >> 组合继承 >> 寄生式组合继承

继承,就是 A 的属性方法可以继承给 B,B 可以继续往下传。 同时,我们还希望

  • B 能够新增自己的属性和方法。
  • B 能够修改继承来的属性和方法,但不影响 A 也不干扰同代。

通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”、“父类”或“超类”。

要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。

那,JS 中怎么实现继承呢。

JS 中有原型。我们先来看看原型是啥。

  • 每个函数对象默认有一个属性 prototype,它指向该函数对象的原型对象
  • 使用 new 运算符调用函数时,会产生一个新对象,新对象默认有一个内部属性原型 [[Prototype]],它指向的就是函数的原型对象。(至于 new 的实现原理,在此先不表)
  • 原型上的属性是能够被新对象共享的。

我们来看一个简单的例子。

const O = {
  name: 'li',
  colors: ['red'],
  sayName: function () {
    console.log(this.name)
  }
}
function F () { }
F.prototype = O
let foo = new F()
console.log(foo.name) // li
foo.sayName() // li

如上,当执行 foo.name 时,会先检查 foo 本身是否有这个属性,如果有就返回,如果没有就继续搜寻原型的原型上是否有。在本例中,也就是可以使用 foo 调用 O 的属性和方法。

需要注意的是,可以使用 foo 调用 O 的属性和方法,但是却不可以重写,这是所有继承都应该满足的,被继承的内容是不可被使用者修改的。如果 foo 试图重写属性和方法,比如执行foo.name = 'jiang',只会在 foo 上创建一个 name 属性。但但是,我们知道 JS 中的引用类型比较特殊,虽然不可以重写,却可以修改其属性,比如,执行 foo.colors.push('blue'),就会修改原型上的 colors 属性了,并且这种修改会影响到 F 的其他实例,很明显这就不是我们想在继承中看到的了。这个“引用值问题”我们会在后面继续讨论。我们暂时继续看这段代码。

我们能否,把这段代码封装成一个通用的函数,用来产生可以继承某个父级对象 O 的一系列对象?这个自然不难实现。如下:

function create (o) {
  let F = function () { }
  F.prototype = o
  return new F()
)
}
const O = {
  name: 'li',
  sayName: function () {
    console.log(this.name)
  }
}
let foo = create(O)
foo.age = '18'
let bar = create(O)
bar.age = '20'

JS 的继承方式被人为地划分了好几种,而这种创建对象的方式,被称为原型式继承。这种继承方式,是纯原型式的继承,我们只是利用了原型上的数据能被共享这个特性,从外部看来,我们只是输入对象,然后就会产生一个新的拥有同样数据的对象。这也是《 JS 精粹》中所推崇的形式,它完全只依赖 JS 本身的特性,编程方式也更接近于函数式编程。

掌握纯原型式继承后,我们来看一种主要的继承方式——原型链继承。ECMA-262 把原型链定义为 ECMAScript 的主要继承方式

我们先看一个继承的实例。代码如下:

function Parent() {
  this.name = 'li'
  this.colors = ['yellow', 'red', 'blue']
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
function Child(age) {
  this.age = age
  this.nums = [1, 2, 3]
}
Child.prototype = new Parent()

childA = new Child('jiang', 12)
childB = new Child('li', 13)
childA.sayName() //  'jiang'
childB.sayName() //  'li'
console.log(childB.age) // 13
console.log(childB instanceof Parent) // true
console.log(childB instanceof Child) // true

最不太好理解的的一步是 Child.prototype = new Parent () 这步。我们一步步解释一下。

首先,我们先说下执行 new  Parent () 会发生什么。前面我们有简单提到,当使用 new 运算符调用函数时,会产生一个新对象,新对象默认有一个内部属性原型 [[Prototype]],它指向的就是函数的原型对象。下面,我们来补充一下 new 运算符的作用。从 new 的内部实现来说:

  1. 在内存中创建一个新对象,我们暂时称其为 bar 吧。

  2. bar 的 [[Prototype]] 属性被赋值为 Parent 的 prototype 属性。

  3. 函数内部的 this 被赋值为 bar。

  4. 执行 Parent 内部的代码。也就是

    this.name = name
    this.colors = ['yellow', 'red', 'blue']
    

    执行完后,bar 上会有属性 name 和 colors。

  5. 如果被调用函数没有返回一个非空对象,就返回创建的这个新对象,也就是我们前四步中提到的 bar,如果被调用的函数中主动 return 一个非空对象,那就返回这个非空对象。

被用 new 运算符调用的函数,我们称其为构造函数。也就是此时我们可以称 bar 的构造函数为 Parent,而 bar 可以称为 Parent 的实例

我们可以看到,new 总的来说,有两个作用:

  1. 产生一个新对象:对象的原型指向原型对象。
  2. 执行函数代码:函数内部的 this 指的是新对象,对 this 的操作就是操作这个新对象。

因为 new 很关键,可以说是 JS 中用来产生新对象的最底层方式。所以说得啰嗦了点。

所以,new Parent() 后,我们得到了一个新对象,还是称其为 bar 吧。bar 的原型指向 Parent 的原型对象。然后,最关键的一步来了,我们把 bar 再赋值给 Child 的原型对象,所以 new Child() 的时候,childA 和 childB 的原型就是 bar 。

这样,我们就创造出了一条原型链:childA 和 childB 的原型是 bar,bar 的原型是 Parent.prototype。

所以此时 childA 拥有自己的属性 age 和 nums,同时可以访问到它的原型链上的 name 和 colors 以及 sayName 方法。

原型链继承的优势之一是可以更好地模拟类,从而更接近面向对象编程。以上代码,我们其实就简单实现了类的概念。

类是支持面向对象的语言的核心语法,可以看成是批量创建对象的一个模板,一般都会满足以下几条。

  1. 一个类可以通过被继承产生多个子类,这些子类拥有父类的属性和方法。
  2. 一个类可以产生多个实例,每个实例都拥有类的属性和方法。
  3. 类有个构造函数,在类被实例化时调用。

构造函数 Child 和 Parent 此时扮演的角色就是产生实例 childA 的类及其父类。

console.log(childB instanceof Child) // true

我们其实可以发现,childA 继承到的方法和属性的来源不同。sayName 这个方法来自于其原型链,我们称这种策略叫原型链继承,而 age 和 nums 属性来自于其构造函数,我们称这种方式叫构造函数式继承

其实,将方法和属性用不同的方式继承是我们有意为之的,原因是,

  • 如果方法定义在构造函数里,那么每个实例都需要复制一份方法,性能不佳, 所以放在原型链上,大家都用同一个就行。
  • 如果属性定义在原型对象上,大家用一个属性,就会出现前面提到的引用值问题,而用构造函数实现继承,每个实例上的对象是单独的,如上,childA 和 childB 上的 nums 是独立的,就不会出现引用值问题。

刚刚刻意没有提到 colors 和 name 这两个属性,他们是定义在 Parent 这个构造函数里,但对 childA 来说,它却是来自其原型链第一环 bar。所以,对于 childA 来说,这两个属性也算是原型链继承,childA 和 childB 会共用 colors,同样会出现前面提到的引用值问题。

除了引用值问题,原型链继承还有个问题很明显,调用子类时无法对父类进行传参。

这两个问题解决起来也比较简单。使用 Function.prototype.call() 或者 Function.prototype.apply() 函数。

function Parent(name) {
  this.name = name
  this.colors = ['yellow', 'red', 'blue']
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
function Child(name, age) {
  Parent.call(this, name)
  this.age = age
  this.nums = [1, 2, 3]
}
Child.prototype = new Parent()
childA = new Child('jiang', 12)
childB = new Child('li', 13)
childA.sayName() //  'jiang'
childB.sayName() //  'li'
console.log(childB.age) // 13
console.log(childB instanceof Parent) // true

call 函数的高光时刻。此时,就可以对 Parent 进行传参了。

此时 Child 函数在被调用的时候相当于执行下面代码:

function Child(name, age) {
  this.name = name
  this.color = ['yellow', 'red', 'blue']
  this.age = age
}

也就是说,此时, name、colors 和 age 一样了,childA 和 childB 会拥有自己的 colors 属性,就不会再有引用值问题。

这种方式称为组合继承。(组合是指原型链+盗用构造函数)

组合继承弥补了很多问题,是 JavaScript 中使用最多的继承模式。

虽然组合继承解决了原型链继承的两个问题,但是又带来了新的问题,需要继续改造一下。前面我们提到,只使用原型链继承的方式中,childA 拥有自己的属性 age,同时可以访问到它的原型上的 name 和 colors,但现在组合继承的例子中,盗用构造函数后,childA 有自己的 name 和 colors 属性了,所以 childA 的原型上不需要再有 name 和 colors 了,也就是 Child.prototype 上不需要有 name 和 colors 了。

所以,我们对 Child.prototype = new Parent() 这一步做一下改造。只让子类的原型对象拥有父类原型对象的属性和方法,而没有父类构造函数上的属性和方法。

设置

function inheritPrototype (Child, Parent) {
  let prototype = Object.create(Parent.prototype) // 子类原型对象原型指向 Parent.prototype
  prototype.constructor = Child // 原型对象的构造函数指向子类
  Child.prototype = prototype
}

现在代码就变成了

function Parent (name) {
  this.name = name
}
Parent.prototype.sayName = function () {
  console.log(this.name)
}
function Child (name) {
  Parent.call(this, name)
}
inheritPrototype(Child, Parent)
childA = new Child('li')
childB = new Child('jiang')

这种方式也有一个名字,叫寄生式组合继承。(寄生式+组合,寄生式是指 Object.create 这种方式,这里的寄生式,主要是指采用寄生式的方式继承父类原型)

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。 寄生式组合继承可以算是引用类型继承的最佳模式。

参考:《JavaScript 高级程序设计》