彻底搞懂JavaScript中的继承

你应该知道,JavaScript是一门基于原型链的语言,而我们今天的主题 -- “继承”就和“原型链”这一概念息息相关。甚至可以说,所谓的“原型链”就是一条“继承链”。有些困惑了吗?接着看下去吧。

一、构造函数,原型属性与实例对象

要搞清楚如何在JavaScript中实现继承,我们首先要搞懂构造函数原型属性实例对象三者之间的关系,让我们先看一段代码:

function Person(name, age) {
    var gender = girl // ①
    this.name = name // ②
    this.age = age
}

// ③
Person.prototype.sayName = function() { 
    alert(this.name) 
}

// ④
var kitty = new Person('kitty', 14)

kitty.sayName() // kitty

让我们通过这段代码澄清几个概念:

  • Person是一个“构造函数”(它用来“构造”对象,并且是一个函数),①处gender是该构造函数的“私有属性”,②处的语句定义了该构造函数的“自有属性”;
  • ③处的prototypePerson的“原型对象”(它是实例对象的“原型”,同时它是一个对象,但同时它也是构造函数的“属性”,所以也有人称它为“原型属性”),该对象上定义的所有属性(和方法)都会被“实例对象”所“继承”(我们终于看到这两个字了,但是不要心急,我们过一会才会谈论它);
  • ④处的变量“kitty”的值是构造函数Person的“实例对象”(它是由构造函数生成的一个实例,同时,它是一个对象),它可以访问到两种属性,一种是通过构造函数生成的“自有属性”,一种是原型对象可以访问的所有属性;

对以上这些概念有清楚的认识,才能让你对JavaScript的“继承”与“原型链”的理解更加深刻,所以务必保障你已经搞清楚了他们之间的关系。(如果没有,务必多看几遍,你可以找张纸写写画画,我第一次就是这么做的)

彻底搞清楚了?那让我们继续我们的主题 -- “继承”。

你是否觉得奇怪,为什么我们的实例对象可以访问到构造函数原型属性上的属性(真是拗口)?答案是因为“每一个对象自身都拥有一个隐式的[[proto]]属性,该属性默认是一个指向其构造函数原型属性的指针”(其实我想说它是一个钩子,在对象创建时默认“勾住”了其构造函数的原型属性,但是我发现emoji居然没有钩子的图标,所以...🤷🏻‍♂️,不过我还是觉得钩子更形象些...)。

当JavaScript引擎发现一个对象访问一个属性时,会首先查找对象的“自有属性”,如果没有找到则会在[[proto]]属性指向的原型属性中继续查找,如果还没有找到的话,你知道其实原型属性也是一个对象,所以它也有一个隐式的[[proto]]属性指向它的原型属性...,正如你所料,如果一直没有找到该属性,JavaScript引擎会一直这样找下去,直到找到最顶部构造函数Objectprototype原型属性,如果还是没有找到,会返回一个undefined值。这个不断查找的过程,有一个形象生动的名字“攀爬原型链”。

现在你应该对“原型链”就是“继承链”这一说法有点感觉了吧,让我们暂时休息一下,对两个我们遗漏的知识点补充说明:

  1. 隐式的[[proto]]属性
  2. 原型对象prototype

(一)隐式的[[proto]]属性

何为“隐式属性”呢?即是开发者无法访问却确实存在的属性,你可能会问,既然是隐式的,如何证明它的存在呢?问得好,答案是虽然JavaScript语言没有暴露给我们这个属性,但是浏览器却帮助我们可以获取到该属性,在Chorme中,我们可以通过浏览器为对象添加的_proto_属性访问到[[proto]]的值。你可以自己试试在控制台中打印这个属性,证明我没有说谎。

(二)原型对象prototype

还记的我们之前提到JavaScript世界一条重要的概念吗?“每一个对象自身都拥有一个隐式的[[proto]]属性,该属性默认是一个指向其构造函数原型属性的指针”。其实与其对应的,还有一条重要的概念我需要在这里告诉你“几乎所有函数都拥有prototype原型属性”。这两个概念确实非常重要,因为每当你搞混了构造函数,原型属性,实例对象之间的关系,以及JavaScript世界中的继承规则时,想想这两个概念总能帮助你剥离迷雾,重新发现真相。

(三)JavaScript世界两个重要概念

因为他们真的很重要,所以我特别使用一个蓝色开头的列表再写一遍(保持耐心,朋友!)

  1. 每一个对象自身都拥有一个隐式的[[proto]]属性,该属性默认是一个指向其构造函数原型属性的指针;
  2. 几乎所有函数都拥有prototype原型属性;

至此,我们搞清楚了构造函数原型属性实例对象三者的关系,相信我,理解清楚这三者的关系能让你以更清晰的视角去观察JavaScript的继承世界,而在下一章中,我们将更进一步,直奔主题的阐述在JavaScript世界中如何实现继承,当然,还有背后的原理。


二、在JavaScript世界中实现继承

既然说了要直奔主题,我们便直接开始对JavaScript世界中对象的继承方式展开说明。不过在那之前,让我们再统一我们对“继承”这一概念的认识:即我们想要一个对象能够访问另一个对象的属性,同时,这个对象还能够添加自己新的属性或是覆盖可访问的另一个对象的属性,我们实现这个目标的方式叫做“继承”。

而在JavaScript世界,实现继承的方式有以下两种:

  1. 创建一个对象并指定其继承对象(原型对象);
  2. 修改构造函数的原型属性(对象);

看起来很合乎逻辑对吧,我们能够针对“对象”,令一个对象继承另一个对象,也能够转而针对创建对象的“构造函数”,以实现实例对象的继承。但是这里有个陷阱(你可能注意到了),对于一个已经定义的对象,我们无法再改变其继承关系,我们的第一种方式只能在“创建对象时”定义对象的继承对象。这是为什么呢?答案是因为“我们设置一个对象的继承关系,本质上是在操作对象隐式的[[proto]]属性”,而JavaScript只为我们开通了在对象创建时定义[[proto]]属性的权限,而拒绝让我们在对象定义时再修改或访问这一属性(所以它是“隐式”的)。很遗憾,在对象定义后改变它的继承关系确实是不可能的。

好了,是时候看看JavaScript世界中继承的主角了 -- Object.create()

(一)关于Object.create() 和对象继承

正如之前所说,Object.create()函数是JavaScript提供给我们的一个在创建对象时设置对象内部[[proto]]属性的API,相信你已经清楚的知道了,通过修改[[proto]]属性的值,我们就能决定对象所继承的对象,从而以我们想要的方式实现继承。

让我们细致的了解一下Object.create()函数:

var x = { 
    name: 'tom',
    sayName: function() {
        console.log(this.name)
    }
}
var y = Object.create(x, {
    name: {
        configurable: true,
        enumerable: true,
        value: 'kitty',
        writable: true,
    }
})
y.sayName() // 'kitty'

看到了吗,Object.create()函数接收两个参数,第一个参数是创建对象想要继承的原型对象,第二个参数是一个属性描述对象(不知道什么是属性描述对象?看看我之前的这篇文章),然后会返回一个对象。

让我们谈谈在调用Object.create()时究竟发生了什么:

  1. 创建了一个空对象,并赋值给相应变量;
  2. 将第一个参数对象设置为该对象[[proto]]属性的值;
  3. 在该对象上调用defineProperty()方法,并将第二个参数传入该方法中;

相信到这里你已经完全明白了如何在创建对象时实现继承了,但这样的方法有很多局限,比如我们只能在创建对象时设置对象的继承对象,又比如这种设置继承的方式是一次性的,我们永远无法依靠这种方式创造出多个有相同继承关系的对象,而对于这种情况,我们理所当然的要请出我们的第二个主角 -- prototype原型对象。

(二)关于prototype 和构造函数继承

还记得我们之前反复提及构造函数,原型属性与实例对象的关系吧?我们还强调了“几乎所有的函数都拥有prototype属性”,现在就是应用这些知识的时候了,其实说到继承,构造函数生产实例对象的过程本身就是一种天然的继承。实例对象天然的继承着原型对象的所有属性,这其实是JavaScript提供给开发者第二种(也是默认的)设置对象[[proto]]属性的方法。

但是这种”天然的“继承方式缺点在于只存在两层继承:自定义构造函数的prototype对象继承Object构造函数的prototype属性,构造函数的实例对象继承构造函数的prototype属性。而我们有时想要更加灵活,满足需求,甚至是”更长“的原型链(或者说是”继承链“)。这是JavaScript默认的继承模式下无法实现的,但解决方式也很符合直觉,既然我们无法修改对象的[[proto]]属性,我们就去修改[[proto]]属性指向的对象 -- 原型对象。

我们说过原型对象也是一个对象对吧?所以我们就有了以下操作:

function Foo(x, y) {
    this.x = x
    this.y = y
}
Foo.prototype.sayX = function() {
    console.log(this.x)
} 
Foo.prototype.sayY = function() {
    console.log(this.y)
}

function Bar(z) {
    this.z = z 
    this.x = 10
}
Bar.prototype = Object.create(Foo.prototype) // 注意这里
Bar.prototype.sayZ = function() {
    console.log(this.z)
}
Bar.prototype.constructor = Bar

var o = new Bar(1)
o.sayX() // 10
o.sayZ() // 1

相信你注意到了,我通过修改了构造函数Bar的原型属性,将其值设置为一个继承对象为Foo.prototype的空对象,在之后,我又为在该对象添加了一些属性(注意到我添加的constructor属性了吗?如果你不明白为什么,你应该去了解一下我这么做的理由。)和方法。这样,构造函数Bar的实例对象就会在查询属性时攀爬原型链,从自有属性开始,途径Bar.prototypeFoo.prototype,最终到达Object.prototype。这正是我们想要的!太棒了!

毫不意外的,这种继承的方式被称为”构造函数继承“,在JavaScript中是一种关键的实现的继承方法,相信你已经很好的掌握了。

但是慢着,还有一个问题没有解决,让我们回到刚才的代码,看看如果我们在源代码上添加一条o.sayY()会发生什么?答案是控制台会输出undefined

毫不意外对吧,毕竟我们从来都没有定义过y属性。但是假如我们也想让构造函数Bar的实例对象拥有构造函数Foo的设置的自有属性又该怎么办呢?答案是通过”构造函数窃取“技术,这将是我们下一章也是最后一章要讨论的话题。

(三)构造函数窃取

如果”窃取“所继承的构造函数的自有属性呢?答案是巧妙的使用.call().apply()方法,让我们修改一下之前的代码:

function Foo(x, y) {
    this.x = x
    this.y = y
}
Foo.prototype.sayX = function() {
    console.log(this.x)
} 
Foo.prototype.sayY = function() {
    console.log(this.y)
}

function Bar(z) {
    this.z = z 
    this.x = 10
    Foo.call(this, z, z) // 注意这里
}
Bar.prototype = Object.create(Foo.prototype) 
Bar.prototype.sayZ = function() {
    console.log(this.z)
}
Bar.prototype.constructor = Bar

var o = new Bar(1)
o.sayX() // 1
o.sayY() // 1
o.sayZ() // 1

Done!我们成功窃取了构造函数Foo的两个自有属性,构造函数Bar的实例对象现在也有了x和y的值!

虽然答案已经一目了然了,但还是让我再解释一下这是怎么做到的:首先我们知道构造函数也是函数,因此我们可以像普通函数一样调用他,让我们以单纯的函数视角看待构造函数Foo,它不过是往this所指的对象上添加了两个属性,然后返回了undefined值,当我们单纯调用该函数时,this的指向为window(不明白为什么指向window,你可以阅读我的这篇文章)。但是通过call()apply()函数,我们可以人为的改变函数内this指针的指向,所以我们将构造函数内的this传入call()函数中,奇妙的事情发生了,原先为Foo函数实例对象添加的属性现在添加到了Bar函数的实例对象上!

构造函数窃取”,我喜欢“窃取”这两个字,确实很巧妙。


太棒了 你终于看完了这篇文章,是否彻底搞懂JavaScript中的继承了呢?希望如此。

算是个奖励,我之前有将JavaScript中的继承知识总结为一张思维导图,你可以点击这里查看。知识总是反复记忆才能真正掌握,希望你能常回来看看。加油👊 !

posted @ 2017-11-23 17:13  libinfs  阅读(9347)  评论(2编辑  收藏  举报