JavaScript 中的继承

JavaScript 中的继承

在一些基于类的语言(比如 Java)当中,继承(inheritance/extends)提供了两个有用的服务,如果一个新的类与一个已存在的类大部分相似,那么你只需说明其不同点即可,JavaScript 是一门弱类型语言,从不需要类型转换,它可以模拟那些基于类的模式,同时它也支持其他更具表现力的模式

在基于类的语言中,对象是类的实例,并且类可以用另一个类继承,JavaScript 是一门基于原型的语言,这意味着对象也是可以直接从其他对象继承,在 JavaScript 当中比较常见的继承方法有类式继承和原型继承

类式继承(构造函数继承)

JavaScriptES5 当中其实是没有类的概念的,所谓的类也是模拟出来的,特别是当我们是用 new 关键字的时候,就使得类的概念就越像其他语言中的类了,类式继承在子类的构造函数中执行父类的构造函数,并为其绑定子类的 this,让父类的构造函数把成员属性和方法都挂到子类的 this 上去,这样既能避免实例之间共享一个原型实例,又能向父类构造方法传参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent(name) {
this.name = [name]
}

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

function Child() {
// 执行父类构造方法并绑定子类的 this,使得父类中的属性能够赋到子类的 this 上
Parent.call(this, 'zhangsan')
}

const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'

console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // 报错,因为找不到 getName()

但是上面这种方式是存在一定缺点的,那就是类式继承是继承不到父类原型上的属性和方法

原型继承

原型继承有别于类式继承,因为继承不在对象本身,而在对象的原型上(prototype),每一个对象都有原型,在浏览器中它体现在一个隐藏的 __proto__ 属性上,在一些现代浏览器中你可以更改它们(不过不太建议这样操作)

它的原理是直接让子类的原型对象指向父类实例,当子类实例找不到对应的属性和方法时,就会往它的原型对象,也就是父类实例上找,如果没有找到,它会再次往下继续查找,这样逐级查找,一直找到了要找的方法,这些查找的原型构成了该对象的原型链条(最后指向 Object.prototype.__proto__ 也就是 null),类似于下面这样

1
2
3
4
5
6
7
8
9
10
11
function Foo() {}
var f1 = new Foo()

// f1 的 __proto__ 是指向 Foo.prototype 的
f1.__proto__ === Foo.prototype

// 而 Foo.prototype.__proto__ 又是指向 Object.prototype
Foo.prototype.__proto__ === Object.prototype

// 而 Object.prototype.__proto__ 则是指向 null 的
Object.prototype.__proto__ === null

下面我们再来看看原型继承的例子,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Parent() {
this.name = 'zhangsan'
}

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

function Child() { }

// 让子类的原型对象指向父类实例,这样一来在 Child 实例中找不到的属性和方法就会到原型对象(父类实例)上寻找
Child.prototype = new Parent()

// 根据原型链的规则,顺便绑定一下constructor,这一步不影响继承,只是在用到 constructor 时会需要
Child.prototype.constructor = Child

// 然后 Child 实例就能访问到父类及其原型上的 name 属性和 getName() 方法
const child = new Child()
child.name // 'zhangsan'
child.getName() // 'zhangsan'

下面我们再来看看原型继承的缺点,它有下面两点

  • 由于所有 Child 实例原型都指向同一个 Parent 实例,因此对某个 Child 实例的父类引用类型变量修改会影响所有的 Child 实例
  • 在创建子类实例时无法向父类构造传参,即没有实现 super() 的功能

我们可以用代码来测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent() {
this.name = ['zhangsan']
}

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

function Child() { }

Child.prototype = new Parent()
Child.prototype.constructor = Child

const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'

console.log(child1.name) // ['foo']
console.log(child2.name) // ['foo'](预期是 ['zhangsan'],但是对 child1.name 的修改引起了所有 child 实例的变化)

组合式继承

既然原型继承和类式继承各有互补的优缺点,那么我们为什么不组合起来使用呢,所以下面就有了综合二者的组合式继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Parent(name) {
this.name = [name]
}

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

function Child() {
Parent.call(this, 'zhangsan')
}

Child.prototype = new Parent()
Child.prototype.constructor = Child

const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'

console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // ['zhangsan']

但是先别急着高兴,组合式继承也是存在着一定缺点的,那就是每次创建子类实例都会执行两次构造函数(call()new 操作),虽然这并不影响对父类的继承,但子类创建实例时,原型中会存在两份相同的属性和方法,这并不优雅

寄生式组合继承

所以,为了解决构造函数被执行两次的问题,我们将指向父类实例改为指向父类原型,减去一次构造函数的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Parent(name) {
this.name = [name]
}
Parent.prototype.getName = function () {
return this.name
}
function Child() {
Parent.call(this, 'zhangsan')
}

// 这里我们将指向父类实例的方式调整为指向父类原型
Child.prototype = Parent.prototype
Child.prototype.constructor = Child

const child1 = new Child()
const child2 = new Child()
child1.name[0] = 'foo'

console.log(child1.name) // ['foo']
console.log(child2.name) // ['zhangsan']
child2.getName() // ['zhangsan']

但这种方式存在一个问题,由于子类原型和父类原型指向同一个对象,我们对子类原型的操作会影响到父类原型,例如给 Child.prototype 增加一个 getName() 方法,那么会导致 Parent.prototype 也增加或被覆盖一个 getName() 方法,为了解决这个问题,我们在这里可以使用 Obeject.create() 这个方法来进行创建(关于 Object.create() 方法更为详细的用法可以参考 Object.create()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Parent(name) {
this.name = [name]
}

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

function Child() {
Parent.call(this, 'zhangsan')
}

Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child

const child = new Child()
const parent = new Parent()
child.getName() // ['zhangsan']
parent.getName() // 报错,找不到 getName()

以上便是我们最终完善后的继承方式,也称为寄生组合式继承,它是目前最为成熟的继承方式,在 BabelES6 中的继承的转化当中也是使用了寄生组合式继承,下面我们就来简单的了解一下

编译后的 extends

我们都知道,ES6 的代码最后都是要在浏览器上能够跑起来的,这中间就利用了 Babel 这个编译工具,将 ES6 的代码编译成 ES5 让一些不支持新语法的浏览器也能运行,下面我们就来看看 extends 被编译成了什么样子,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function _possibleConstructorReturn(self, call) {
// ...
return call && (typeof call === 'object' || typeof call === 'function') ? call : self
}

function _inherits(subClass, superClass) {
// ...
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
})
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass
}


var Parent = function Parent() {
// 验证是否是 Parent 构造出来的 this
_classCallCheck(this, Parent)
}

var Child = (function (_Parent) {
_inherits(Child, _Parent)
function Child() {
_classCallCheck(this, Child)
return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments))
}
return Child
}(Parent))

核心是 _inherits 函数,我们可以发现它采用的是我们上面介绍到的『寄生组合继承』方式,同时证明了这种方式的成功,不过这里增加了一个额外的操作 Object.setPrototypeOf(subClass, superClass),它的作用则是用来继承父类的静态方法,这也是我们原来的继承方式所疏忽掉的地方

继承本身的问题

我们下面来从设计思想上简单的谈谈继承本身的问题,我们可能听闻过面向对象的设计的方式,那么面向对象的设计的方式一定就是好的设计吗?当然这个需要根据使用场景来进行区分,如果从继承的角度说,这一设计是存在巨大隐患的,假如我们现在有不同品牌的车,每辆车都有 drivemusicaddOil 这三个方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Car {
constructor(id) {
this.id = id
}
drive() {
console.log(`drive`)
}
music() {
console.log(`music`)
}
addOil() {
console.log(`addOil`)
}
}

class otherCar extends Car { }

我们现在可以实现车的功能,并且以此去扩展不同的车,但是问题来了,新能源汽车也是车,但是它并不需要 addOil(加油),如果让新能源汽车的类继承 Car 的话,也是有问题的,而这也是俗称的『大猩猩和香蕉』的问题

大猩猩手里有香蕉,但是我们现在明明只需要香蕉,却拿到了一只大猩猩,也就是说加油这个方法,我们现在是不需要的,但是由于继承的原因,也给到子类了,所以我们可以发现

继承的最大问题在于『无法决定继承哪些属性,所有属性都得继承』

当然有人可能会说,我们可以再创建一个父类,把加油的方法给去掉,但是这也是有问题的,一方面父类是无法描述所有子类的细节情况的,为了不同的子类特性去增加不同的父类,代码势必会大量重复,另一方面一旦子类有所变动,父类也要进行相应的更新,代码的耦合性太高,维护性不好,那我们该如何来解决继承的诸多问题呢?

我们可以借住 React 当中的 HOC 的方式,利用『组合』来维护我们的继承,这也是当今编程语法发展的趋势,顾名思义,『组合』就是先设计一系列零件,然后将这些零件进行拼装,来形成不同的实例或者类

1
2
3
4
5
6
7
8
9
10
11
12
13
function drive() {
console.log(`drive`)
}
function music() {
console.log(`music`)
}
function addOil() {
console.log(`addOil`)
}

let car = compose(drive, music, addOil)

let otherCar = compose(drive, music)

可以发现,代码干净,复用性也很好,而这也就是面向组合的设计方式

总结

我们回顾一下继承的实现过程

  • 我们首先采用了类式继承,通过在子类构造函数中调用父类构造函数并传入子类 this 来获取父类的属性和方法,但类式继承存在着不能继承父类原型链上的属性和方法的缺陷
  • 接着我们采用了原型继承,通过把子类实例的原型指向父类实例来继承父类的属性和方法,但原型继承的缺陷在于对子类实例继承的引用类型的修改会影响到所有的实例对象以及无法向父类的构造方法传参
  • 所以我们综合了两种继承的优点,提出了组合式继承,但组合式继承也引入了新的问题,它每次创建子类实例都执行了两次父类构造方法,所以我们通过采用 Obeject.create() 来替换掉使用 call() 方法会执行父类构造方法的缺点,也就是我们最终的寄生式组合继承的实现

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×