原型與原型鏈
JavaScript 的繼承機制真的不是那麼一目瞭然的存在,但為了能更深入其中,這是一個躲也躲不掉的主題。
網上能搜到相當多關於原型鏈的文章,其實知識點都大同小異,我就用我的思路理一下這條鏈子究竟是走一個什麼樣的套路。
函數物件
在 JavaScript 裡函數即物件,也被稱為一等公民,代表著程序得以隨意的操控函數,例如將函數賦值給一個變數或將其作為參數傳遞,甚者可以為函數設置屬性、調用方法。
普通物件:
const obj1 = {};
const obj2 = new Object();
函數物件:
function func1() {}
const func2 = function() {};
const func3 = new Function();
凡是用 function
關鍵字或 Function
構造函數創建的對象都稱作函數對象,但只有函數對象才擁有 prototype
屬性。
構造函數
JavaScript 不像傳統物件導向程式語言,它是沒有 class 的(ES6 的語法糖不在本文討論範圍),而且其實也沒有構造函數,儘管我們都這麼叫它,所以只要先知道
在函數前如果存在
new
這個關鍵字,這個函數就是構造函數只要在呼叫函數時前面存在
new
這個關鍵字就稱作呼叫構造函數
真是一堆噁心的專有名詞
function Ninja(name, ninjutsu) {
this.name = name;
this.ninjutsu = ninjutsu;
this.attack = function() {
console.log(`看我的${this.ninjutsu}!`);
};
}
const naruto = new Ninja('鳴人', '螺旋丸');
const sasuke = new Ninja('佐助', '千鳥');
naruto.attack(); // 看我的螺旋丸!
sasuke.attack(); // 看我的千鳥!
在這裡有一個名為 Ninja
的構造函數用來創建實例 naruto
以及 sasuke
,他們各包含了姓名與忍術兩個屬性以及一個發動忍術的方法。
這樣看起來挺好的,但如果這樣就好的話就沒辦法繼續講下去了 🤣,也確實有一個問題,每個忍者具有各自的 name
與 ninjutsu
這是合理的,就像你我的名字不同、興趣也不同。
但是發動忍術這個動作應該是每個忍者都具備的能力,就像你我都有呼吸的能力一樣,可是
console.log(naruto.attack === sasuke.attack); // false
這麼寫的話發動忍術這個函數卻是存在於各自的實例中,站在程式的角度它們是共用的、是可以被抽出來的,不然如果我建立一萬個忍者就有一萬個相同的函數存在於不同的實例中,是相當耗資源的,所以我們才需要一個原型讓實例可以去繼承、共用它所需要使用到的屬性或方法,像這樣
function Ninja(name, ninjutsu) {
this.name = name;
this.ninjutsu = ninjutsu;
}
Ninja.prototype.attack = function() {
console.log(`看我的${this.ninjutsu}!`);
};
const naruto = new Ninja('鳴人', '螺旋丸');
const sasuke = new Ninja('佐助', '千鳥');
naruto.attack(); // 看我的螺旋丸!
sasuke.attack(); // 看我的千鳥!
console.log(naruto.attack === sasuke.attack); // true
在這裡我們就可以看到發動忍術這個技能即使不存在於 naruto
與 sasuke
的實例中,他們仍然可以使用而且是相等的,代表它們取用到的是同一個函數。
到此為止是一個普遍解釋原型鏈的起手式,接下來咱們需要深入幾個重點:
new
是一個 JavaScript 的運算符,但它究竟做了些什麼?我定義的函數
attack
明明是加在構造函數Ninja
的prototype
裡,但透過實例呼叫時它是如何找到那個函數的?
new
在上面當我們要創建出 naruto
以及 sasuke
的實例時,我們必須透過 new
這個關鍵字,而在創建實例時 new
基本做了以下四件事:
建立一個新物件
將構造函數的作用域賦予給了新物件,意旨 this 就指向了這個新物件
執行構造函數裡面的代碼
返回(return)這個新物件
但是要注意,如果你在調用構造函數時前面沒有加 new
那麼就相當於一般的函數呼叫,所以如果我這麼做
function Ninja(name, ninjutsu) {
this.name = name;
this.ninjutsu = ninjutsu;
}
const naruto = Ninja('鳴人', '螺旋丸');
console.log(naruto); // undefined
console.log(window.name); // 鳴人
console.log(window.ninjutsu); // 螺旋丸
在這邊我調用 Ninja
構造函數時前面並沒有加上 new
,而 naruto
得出來的結果竟是 undefined
!為何?
上面說過了在調用構造函數時前面沒有加 new
那麼就相當於一般的函數呼叫,所以 Ninja
在這裡就變成了一般函數而且並沒有 return
任何東西所以 naruto
自然會得到 undefined
。
再來,在執行 Ninja
函數裡面的代碼時 this
所指向的是全域的 window
物件,所以 name
與 ninjutsu
兩個屬性就被加到了 window
物件裡。
原型鏈
回到上面的代碼
function Ninja(name, ninjutsu) {
this.name = name;
this.ninjutsu = ninjutsu;
}
Ninja.prototype.attack = function() {
console.log(`看我的${this.ninjutsu}!`);
};
const naruto = new Ninja('鳴人', '螺旋丸');
naruto.attack(); // 看我的螺旋丸!
console.log(naruto);
這邊我們只需要看 naruto
就夠了,上面提到的為何在 naruto
實例上能取用到 attack
函數?它明明不在其中,是如何找到它的,我們直接從 Chrome 的 Console 面板中一探究竟
圖一:
我們先看看被 Ninja
構造函數所 new
出來的 naruto
實例裡包含了什麼,兩個屬性 name
與 ninjutsu
是我們在代碼裡定義的,下面還有一個名為 __proto__
的屬性,再讓我們點開它看看
圖二:
唷!這不是 attack
函數嗎!但這一層是哪裡?我想應該有些讀者能猜到了,__proto__
就是那條金光閃閃的原型鏈,由它負責串起整個繼承機制,但它跟 prototype
真的很容易混淆,我只能藉由它的形狀因為左右兩側有下底線來想像它是條鏈子。圖一是 naruto
的實例,它的原型鏈 __proto__
連到的是創建它的構造函數 Ninja
的 prototype
(圖二)。
再來圖二裡包含了三個屬性:
attack
函數是我們一開始就定義在Ninja
的prototype
裡的constructor
所連到的就是構造函數Ninja
這個我們待會聊這裡還有一條
__prto__
我們來看看它又會連到哪兒
圖三:
這裡一大堆鬼東西到底是什麼地方,但其實這裡就是 Object.prototype
也是原型鏈的頂層,再上去就是 null
,也就等於沒東西了,很多常見的方法例如 hasOwnProperty()
、toString()
、valueOf()
等等的都在這裡,因為函數即物件且 JavaScript 為了讓物件能使用它原生對象的方法所以最終我們連到了這裡。
讓我們用程式驗證一下:
console.log(naruto.__proto__ === Ninja.prototype); // true
console.log(naruto.__proto__.__proto__ === Object.prototype); // true
console.log(naruto.__proto__.__proto__.__proto__ === null); // true
console.log(Ninja.__proto__ === Function.prototype); // true
console.log(Ninja.__proto__.__proto__ === Object.prototype); // true
console.log(Ninja.__proto__.__proto__.__proto__ === null); // true
如果這邊 OK 的話我們綜合上述的小範例再用更宏觀的角度來看的話,原型鏈大概是長這樣:
原生的構造函數 Function
、Object
、Array
、String
、Symbol
透過 __proto__
到最後都連到了 Object.prototype
,這也是為什麼它們可以調用物件原型裡的方法的原因了。
那麼左邊是我們的範例,如果看完圖再回過頭看範例應該會清晰許多。
我們再回過頭來聊一下圖二與圖三的原型裡皆有一個 constructor
屬性,它反而是連回了構造函數本身,其實說穿了它只是每個函數都存在的一個屬性而已,我們已經知道了 JavaScript 的函數可以做為構造函數使用,也就是可以 new
,所以所有函數的 prototype.constructor
都指向自己,
因此所有 new
出來的對象也都有一個 reference
能參照到自己的構造器,僅此。
是不是感覺這個 constructor
很難理解又很沒用的感覺,
.
.
.
.
.
真的就是這麼沒用。
如果還有思緒沒理清的小夥伴們最後搭配這張更簡易的關係圖倒著回去看應該能有幫助。
instanceOf
instanceOf
運算符可以用來判斷 Object
的 prototype
屬性是否存在於 Function
的原型鏈上。
位於 instanceOf
左邊的運算元是物件的 __proto__
右邊則是函數的 prototype
,這樣講可能還是有些模糊,一樣拿上面的範例做示範,上代碼!
console.log(naruto instanceof Ninja); // true
相當於
console.log(naruto.__proto__ === Ninja.prototype); // true
是不是很 easy 呢!
hasOwnProperty
hasOwnProperty
則可用來檢查屬性屬於當前物件還是身處於原型鏈中。
一樣拿上面的範例:
console.log(naruto.hasOwnProperty('name')); // true
console.log(naruto.hasOwnProperty('attack')); // false
name
屬性確確實實的存在於 naruto
的實例中所以得到 true
,而 attack
則是存在於原型鏈中所以會得到 false
,所以我們可以得知 hasOwnProperty
只會檢查該物件,並不會檢查整串原型鏈。
另外注意:
- 迴圈的
prop in object
會檢查整串原型鏈且為可列舉的屬性
function Person(name, age) {
this.name = name;
this.age = age;
this.skill = function() {
console.log('唱歌');
};
}
const jay = new Person('周杰倫', 41);
Object.defineProperty(jay, 'wife', {
value: '昆凌',
enumerable: false,
});
for (const prop in jay) {
console.log(prop); // name age skill
}
在此迴圈中 wife
屬性就沒有被列出來。
- 非迴圈的
prop in object
會檢查整串原型鏈不管屬性是否可列舉
console.log('name' in jay); // true
console.log('age' in jay); // true
console.log('skill' in jay); // true
console.log('wife' in jay); // true
而在此我們可以看到 'wife' in jay
所得到的結果為 true
。
getPrototypeOf
上述不斷提及的 __proto__
屬性其實是內部屬性,一般也不建議用這種方式去取得相對應的 [[Prototype]]
,只是為了展示代碼與思路比較清晰明瞭所以選擇 __proto__
,但我們可以使用 getPrototypeOf
這個方法取代 __proto__
,像這樣:
console.log(Object.getPrototypeOf(naruto) === Ninja.prototype); // true
console.log(Object.getPrototypeOf(Ninja) === Function.prototype); // true
總結
JavaScript 的原型與原型鏈真的挺難懂的,至少無法只看個兩三遍就能理解對於我而言,所以我也不斷的在網上爬文,很感謝寫過相同主題的這些前輩們讓我能涵蓋更廣的知識點,文中若有觀念錯誤的地方還請各路大神不吝指教!
參考資料
- 該來理解 JavaScript 的原型鍊了
- JavaScript 繼承機制的設計思想
- 從設計初衷解釋 JavaScript 原型鏈
- 從 proto 和 prototype 來深入理解 JS 對象和原型鏈
- 理解 JavaScript 的原型鏈和繼承
- 你懂 JavaScript 嗎?#19 原型(Prototype)
- JavaScript 闖關記之原型與原型鏈