浅谈JavaScript的几种继承方式

141 阅读6分钟

继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承。 前者只继承方法签名,后者继承实际的方法。接口继承在 ECMAScript 中是不可能的,因为函数没有签名。实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。-----《JavaScript高级程序设计》

原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。

要知道原型链是啥我们首先得明白什么是原型,那么简单讲一下原型:

  • 每个函数都会自动生成prototype属性,这个属性就是原型对象
  • 原型包含应该由特定引用类型的实例共享的属性和方法
  • 原型有constructor属性指回构造函数
  • 构造函数的实例有内部指针[[prototype]]指向prototype(浏览器一般用__proto__来暴露)

这里说明一点,构造函数也是函数。两者之间的区别就是是否使用 new 操作符。

function Person() {
}

Person.prototype.name = '小白'
Person.prototype.sayHi = function () {
    console.log(`Hi,我是${this.name}`)
}

// 原型有constructor属性指回构造函数
console.log(Person.prototype.constructor === Person)    // true

let person1 = new Person()
let person2 = new Person()
// 构造函数的实例有内部指针[[prototype]]指向prototype
console.log(person1.__proto__ === Person.prototype)    // true
console.log(person1.__proto__ === person2.__proto__)    // true
person1.sayHi()    // Hi,我是小白
person2.sayHi()    // Hi,我是小白

原型链,顾名思义,一堆原型串在一起。理解原型之后在理解原型链我们只需要理解是如何串在一起的,也就明白原型链是什么意思了。

总结一下就是一句话,子构造函数的原型是父构造函数的实例

function Father() {
}

function Son() {

}

Son.prototype = new Father()
Son.prototype.constructor = Son

这里说明一下为什么要单独把Son原型的constructor属性指回son。

根据上文说到的:每个函数都会自动生成prototype属性,这个属性就是原型对象。原型有constructor属性指回构造函数。Son原型的constructor属性一开始确实是指向Son的,但是后来Son原型被重写成Father的实例了。Father的实例自然是没有constructor属性的,自然需要我们为Son原型新增一个constructor属性,并将其指回Son。但是这么写存在问题,当我们显式新增属性时,属性内部特性默认值是true。所以我们更严谨一点的写法应该是用Object.defineProperty()

// 可以不写[[Configurable]],[[Enumerable]],[[Writable]],默认是false
Object.defineProperty(Son.prototype, 'constructor', { value: Son })

原型链实现继承十分简单,必然也存在缺陷。原型中包含的引用值会在所有实例间共享,这个问题自然在原型链也存在。

function Father() {
}
Father.prototype.idols = ['周杰伦', '成龙']

function Son() {

}

Son.prototype = new Father()
Object.defineProperty(Son.prototype, 'constructor', { value: Son })

let son1 = new Son()
let son2 = new Son()
console.log(son2.idols)    // [ '周杰伦', '成龙' ]
son1.idols.push('迪迦')
console.log(son2.idols)    // [ '周杰伦', '成龙', '迪迦' ]
son1.idols = ['迪迦']
console.log(son1.idols)    // [ '迪迦' ]
console.log(son2.idols)    // [ '周杰伦', '成龙', '迪迦' ]

首先我们得清楚原型链查找顺序:先查实例,实例没有向上找原型(是另一个构造函数的实例),若没有继续向上找原型

son1,son2自身并没有idols属性,只能去原型链上找,son.prototype 也就是Father的一个实例上也没有idols属性,所以只能去Father的原型上去找。所以当我们操作idols属性时,实际上是在操作Father.prototype.idols。

那么我们又发现为什么son1.idols=['迪迦']时,属性没有共享呢?

抛开你对原型,原型链的一切认知,单纯的从对象出发,这不就是给son1增加一个idols属性嘛,压根就没对Father.prototype.idols进行操作。从这里我们就又发现,实例的属性可以遮蔽原型的属性

盗用构造函数

那么如何解决引用值的问题呢?盗用构造函数就登场了!

盗用构造函数,又称对象伪装,经典继承。

核心思想:子类构造函数调用父类构造函数

function Father() {
    this.idols = ['周杰伦', '成龙']
    this.sayHi= function () {
        console.log('Hi')
    }
}

function Son() {
    Father.call(this)
}


let son1 = new Son()
let son2 = new Son()
son1.idols.push('迪迦')
console.log(son1.idols)    // [ '周杰伦', '成龙', '迪迦' ]
console.log(son2.idols)    // [ '周杰伦', '成龙' ]
son1.sayHi()    // Hi
son2.sayHi()    // Hi
console.log(son1.sayHi == son2.sayHi)    // false

嗯,不错解决了引用值共享的问题。但是细心的小伙伴已经所在了,son1,son2的sayHi()的功能一模一样,但却是两个函数!明明能共享的东西却没有共享!

组合继承

又称伪经典继承,是使用最多的集继承模式。

盗用构造函数的方法做到了继承属性(解决引用值),但是没能解决共享方法。

原型链继承的方法做到了继承属性(未解决引用值),共享方法。那两者结合一下不就行了嘛!

function Father() {
    this.idols = ['周杰伦', '成龙']

}
Father.prototype.sayHi = function () {
    console.log('Hi')
}

// 继承属性(解决引用)
function Son() {
    Father.call(this)
}
// 共享方法
Son.prototype = new Father()
Object.defineProperty(Son.prototype, 'constructor', { value: Son })

let son1 = new Son()
let son2 = new Son()
son1.idols.push('迪迦')
console.log(son1.idols)    // [ '周杰伦', '成龙', '迪迦' ]
console.log(son2.idols)    // [ '周杰伦', '成龙' ]
son1.sayHi()    // Hi
son2.sayHi()    // Hi
console.log(son1.sayHi == son2.sayHi)    // true

这下所有问题全部都解决了,但是还是不完美!细心的小伙伴可能已经发现了,为了实现继承,构造函数Father()被调用了两次,继承属性时调用了一次,共享方式时又调用了一次。有没有办法只调用一次就实现继承呢?别着急这个问题一会再解决,我们先引入一些新东西。

原型式继承

假设我现在有一个对象,我想在这个对象的基础上实现信息共享,那么应该如何实现呢?

很简单,这个对象就是原型嘛(虽然没有它的构造函数)

也就是把之前的 Son.prototype = new Father()变成Son.prototype = object

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

let person1 = {
    name: 'Mac'
}

let person2 = object(person1)  // 等效于person2=Object.create(person1)

通过object()函数,我们成功创造了一个对象,且对象的原型是参数,这一步等效于Object.create()只有一个参数的情况。 使用的手法与原型链继承类似,所以也存在引用值的问题,不过我们不关心这个问题,因为本身就是为了实现信息共享才创建这个对象的。

寄生式继承

寄生式继承 = 原型式继承上 + 以某种方式增强对象

function createAnother(original) {
    // 原型式继承
    let clone = object(original);    // 等效于 clone = Object.create(original)
    // 以某种方式增强这个对象
    clone.sayHi = function () {
        console.log("hi");
    };
    return clone;
}

寄生式组合继承

叨叨了半天,终于可以来解决组合继承调用两次构造函数的问题了。

首先我们把继承拆分成两部分,继承属性和共享方法

之前的组合继承中,继承属性(解决引用)我们使用 盗用构造函数解决,调用了一次构造函数;共享方法我们使用了原型链继承,又调用了一次构造函数。

看到寄生式组合继承,小伙伴们估计也就想到了,用寄生式继承去实现共享方法!那么参数是谁?自然是父类的原型呀!

// 组合继承,共享方法
Son.prototype = new Father() 
Object.defineProperty(Son.prototype, 'constructor', { value: Son })

// 寄生式组合继承,共享方法
/**
 * 在父类的原型的基础上,创建一个对象,且这个对象是子类的原型
 * @param {*} subType 子类
 * @param {*} superType 父类
 */
function inheritPrototype(subType, superType) {
    // 创建对象
    let prototype = Object.create(superType.prototype)
    // 增强对象
    prototype.constructor = subType
    // 继承父类的方法
    subType.prototype = prototype;
}

寄生式组合继承与组合继承的区别 用寄生式继承去实现new操作符

寄生式组合继承可以算是引用类型继承的最佳模式

新手上路,老司机轻点喷,谢谢指正!