完全搞懂JavaScript中的繼承

你應該知道,JavaScript是一門基於原型鏈的語言,而咱們今天的主題 -- 「繼承」就和「原型鏈」這一律念息息相關。甚至能夠說,所謂的「原型鏈」就是一條「繼承鏈」。有些困惑了嗎?接着看下去吧。javascript

1、構造函數,原型屬性與實例對象

要搞清楚如何在JavaScript中實現繼承,咱們首先要搞懂構造函數原型屬性實例對象三者之間的關係,讓咱們先看一段代碼:html

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

讓咱們經過這段代碼澄清幾個概念:java

  • Person是一個「構造函數」(它用來「構造」對象,而且是一個函數),①處gender是該構造函數的「私有屬性」,②處的語句定義了該構造函數的「自有屬性」;
  • ③處的prototypePerson的「原型對象」(它是實例對象的「原型」,同時它是一個對象,但同時它也是構造函數的「屬性」,因此也有人稱它爲「原型屬性」),該對象上定義的全部屬性(和方法)都會被「實例對象」所「繼承」(咱們終於看到這兩個字了,可是不要心急,咱們過一會纔會談論它);
  • ④處的變量「kitty」的值是構造函數Person的「實例對象」(它是由構造函數生成的一個實例,同時,它是一個對象),它能夠訪問到兩種屬性,一種是經過構造函數生成的「自有屬性」,一種是原型對象能夠訪問的全部屬性;

對以上這些概念有清楚的認識,才能讓你對JavaScript的「繼承」與「原型鏈」的理解更加深入,因此務必保障你已經搞清楚了他們之間的關係。(若是沒有,務必多看幾遍,你能夠找張紙寫寫畫畫,我第一次就是這麼作的)瀏覽器

完全搞清楚了?那讓咱們繼續咱們的主題 -- 「繼承」。app

你是否以爲奇怪,爲何咱們的實例對象能夠訪問到構造函數原型屬性上的屬性(真是拗口)?答案是由於「每個對象自身都擁有一個隱式的[[proto]]屬性,該屬性默認是一個指向其構造函數原型屬性的指針」(其實我想說它是一個鉤子,在對象建立時默認「勾住」了其構造函數的原型屬性,可是我發現emoji竟然沒有鉤子的圖標,因此...🤷🏻‍♂️,不過我仍是以爲鉤子更形象些...)。函數

當JavaScript引擎發現一個對象訪問一個屬性時,會首先查找對象的「自有屬性」,若是沒有找到則會在[[proto]]屬性指向的原型屬性中繼續查找,若是尚未找到的話,你知道其實原型屬性也是一個對象,因此它也有一個隱式的[[proto]]屬性指向它的原型屬性...,正如你所料,若是一直沒有找到該屬性,JavaScript引擎會一直這樣找下去,直到找到最頂部構造函數Objectprototype原型屬性,若是仍是沒有找到,會返回一個undefined值。這個不斷查找的過程,有一個形象生動的名字「攀爬原型鏈」。this

如今你應該對「原型鏈」就是「繼承鏈」這一說法有點感受了吧,讓咱們暫時休息一下,對兩個咱們遺漏的知識點補充說明:prototype

  1. 隱式的[[proto]]屬性
  2. 原型對象prototype

(一)隱式的[[proto]]屬性

何爲「隱式屬性」呢?便是開發者沒法訪問卻確實存在的屬性,你可能會問,既然是隱式的,如何證實它的存在呢?問得好,答案是雖然JavaScript語言沒有暴露給咱們這個屬性,可是瀏覽器卻幫助咱們能夠獲取到該屬性,在Chorme中,咱們能夠經過瀏覽器爲對象添加的_proto_屬性訪問到[[proto]]的值。你能夠本身試試在控制檯中打印這個屬性,證實我沒有說謊。指針

(二)原型對象prototype

還記的咱們以前提到JavaScript世界一條重要的概念嗎?「每個對象自身都擁有一個隱式的[[proto]]屬性,該屬性默認是一個指向其構造函數原型屬性的指針」。其實與其對應的,還有一條重要的概念我須要在這裏告訴你「幾乎全部函數都擁有prototype原型屬性」。這兩個概念確實很是重要,由於每當你搞混了構造函數,原型屬性,實例對象之間的關係,以及JavaScript世界中的繼承規則時,想一想這兩個概念總能幫助你剝離迷霧,從新發現真相。code

(三)JavaScript世界兩個重要概念

由於他們真的很重要,因此我特別使用一個藍色開頭的列表再寫一遍(保持耐心,朋友!)

  1. 每個對象自身都擁有一個隱式的[[proto]]屬性,該屬性默認是一個指向其構造函數原型屬性的指針;
  2. 幾乎全部函數都擁有prototype原型屬性;

至此,咱們搞清楚了構造函數原型屬性實例對象三者的關係,相信我,理解清楚這三者的關係能讓你以更清晰的視角去觀察JavaScript的繼承世界,而在下一章中,咱們將更進一步,直奔主題的闡述在JavaScript世界中如何實現繼承,固然,還有背後的原理。


2、在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中的繼承知識總結爲一張思惟導圖,你能夠點擊這裏查看。知識老是反覆記憶才能真正掌握,但願你能常回來看看。加油👊 !

相關文章
相關標籤/搜索