夯基础之手撕javascript继承详解

(编辑:jimmy 日期: 2024/12/23 浏览:2)

前言

提到JS继承,你首先想到的什么? 面试 继承方式 优缺点...,js继承作为曾经的苦主,我看了忘,忘了看,看了又忘,OMG,都9012年了面试官还不放过我。

ok,开开玩笑,接下来言归正传,来聊聊js继承这个经典的话题。

JS的“类”

javascript不像java,php等传统的OOP语言,js本身并没有类这个概念,那么它是怎么实现类的模拟呢?

  1. 构造函数方式
  2. 原型方式
  3. 混合方式

构造函数方式

Function Foo (name) {
 this.name = name
 this.like = function () {
 console.log(`like${this.name}`)
 }
}
let foo = new Foo('bibidong')

像这样就是通过构造函数的方式来定义类,其实和普通函数一样,但为了和常规函数有个区分,一般把函数名首字母大写。

缺点:无法共享类的方法。

原型方式

function Foo (name) {}
Foo.prototype.color = 'red'
Foo.prototype.queue = [1,2,3]
let foo1 = new Foo()
let foo2 = new Foo()

foo1.queue.push(4)
console.log(foo1) // [1, 2, 3, 4]
console.log(foo2) // [1, 2, 3, 4]

我们通过原型方式直接把属性和方法定义在了构造函数的原型对象上,实例可以共享这些属性和方法,解决了构造函数方式定义类的缺点。

缺点:可以看到我们改变了foo1的数据,结果foo2的queue属性也变了,这便是原型方式最大的问题,引用类型的属性会被其它实例修改。除此之外,这种方式下也无法传参。

混合方式

function Foo (name) { // 属性定义在构造函数里面
 this.name = name
 this.color = 'red'
 this.queue = [1,2,3]
}
Foo.prototype.like = function () { // 方法定义在原型上
 console.log(`like${this.name}`)
}
let foo1 = new Foo()
let foo2 = new Foo()

所谓混合模式,便是把上面两种方式混合起来,我们在构造函数里面定义属性,在原型对象上定义要共享的方法,既能传参,也避免了原型模式的问题。

小结一下:js类的能力是模拟出来的,可以通过构造函数方式,原型方式来定义,混合模式则聚合了前两者的优点。除此,还有Object.create(), es6的class,都可以来创建对象,定义类。

常见的继承方式

一、原型链继承

基于原型链查找的特点,我们将父类的实例作为子类的原型,这种继承方式便是原型链继承。

function Parent () {
 this.color = 'red'
 this.queue = [1,2,3]
}
Parent.prototype.like = function () {
 console.log('')
}

function Child () { }
Child.prototype = new Parent() // constructor指针变了 指向了Parent
Child.prototype.constructor = Child // 手动修复

let child = new Child()

Child.prototype相当于是父类Parent的实例,父类Parent的实例属性被挂到了子类的原型对象上面,拿color属性举个例子,相当于就是这样

Child.prototype.color = 'red'

这样父类的实例属性都被共享了,我们打印一下child,可以看到child没有自己的实例属性,它访问的是它的原型对象。

夯基础之手撕javascript继承详解

我们创建两个实例child1,child2

let child1 = new Child()
let child2 = new Child()
child1.color = 'bulr'
console.log(child1)
console.log(child2)

夯基础之手撕javascript继承详解

我们修改了child1的color属性,child2没有受到影响,并非是其它实例拥有独立的color属性,而是因为这个color属性直接添加到了child1上面,它原型上的color并没有动,所以其它实例不会受到影响从打印结果也可以清楚看到这一点。那如果我们修改的属性是个引用类型呢?

child1.queue = [1,2,3,'我被修改了'] // 重新赋值
child1.like = function () {console.log('like方法被我修改了')}
console.log(child1)
console.log(child2)

夯基础之手撕javascript继承详解

我们重写了引用类型的queue属性和like方法,其实和修改color属性是完全一样的,它们都直接添加到了child1的实例属性上。从打印结果能看到这两个属性已经添加到了child1上了,而child2并不会受到影响,再来看下面这个。

child1.queue.push('add push') // 这次没有重新赋值
console.log(child1)
console.log(child2)

夯基础之手撕javascript继承详解

如果进行了重新赋值,会添加到到实例属性上,和原型上到同名属性便无关了,所以并不会影响到原型。这次我们采用push方法,没有开辟新空间,修改的就是原型。child2的queue属性变化了,子类Child原型上的queue属性被实例修改,这样肯定就影响到了所有实例。

缺点

  • 子类的实例会共享父类构造函数引用类型的属性
  • 创建子类实例的时候无法传参

二、构造函数式继承

相当于拷贝父类的实例属性给子类,增强了子类构造函数的能力

function Parent (name) {
 this.name = name
 this.queue = [1,2,3]
}
Parent.prototype.like = function () {
 console.log(`like${this.name}`)
}

function Child (name) {
 Parent.call(this, name) // 核心代码
}

let child = new Child(1)

夯基础之手撕javascript继承详解

我们打印了一下child,可以看到子类拥有父类的实例属性和方法,但是child的__proto__上面没有父类的原型对象。解决了原型链的两个问题(子类实例的各个属性相互独立、还能传参)

缺点

  • 子类无法继承父类原型上面的方法和属性。
  • 在构造函数中定义的方法,每次创建实例都会再创建一遍。

三、组合继承

人如其名,组合组合,一定把什么东西组合起来。没错,组合继承便是把上面两种继承方式进行组合。

function Parent (name) {
 this.name = name
 this.queue = [1,2,3]
}
Parent.prototype.like = function () {
 console.log(`like${this.name}`)
}

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

Child.prototype = new Parent()
Child.prototype.constructor = Child  // 修复constructor指针
let child = new Child('')

接下来我们做点什么,看它组合后能不能把原型链继承和构造函数继承的优点发扬光大

let child1 = new Child('bibidong')
let child2 = new Child('huliena')
child1.queue.push('add push')
console.log(child1)
console.log(child2)

夯基础之手撕javascript继承详解

我们更新了child1的引用属性,发现child2实例没受到影响,原型上的like方法也在,不错,组合继承确实将二者的优点发扬光大了,解决了二者的缺点。组合模式下,通常在构造函数上定义实例属性,在原型对象上定义要共享的方法,通过原型链继承方法让子类继承父类构造函数原型上的方法,通过构造函数继承方法子类得以继承构造函数的实例属性,是一种功能上较完美的继承方式。

缺点:父类构造函数被调用了两次,第一次调用后,子类的原型上拥有了父类的实例属性,第二次call调用复制了一份父类的实例属性作为子类Child的实例属性,那么子类原型上的同名属性就被覆盖了。虽然被覆盖了功能上没什么大问题,但这份多余的同名属性一直存在子类原型上,如果我们删除实例上的这个属性,实际上还能访问到,此时获取到的是它原型上的属性。

Child.prototype = new Parent() // 第一次构建原型链
Parent.call(this, name) // 第二次new操作符内部通过call也执行了一次父类构造函数

四、原型式继承

将一个对象作为基础,经过处理得到一个新对象,这个新对象会将原来那个对象作为原型,这种继承方式便是原型式继承,一句话总结就是将传入的对象作为要创建的新对象的原型。

先写下这个有处理能力的函数

function prodObject (obj) {
 function F (){
  
 }
 F.prototype = obj
 return new F() // 返回一个实例对象
}

这也是Object.create()的实现原理,所以用Object.create直接替换prodObject函数是ok的

这也是Object.create()的实现原理,所以用Object.create直接替换prodObject函数是ok的

let base = {
 color: 'red',
 queue: [1, 2, 3]
}
let child1 = prodObject(base)
let child2 = prodObject(base)
console.log(child1)
console.log(child2)

夯基础之手撕javascript继承详解

原型式继承基于prototype,和原型链继承类似,这种继承方式下实例没有自己的属性值,访问到也是原型上的属性。

缺点:同原型链继承

五、寄生式继承

原型式继承的升级,寄生继承封装了一个函数,在内部增强了原型式继承产生的对象。

function greaterObject (obj) {
 let clone = prodObject(obj)
 clone.queue = [1, 2, 3]
 clone.like = function () {}
 return clone
}
let parent = {
 name: 'bibidong',
 color: ['red', 'bule', 'black']
}
let child = greaterObject(parent)

打印了一下child,它的缺点也很明显了,寄生式继承增强了对象,却也无法避免原型链继承的问题。

缺点

  • 拥有原型链继承的缺点
  • 除此,内部的函数无法复用

六、寄生组合式继承

大招来了,寄生组合闪亮登场!

上面说到,组合继承的问题在于会调用二次父类,造成子类原型上产生多余的同名属性。Child.prototype = new Parent(),那这行代码该怎么改造呢?

我们的目的是要让父类的实例属性不出现在子类原型上,如果让Child.prototype = Parent.prototype,这样不就能保证子类只挂载父类原型上的方法,实例属性不就没了吗,代码如下,看起来好像是简直不要太妙啊。

function Parent (name) {
 this.name = name
 this.queue = [1,2,3]
}
Parent.prototype.like = function () {
 console.log(`like${this.name}`)
}

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

Child.prototype = Parent.prototype // 只改写了这一行
Child.prototype.constructor = Child
let child = new Child('')

回过神突然发现改写的那一行如果Child.prototype改变了,那岂不是直接影响到了父类,举个栗子

Child.prototype.addByChild = function () {}
Parent.prototype.hasOwnProperty('addByChild') // true

addByChild方法也被加到了父类的原型上,所以这种方法不够优雅。同样还是那一行,直接访问到Parent.prototype存在问题,那我们可以产生一个以Parent.prototype作为原型的新对象,这不就是上面原型式继承的处理函数prodObject吗

Child.prototype = Object.create(Parent.prototype) // 改为这样

这样就解决了所有问题,我们怕改写Child.prototype影响父类,通过Object.create返回的实例对象,我们将Child.prototype间接指向Parent.prototype,当再增加addByChild方法时,属性就和父类没关系了。

寄生组合式继承也被认为是最完美的继承方式,最推荐使用。

总结

js的继承方式主要就这六种,es6的继承是个语法糖,本质也是基于寄生组合。这六种继承方式,其中原型链继承和构造函数继承最为基础和经典,组合继承聚合了它们二者的能力,但在某些情况下会造成错误。原型式继承和原型链相似,寄生式继承是在原型式继承基础上变化而来,它增强了原型式继承的能力。最后的寄生组合继承解决了组合继承的问题,是一种最为理想的继承方式。