用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

歡進發表於2019-05-04
在看了網上很多相關的文章,很多都是懵逼看完,並不是說各位前輩們寫得不好,而是說實在不容易在一兩次閱讀中理解透。我在閱讀了一些文章後,自己整理總結和繪製了一些相關的圖,個人認為會更容易接受和理解,所以分享在此。也因此以下的所有的理解和圖解都是出於個人的理解,如果有錯誤的地方,請各位前輩務必見諒,並辛苦在下方提出和糾錯,我實在擔心自己不成熟的理論底子會誤導了其餘的小兄弟。

一開始,先說說為何這個知識點為什麼理解起來這麼亂

個人感覺原因有三:

  1. JS內函式即物件。
  2. Function物件和Object物件這兩個內建物件的特殊性。
  3. 很多講解圖的指向一眼下去花裡胡哨,看著都頭疼[手動狗頭]。

再說說,為何網上各位前輩的相關文章都難以參透

很多前輩在講解相關知識點的時候都是從__proto__開始講起,但在我看來,__proto__與prototype關係之密切是無法單獨提出來講的(單獨講就意味著難以理解);而prototype與constructor又有密切關係,這就造成一種很尷尬的處境,要先講__proto__就必然需要同時講解prototype和constructor屬性,這個也就是為何對於小白的我們而言這些概念是那麼的難以理解。(以上個人看法,僅供參考)

然後在講講我個人採取的理解方式

為了更輕鬆、更有動力地理解透,我採用從constructor到__proto__原型鏈一步步“拆解”的方式去理解,希望有好的效果。文章內容如下:

  1. 先理解為什麼“函式即物件”
  2. constructor其實很純粹
  3. prototype是為何而出現
  4. 真正的constructor屬性藏在哪
  5. __proto__讓例項能找到自己的原型物件
  6. 究竟何為原型鏈
  7. 原型鏈引出新的繼承方式 
  8. 總結

最後,講講往下看需要知道的那些小知識:

① 當任意一個普通函式用於建立一類物件時,它就被稱作建構函式,或構造器。

function Person() {}
var person1 = new Person()
var person2 = new Person()
複製程式碼

上面程式碼Person( )就是person1和person2的建構函式。

② 可以通過物件.constructor拿到建立該例項物件的建構函式。

console.log(person1.constructor) // 結果輸出: [Function: Person]
複製程式碼

Person函式就是person1物件的建構函式。

③ Function函式和Object函式是JS內建物件,也叫內部類,JS自己封裝好的類,所以很多莫名其妙、意想不到的設定其實無需過分糾結,官方動作,神仙操作。

④ 原型物件即例項物件自己建構函式內的prototype物件。

一、先理解為什麼“函式即物件”

先看以下程式碼:

function Person() {...}
console.log(Person.constructor) // 輸出結果:[Function: Function]
// 上面是普通函式宣告方法,生成具名函式,在宣告時就已經生成物件模型。
console.log(Function.constructor) // 輸出結果:[Function: Function]
console.log(Object.constructor) // 輸出結果:[Function: Function]
複製程式碼

上面的程式碼構造了一個Person函式,我們能看出那些資訊?

  1. Person雖被宣告為一個函式,但它同樣可以通過Person.constructor輸出內容。輸出內容說明Function函式是Person函式[普通宣告的函式]的建構函式。
  2. Function函式同時是自己的建構函式。
  3. Function函式同樣是Object這類內建物件的建構函式。
其實上面三點總結下來就是一句:在JS裡,函式就是Function函式的例項物件。也就是我們說的函式即物件。上面的宣告函式的程式碼其實幾乎等同於下面程式碼:

// 使用Function構造器建立Function物件
var Person = new Function('...')
// 幾乎?因為這種方式生成的函式是匿名函式[anonymous],並且只在真正呼叫時才生成物件模型。複製程式碼

在JS裡,函式和物件包含關係如下:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

總結:物件由函式建立,函式都是Function物件例項。

二、constructor其實很純粹

先忽略__proto__和prototype,直接理解constructor,程式碼例子:

function Person() {}
var person1 = new Person()
var person2 = new Person()
複製程式碼

下面一張圖就畫出了它們constructor的指向(忽略了__proto__和prototype):

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

圖中,藍色底是Person的例項物件,而Person、Function是函式(也是物件)。

首先,我們已經知道每個物件都可以通過物件.constructor指向建立該物件的建構函式。我們先假設每個物件上都有這麼個constructor屬性,然後理解如下:

注意:constructor屬性不一定是物件本身的屬性,這裡只為方便理解將其泛化成物件本身屬性,所以用虛線框,第三大點細講。
  1. person1與person2是Person物件的例項,他們的constructor指向建立它們的建構函式,即Person函式;
  2. Person是函式,但同時也是Function例項物件,它的constructor指向建立它的建構函式,即Function函式;
  3. 至於Function函式,它是JS的內建物件,在第一點我們就已經知道它的建構函式是它自身,所以內部constructor屬性指向自己。

所以constructor屬性其實就是一個拿來儲存自己建構函式引用的屬性,沒有其他特殊的地方。

在接下來的所有例子都將把Function物件視為Function物件自己的例項物件,通過去掉它的特殊性來更好理解相關概念。

三、prototype是為何而出現

上一步理解是很容易的,然後這時要求你去給Person的兩個例項物件加上一個效果相同的方法,你寫了以下程式碼:

// 下面是給person1和person2例項新增了同一個效果的方法sayHello
person1.sayHello = function() {
    console.log('Hello!')
}
person2.sayHello = function() {
    console.log('Hello!')
}
console.log(person1.sayHello === person2.sayHello) // false,它們不是同一個方法,各自佔有記憶體複製程式碼

圖示如下:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

當你去對比這兩個方法的時候,你會發現它們只是效果相同、名字相同,本質上卻是各自都佔用了部分記憶體的不同方法。這時候就出問題了,如果這時候有千千萬萬個例項(誇張)要這樣效果同樣的方法,那記憶體豈不是要炸。這時,prototype就出現解決問題了。

當需要為大量例項新增相同效果的方法時,可以將它們存放在prototype物件中,並將該prototype物件放在這些例項的建構函式上,達到共享、公用的效果。程式碼如下:

Person.prototype.sayHello = function() {
    console.log('Hello!')
}
console.log(person1.sayHello === person2.sayHello) // true,同一個方法複製程式碼

圖示如下:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

而之所以這種形式可以減少記憶體的浪費,是由於無需再拿出部分記憶體為同一類的例項單純建立相關同一效果的屬性或方法,而可以直接去建構函式的prototype物件上找並呼叫。

總結:prototype物件用於放某同一型別例項的共享屬性和方法,實質上是為了記憶體著想。

講到這裡,你需要知道的是,所有函式本身是Function函式的例項物件,所以Function函式中同樣會有一個prototype物件放它自己例項物件的共享屬性和方法。所以上面的圖示是不完整的,應改成下圖:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

其實裡面的sayHello也是個函式,也有自己的prototype,但不畫出來了,免得頭疼。

注意:接下來的用【原型物件】表示【建立自己的建構函式內部的prototype】!

四、真正的constructor屬性藏在哪

看到上面,有些小夥伴就頭疼了,你說的constructor屬性為什麼我就沒在console出來的物件資料中看到呢?

思考個問題:new Person( )出來的千千萬萬個例項中如果都有constructor屬性,並且都指向建立自己的建構函式,那豈不又出現了第三點的問題,它們都擁有一個效果相同但卻都各自佔用一部分記憶體的屬性?

我相信你們懂我的意思了,constructor是完全可以被當成一個共享屬性存放在原型物件中,作用也依然是指向自己的建構函式,而實際上也是這麼處理的。物件的constructor屬性就是被當做共享屬性放在它們的原型物件中,即下圖:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

總結:預設constructor實際上是被當做共享屬性放在它們的原型物件中。

這時候有人會拿個反例來問:如果是共享屬性,那我將兩個例項其中一個屬性改了,為啥第二個例項沒同步?如下面程式碼:

function Person() {}
var person1 = new Person()
var person2 = new Person()
console.log(person1.constructor) // [Function: Person]
console.log(person2.constructor) // [Function: Person]
person1.constructor = Function
console.log(person1.constructor) // [Function: Function]
console.log(person2.constructor) // [Function: Person] !不是同步為[Function: Function]複製程式碼

這個是因為person1.constructor = Function改的並不是原型物件上的共享屬性constructor,而是給例項person1加了一個constructor屬性。如下:

console.log(person1) // 結果:Function { constructor: [Function: Function] }複製程式碼

你可以看到person1例項中多了constructor屬性。它原型物件上的constructor是沒有改的。

嗯。嗯?嗯?!搞事?!! 這下共享屬效能理解了,但上面的圖解明顯會造成很大的問題,我們根本不能通過一個物件.constructor找回建立自己的建構函式(之間沒有箭頭連結)!

好的,不急,第四點只是告訴你為什麼constructor要待在建立自己的建構函式prototype上。接下來是該__proto__屬性亮相了。

五、__proto__讓例項能找到自己的原型物件

帶著第四點的疑問,我們如果要去解決這個問題,我們自然會想到在物件內部建立一個屬性直接指向自己的原型物件,那就可以找到共享屬性constructor了,也就是下面的關係:

  1. 例項物件.__proto__ = 建立自己的建構函式內部的prototype(原型物件)
  2. 例項物件.__proto__.constructor = 建立自己的建構函式
也如下圖所示:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

上面說的__proto__屬性實際上也的確是這樣的設定的,物件的__proto__屬性就是指向自己的原型物件。這裡要注意,因為JS內所有函式都是Function函式的例項物件,所以Person函式也有個__proto__屬性指向自己的原型物件,即Function函式的prototype。至於Function函式為何有個__proto__屬性指向自己(藍色箭頭)也不用解釋了吧,它拿自身作為自己的建構函式,反正就是個特例,不講道理。

疑惑來了:例項物件.constructor 等於 例項物件.__proto__.constructor

這個就是JS內部的操作了,當在一個例項物件上找不到某個屬性時,JS就會去它的原型物件上找是否有相關的共享屬性或方法,所以上面的例子中,person1物件內部雖然沒有自己的constructor屬性,但它的原型物件上有,所以能實現我們上面提到的效果。當然後面還涉及原型鏈,你只要知道上面一句話能暫時回答這個問題就好。

疑惑來了:prototype也是個物件吧,它肯定也有個__proto__吧?

的確,它也是個物件,也的確有個__proto__指向自己的原型物件。那我們嘗試用程式碼找出它的建構函式,如下:

function Person() {}
console.log(Person.prototype.__proto__.constructor) // [Function: Object]複製程式碼

因為__proto__指向原型物件,原型物件中的constructor又指向建構函式,所以Person.prototype.__proto__.constructor指向的就是Person中prototype物件的建構函式,上面的輸出結果說明了prototype的建構函式就是Object函式(物件)。

總結:這麼說的話其實函式內的prototype也不過是個普通的物件,並且預設也都是Object物件的例項。

下面一張圖就畫出了文章例子中所有__proto__指向,我們試試從中找出它的貓膩。

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

貓膩一、所有函式的__proto__指向他們的原型物件,即Function函式的prototype物件

在第一點我們就講了所有的函式都是Function函式的例項(包括Function自己),所以他們的__proto__自然也就都指向Function函式的prototype物件。

貓膩二、最後一個prototype物件是Object函式內的prototype物件。

在JS內,萬物皆物件,而Object函式作為JS的內建物件,也是充當了很重要的角色。Object函式是所有物件通過原型鏈追溯到最根的建構函式。換句話說,就是官方動作,不講道理的神仙操作。

貓膩三、Object函式的prototype中的__proto__指向null。

這是由於Object函式的特殊性,有人會想,為什麼Object函式不能像Function函式一樣讓__proto__屬性指向自己的prototype?答案就是如果指向自己的prototype,那當找不到某一屬性時沿著原型鏈尋找的時候就會進入死迴圈,所以必須指向null,這個null其實就是個跳出條件。

上面談到原型鏈,有些小兄弟還不知道是什麼東西,那接下來看看何為原型鏈,看懂了再回來重新理解一下貓膩三的解釋。

六、究竟何為原型鏈

在讓我告訴你何為原型鏈時,我先給你畫出上面那個例子中所有的原型鏈,你看看能不能看出一些規律。上面的例子中一共有四條原型鏈,紅色線連線起來的一串就是原型鏈

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

左邊的圖:原型鏈也就是將原型物件像羊肉串一樣串起來成為一條鏈,好粗暴的解釋,但的確很形象。

右邊的圖:之前說過Person函式(所有函式)其實是Function函式的例項,假設把它看成一個普通的例項物件,忽略它函式身份以及prototype物件,其實它和左邊圖中的person1沒什麼區別,只是它們的__proto__屬性指向了各自的的原型物件。

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

左邊的圖:Function函式因為是個特殊的例子,它的建構函式就是自己,所以__proto__屬性也指向自己的prototype物件;但它的特殊性並不影響它的prototype物件依然不出意外的是Object函式的例項

右邊的圖:這個理解起來就很難受,因為Object函式和別的函式一樣也是Function函式的例項,所以它的__proto__屬性毫無例外地是指向Function函式的prototype物件,但是問題是Function函式中的prototype本身又是Object函式的例項物件,所以Function函式中的prototype物件中的__proto__屬性就指向Object函式的prototype物件,這就形成“我中有你,你中有我”的情況,也是造成難以理解的原因之一。

為了更好地理解原型鏈,我打算忽略掉那討厭的特例,Function函式。

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

忽略掉Function函式後你會發現好清爽!相信大家也發現了,__proto__屬性在其中起著關鍵作用,它將一個個例項和原型物件關聯在一起,但由於所關聯的原型物件也有可能是別人的例項物件,所以就形成了串連的形式,也就形成了我們所說的原型鏈。

七、原型鏈引出新的繼承方式

個人認為原型鏈的出現只是一次巧合,不是特別刻意的存在。但是這種巧合確實有它自己的意義。還記得我之前說過的兩點嗎:

  1. prototype物件儲存著建構函式給它的例項們呼叫的共享屬性和方法。
  2. 例項物件當沒有某一屬性時,會通過__proto__屬性去找到建立它們的建構函式的prototype物件,並在裡面找有沒有相關的共享屬性或方法。
那這時就很有趣了。prototype物件本身也有一個__proto__屬性指向它自己的原型物件,上面有著建構函式留下的共享屬性和方法。那這麼說的話,假如當在自己原型物件上找不到相關的共享屬性或方法時,對於它現在所在的prototype物件而言,也是一次尋值失敗的情況,那它自然也會去它自己的原型物件上找,世紀大片圖示如下:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

現在來想想,假如Object函式內的prototype物件中__proto__屬性不指向空,而指向自己的prototype?那不完了咯,死迴圈。

可能這時有小兄弟會問,這不就是一個不斷找值的過程嗎,有什麼意義?但是就因為這種巧合,讓一些可愛的人想到了一種新的繼承方式:原型鏈繼承

請看下面程式碼:

function GrandFather() {
    this.name = 'GrandFather'
}
function Father() {
    this.age = 32
}
Father.prototype = new GrandFather() // Father函式改變自己的prototype指向
function Son() {}
Son.prototype = new Father() // Son函式改變自己的prototype指向

var son = new Son()
console.log(son.name) // 結果輸出:GrandFather
console.log(son.age)  // 結果輸出:32
console.log(Son.prototype.constructor) // 結果輸出:[Function: GrandFather]複製程式碼

相關指向圖如下:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

兩邊的圖都是忽略了Function函式的,同時將一些沒有必要展示出來的屬性給忽略了,如各大函式的__proto__屬性。

左邊的圖:在沒有改變各個函式的prototype的指向時,預設就是左邊的圖片所示。每個函式的prototype都是預設情況下將它們內部的__proto__指向Object函式的(黑色箭頭)。

右邊的圖:Father函式和Son函式都丟棄了它們各自的prototype物件,指向一個新的物件。這形成了三個新的有趣現象:

  1. Father函式中的prototype指向了GrandFather的例項物件,這時候這個例項物件就成為了Father函式以後例項的原型物件,順其自然GrandFather例項物件內的私有屬性name就變成了Father函式以後例項的共享屬性;
  2. 同樣的,Son函式中的prototype指向了Father的例項物件,將Father的例項物件內的私有屬性age就變成了Son函式以後例項的共享屬性。
  3. 它們的__proto__屬性將它們串了起來,形成一條新的原型鏈。
上面的操作我們能看到Son函式以後的例項都能通過原型鏈找到name和age屬性,也就是實現了我們所說的繼承,繼承了父類的屬性。不過相信眼尖的我們會發現這種繼承方式問題很大:
  1. constructor的指向不可靠了,像Son例項物件.constructor最後得到的值是沿著原型鏈找到的GrandFather函式。可我們自己清楚Son例項物件就該是Son函式,但卻不在我們的意料之中。
  2. 所有所謂繼承下來的屬性全都是共享屬性,好致命的問題。
所以,Emmm,瞭解一下就好。

八、總結

最近在學思維導圖怎麼做,所以嘗試直接拿思維導圖做總結了:

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

用自己的方式(圖)理解constructor、prototype、__proto__和原型鏈

寫完這篇文章後,自己是覺得清晰了很多,當然本人並不確定內部的一些觀點是否正確,大部分觀點都是我結合各位前輩文章並加上自己的思考總結出來的一些比較能自圓其說的說法。感謝各位大佬前輩的閱讀,如果有什麼嚴重的錯誤,務必諒解和提出,大三在讀,只希望能在前端這條路上走得更遠。

最後,感謝一個讓我基本搞懂這些概念的部落格文章:

幫你徹底搞懂JS中的prototype、__proto__與constructor(圖解)

如果覺得我寫得太爛可以去看幾遍上面那篇文章。

End





相關文章