JS 常见继承方法详解 学习笔记

JS 几种继承方法详解:

关于js中继承的常用方式,其实网上都有很多详解。也对其做了很多优点和缺点的评价。对于初步学习继承的小白来说是很有帮助的。接下来我讲讲我学习过程中对继承的理解和总结。

类式继承:

其实类式继承在本质上是用一个构造函数的实例来充当另一个构造函数的原型,有点像我们熟知的原型链的结构。

我们来看一个栗子:

function Action(){
  //do something
}
Action.prototype.eat = function(){
  console.log( this.name + " is eat")
}
function Animo(name){
  this.name = name
}
Animo.prototype = new Action()
var tiger = new Animo("tiger")
tiger.eat()

在这里,我声明了两个构造函数:ActionAnimo,其中用Action作为父类,而Animo作为子类。默认的情况下,Animo的原型链是这样子的:tiger->Animo.prototype->Object.prototype->null,这是他的原型链,也是Animo实例查找属性或方法的路线,现在有一个问题。假如我们是底层开发者,我们要怎么来让Animo的实例能继承到其他构造函数的功能呢?其实答案就在上面。我们知道,既然实例是按着上面的原型链继承的,那我就破坏它的原型链,插入我们定义的其他的构造函数的功能。其实在上面代码实例中,我们就改变了tiger的原型链,现在的原型链:tiger->obj->Action.prototype->Object.prototype->null,objnew Action()的实例对象,当我们调用tiger.eat()的时候,我们很容易的发现,首先自身没有,然后 在obj中查找,没有继续向Action.prototype中找,然后就发现"卧槽,你怎么有这个玩意儿"。然后就拿过来使用了。其实这就是能够促成类式继承的一个核心要素吧。我们来说说这个方法的优缺点吧。

优点:

其实这个方法的优点很明显,第一是很容易实现,第二是他能够访问到父类实例的所有属性,也就是能够访问到obj的属性和方法。

缺点:

首先,正因为他能够访问到父类实例的所有属性和方法,这也成为了他的一个弊端。我并不想给你我的全部,我只想给你一点点而已。假如有人乱改obj中的数据,那就会出大问题,因为obj上的数据是共享的,不是你一个实例独占的,当你改掉之后会影响到其他的子类。然后该继承的方法还有一个缺点,假如说,我想通过父类来初始化我的数据,那么使用这个继承方法就很捉襟见肘了。接下来我们来介绍另一种继承方式,来看看它是怎么来解决这些问题的。

构造函数式继承:

其实构造函数式继承的本质是利用父类构造函数的执行来初始化自身的数据,这是什么意思呢,我们接下来会解释,我们先看一个栗子:

function Action(){
  this.actionName = ["eat","looklook","scream","and so on..."]
  this.eat = function(){
	console.log("this is eat action")
  }
   this.scream = function(){
       console.log("ao~~~~~~~")
	}
}
function Animo(name,sup){
  this.name = name
  sup.call(this)
}
var tiger = new Animo("tiger",Action)
console.log(tiger)
/*
Animo {
  name: 'tiger',
  actionName: [ 'eat', 'looklook', 'scream', 'and so on...' ]
}
*/

在解释这个继承之前,我们先来回顾一下Function.prototype.call()的用法:

Function.prototype.call:该方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。call() 提供新的 this 值给当前调用的函数/方法。也就是说,call可以改变函数内部的this指向。

我们现在来分析一下上面的代码。首先,我们有一个Action构造函数,该构造函数里面包含一些默认的行为名称,毕竟每个动物生下来就会有一些天生的技能。现在我们还有一个名叫Animo的构造函数,用来生成动物实例。现在,我们有一个需求,那就是当一个实例被初始化的时候,要有actionName的属性,但是,当我们修改里面的数据的时候,对任何的其他子类没有任何影响。既然对任何其他子类没有影响,也就是说,该属性是被独立在实例内部的。接下来的代码设计就是解决这个需求的关键。首先我们来看new Animo("tiger",Action)。我来解释一下当我们new一个构造函数的时候到底发生了什么。当我们new一个构造函数的时候,在底层会开辟一个内存空间,而这个内存空间的地址被赋值给了构造函数的this,然后开始执行构造函数。我们在执行构造函数的时候传入了两个参数"tiger"Action。现在我们开始执行Animo构造函数。

首先执行this.name = name,我们之前说过,this其实是一个地址值,这个地址值就是我们所说的实例对象的引用。也就是说,现在的实例对象中已经有一个叫name的属性了,并它的值为"tiger"。然后执行sup.call(this),其实每一个函数在执行的时候都会经过预编译过程,其中预编译过程决定了函数内部的this的指向。而call函数可以改变被执行函数内部的this指向,并将它改为我们所传入的this。当执行到sup.call(this)时,我们便进入到了Action函数内部,此时该函数的this已经被call函数改变了,现在指向的是我们的那个实例对象。所以执行this.actionName = [...]的时候,就等于在实例对象中添加一个名叫actionName的属性,并且赋值为["eat","looklook","scream","and so on..."]。然后添加其他的数据。当执行完这个语句后就从该函数中退出,然后进入到Animo的函数环境。发现也没啥代码需要执行了,则返回this存储的值,也就是实例对象的地址引用。有人可能会说,为什么会返回this的值,而不是undefine。(在函数中,没有手动返回一个具体值的话就会默认返回undefine)。原因在于我们使用了new操作符,其实构造函数和普通函数没有任何的区别,构造函数在没有用new操作符的时候,单独执行它就是一个普通的函数。因为有了new操作符后,一些底层的机制会被改变。现在我们说回我们的代码。当我们打印出tiger时我们发现。有四个值,一个是name另一个是actionName,另外还有两个方法。其实就是存在我们实例内存中的四个数据。我们发现,可以通过call方法来实现对父类内部数据的拷贝。不用通过先实例化父类,然后将父类的实例作为原型去继承。该继承的方法好在我们有一个父类数据的本地拷贝,想改就改,反正在我自己的内存中,和其他的子类没有任何关系,因为数据不共享。sup.call(this)是我们这个继承方法的核心。我们来说说它的优点和缺点。

优点:

其实优点就是有自己的父类数据备份,我们改变这个数据的时候,不用去考虑其他的子类。

缺点:

我们每一次实例化一个对象,那么就会有一份父类的数据的拷贝,这样浪费资源也违背了代码服用的原则。那么我们有没有更好的方法去解决这个问题呢?答案是有的,我们接下来要说说下一个继承方法:组合式继承。

组合式继承:

什么是组合式继承呢,其实组合式继承本质上是类式继承和构造函数式继承相结合。我们知道,假如我们只用类式继承,那么就会造成数据共享。如果我们只使用构造函数式继承,每次实例化我们的对象都会拷贝一份父类的数据,会相当耗费资源,并且没有做到代码复用。那我们是不是可以有一个想法,想法就是我们的父类属性让子类的实例拷贝,然后父类上的方法挂载到父类的原型上来继承呢。其实这个想法就是组合式继承的基本思想,我们来看一个例子:

function Action(){
  this.actionName = ["eat","looklook","scream","and so on..."]
}
Action.prototype.eat = function(){
  console.log("this is eating action")
}
Action.prototype.scream = function(){
  console.log("ao~~~~")
}
function Animo(name,sup){
  this.name = name
  sup.call(this)
}
Animo.prototype = new Action()
var tiger = new Animo("tiger",Action)
console.log(tiger.actionName)
tiger.eat()
tiger.scream()

结果:

[ 'eat', 'looklook', 'scream', 'and so on...' ]
this is eating action
ao~~~~

我们来分析一下上面的代码,首先,我们要取其精华,去其糟粕。我们把要使用的方法挂载到Action的原型上,这样我们可以通过继承来使用方法,那么我就不用去把这些方法拷贝一份。而我们用构造函数式的继承来拷贝我们要使用的父类的属性,为什么要拷贝呢?因为如果不拷贝的话,我们在使用继承的属性的话,万一别的人修改了共享的属性数据,那么我们再次去访问该属性就不是原来的了。可能会出现错误,所以我们要拷贝一份到自己的内存当中。此时我们不仅解决了数据共享而带来的麻烦,也实现了方法的代码的复用。有人可能会说,这也许就是继承的最终结果了吧。不,还不是。

优点:

组合式继承不仅解决了数据共享而带来的麻烦,也实现了方法的代码的复用。

缺点:

我们发现,我们在完成组合式继承的时候,一共调用了两次父类的构造函数。第一次是我们完成类式继承时来生成父类实例,为第二次是我们要拷贝父类属性数据调用一次。也就是说一共调用了两次。其实我们只调用一次就可以了。

组合式继承优化:

我们说过,我们用组合式继承模式的话,其实有一些代码是冗余的,例如:

var tiger = new Animo("tiger",Action)

这个代码其实目的是为了来继承来自原型的一些方法,我们实例化了一个对象,但是其实我们想想,这段断码是冗余的,为什么呢?因为它走了构造函数内部的代码。假如该构造函数的内部有很多复杂代码,其实我们这样做是不必要的,而我们只需要:

Animo.prototype = Action.prototype

这样我们既可以继承到父类的原型上的方法,也避免了多次执行我们的父类构造函数。

原型式继承:

我们来看一个新的继承方式,原型式继承。其实原型式的继承有点像类式继承。我们来看一段代码实例:

function inherit(obj){
  function bar(){}
  bar.prototype = obj
  return new bar() 
}

var myobj = inherit({name:'zs'})
console.log(myobj.name)
//zs

是不是对这种继承方式很熟悉呢,如果不熟悉的话我来替换一下:

var myobj = Object.create({name:"zs"})
console.log(myobj.name)
//zs

其实Object.create的内部代码和我们写的代码类似,主要是通过一个中间媒介,这里的是bar函数,通过把我们要继承的对象挂载到bar.prototype上,即让bar.prototype属性来存储我们要继承的对象的地址引用。然后我们new bar()的时候,把生成的对象的地址引用返回给外界的myobj。我们可以看到,原型式继承利用了一个空对象,通过空对象这个媒介来继承obj上的属性和方法。有人说这不就是类式继承吗,没错他是类式继承的一次封装,所以它的缺点在类式继承里都有,但是为什么要讲它呢?因为要为后面我们讲的寄生式继承做一个铺垫。

优点:

能够简单的继承目标对象的属性。

缺点:

数据会被共享和篡改。

寄生式继承:

什么是寄生式继承,其实这个名字有点抽象,我先用语言描述一遍,然后我们来用代码举例。寄生式继承实际上是对原型式继承的二次封装,并且在第二封装的过程中对继承的对象进行其他的扩展,这样做不仅可以继承父类的属性,还可以动态添加我们自己想要的属性。这就是寄生式继承。例如:

var obj = {
    name:"father"
}
function inherit(obj){
    function Ichina(){}
    Ichina.prototype = obj
    var newobj = new Ichina()
    return newobj
}

function secInherit(obj){
    var o = inherit(obj)
        o.age = 21
    return o
}

var myobj = secInherit(obj)
console.log(myobj.name)
console.log(myobj.age)
//father
//21    

其实这个代码很简单,首先我们需要让obj成为我们媒介构造函数的原型,所以我们来封装一个函数inherit来让它完成这个过程,也就是说从inherit返回的实例继承里我们的obj内的属性。然后我们再来封装一个函数,这个函数干什么用的呢,这个函数用来动态追加属性和方法的。我们首先var o = inherit(obj)也就是把生成的实例对象的地址引用返回给变量o,然后再动态的为o添加我们想要的属性或者方法,而这个过程全程发生在secInherit这个函数里。相当于对原型式继承的第二次封装。我们可以发现,在第二次封装过程中,并没有新的对象出现,o存储的地址引用就是newobj的地址值。所以他是在第二次封装的函数内给实例内存中添加属性的。这样其实可以充分的利用inherit这个函数,也就是继承和动态添加放在不同函数中进行。

缺点:

其实这样做的缺点其实来源于它的核心,它的核心是利用的是原型式继承,那么原型式的缺点都适合它。那么怎么解决呢?

寄生组合式继承:

我们知道,寄生式继承的诟病就是来源于原型式继承,而原型式继承类似于类式继承,那么我们是怎么解决类式继承的缺点的呢,就是用构造函数继承的思想诞生的组合式继承来解决的。我们也可以借鉴里面的思想。其实所谓的寄生组合式继承是把寄生式继承和组合式继承结合在一起,来规避寄生式继承的不足。我们来举个例子:

function father(){
    this.height = 180
}
father.prototype.getMoney = function(){
    console.log("i can make money")
}
function son(){
    father.call(this)
    this.wight = 150
}
function inherit(obj){
    function F(){}
    F.prototype = obj
    var p = new F()
    return p
}
function createObject(sub,sup){
    var o = inherit(sup.prototype)
        o.constructor = sub
        sub.prototype = o
}
createObject(son,father)
son.prototype.buySomethings = function(){
    console.log("i can use my father's money")
}
var obj = new son()
console.log(obj.height)
console.log(obj.wight)
obj.getMoney()
obj.buySomethings()

结果:

180
150
i can make money
i can use my father's money

代码有一点点复杂,但问题不大,我们来慢慢分析。首先我们定义了一个父类father和一个子类son,其中我们在子类中利用father.call(this)来对父类的属性进行拷贝。此时我们就用了构造函数继承,然后我们定义一个函数inherit,用它来进行原型式的继承,利用一个中间媒介F函数来让p继承传入的obj内的属性或方法。然后我们定义createObject函数,对原型式的继承进行二次封装。在这里我们声明一个变量o,其实这个变量和p存的值一样,都是实例的地址值。将o.constructor指向sub,原因是因为o将会成为sub的原型,所以要重修一下它的constructor。然后我们执行createObject(son,father)。我们可以走一下这个程序,一旦执行完,此时father的实例就成为了son的原型,继承father原型上的方法。然后我们在son的原型上可以添加我们想添加的额外的方法。当我们执行new son()的时候,我们就可以从父类拷贝属性。

以上 所讲的是寄生组合式的基本构成。它是前面的继承方式的改进。我们来看看obj的原型链:

obj->(son.prototype<==>o<==>p)->father.prototype->Object.prototype->null

其实寄生组合式继承不是很复杂。

总结:上面是我在学习继承的时候对继承的理解和一点点感悟。有错误的地方请指正,谢谢!

其实我们从上面的继承方法的改进可以看到,其实当我们在做任何一件事情的时候,一开始的解决方案大多数都不是最优的,当我们遇到短板的时候都会去尽力的去改进它。让它最终趋于完美,这都是要经历挫折的。慢慢的弥补。其实我觉得学习也是一样,无论学什么,我们最开始的方法都不是最优解,也需要慢慢的去改变去完善,可能会很茫然,但是随着时间的推移,你一定会变得清晰。加油!我亲爱的陌生人!让我们一起砥砺前行!

点个赞呗 ^_^。。

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值