Jun

只要我的心還會跳,腿還能動

我就沒有理由停下前進的步伐

原型與原型鏈


JavaScript 的繼承機制真的不是那麼一目瞭然的存在,但為了能更深入其中,這是一個躲也躲不掉的主題。

網上能搜到相當多關於原型鏈的文章,其實知識點都大同小異,我就用我的思路理一下這條鏈子究竟是走一個什麼樣的套路。


函數物件

在 JavaScript 裡函數即物件,也被稱為一等公民,代表著程序得以隨意的操控函數,例如將函數賦值給一個變數或將其作為參數傳遞,甚者可以為函數設置屬性、調用方法。

普通物件:

const obj1 = {};
const obj2 = new Object();

函數物件:

function func1() {}
const func2 = function() {};
const func3 = new Function();

凡是用 function 關鍵字或 Function 構造函數創建的對象都稱作函數對象,但只有函數對象才擁有 prototype 屬性。


構造函數

JavaScript 不像傳統物件導向程式語言,它是沒有 class 的(ES6 的語法糖不在本文討論範圍),而且其實也沒有構造函數,儘管我們都這麼叫它,所以只要先知道

真是一堆噁心的專有名詞

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,他們各包含了姓名與忍術兩個屬性以及一個發動忍術的方法。

這樣看起來挺好的,但如果這樣就好的話就沒辦法繼續講下去了 🤣,也確實有一個問題,每個忍者具有各自的 nameninjutsu 這是合理的,就像你我的名字不同、興趣也不同。

但是發動忍術這個動作應該是每個忍者都具備的能力,就像你我都有呼吸的能力一樣,可是

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

在這裡我們就可以看到發動忍術這個技能即使不存在於 narutosasuke 的實例中,他們仍然可以使用而且是相等的,代表它們取用到的是同一個函數。

到此為止是一個普遍解釋原型鏈的起手式,接下來咱們需要深入幾個重點:


new

在上面當我們要創建出 naruto 以及 sasuke 的實例時,我們必須透過 new 這個關鍵字,而在創建實例時 new 基本做了以下四件事:

  1. 建立一個新物件

  2. 將構造函數的作用域賦予給了新物件,意旨 this 就指向了這個新物件

  3. 執行構造函數裡面的代碼

  4. 返回(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 物件,所以 nameninjutsu 兩個屬性就被加到了 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 面板中一探究竟

圖一:
img

我們先看看被 Ninja 構造函數所 new 出來的 naruto 實例裡包含了什麼,兩個屬性 nameninjutsu 是我們在代碼裡定義的,下面還有一個名為 __proto__ 的屬性,再讓我們點開它看看

圖二:
img

唷!這不是 attack 函數嗎!但這一層是哪裡?我想應該有些讀者能猜到了,__proto__ 就是那條金光閃閃的原型鏈,由它負責串起整個繼承機制,但它跟 prototype 真的很容易混淆,我只能藉由它的形狀因為左右兩側有下底線來想像它是條鏈子。圖一是 naruto 的實例,它的原型鏈 __proto__ 連到的是創建它的構造函數 Ninjaprototype(圖二)。

再來圖二裡包含了三個屬性:

  1. attack 函數是我們一開始就定義在 Ninjaprototype 裡的

  2. constructor 所連到的就是構造函數 Ninja 這個我們待會聊

  3. 這裡還有一條 __prto__ 我們來看看它又會連到哪兒

圖三:
img

這裡一大堆鬼東西到底是什麼地方,但其實這裡就是 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 的話我們綜合上述的小範例再用更宏觀的角度來看的話,原型鏈大概是長這樣:

img

原生的構造函數 FunctionObjectArrayStringSymbol 透過 __proto__ 到最後都連到了 Object.prototype,這也是為什麼它們可以調用物件原型裡的方法的原因了。

那麼左邊是我們的範例,如果看完圖再回過頭看範例應該會清晰許多。

我們再回過頭來聊一下圖二與圖三的原型裡皆有一個 constructor 屬性,它反而是連回了構造函數本身,其實說穿了它只是每個函數都存在的一個屬性而已,我們已經知道了 JavaScript 的函數可以做為構造函數使用,也就是可以 new,所以所有函數的 prototype.constructor 都指向自己,

因此所有 new 出來的對象也都有一個 reference 能參照到自己的構造器,僅此。

是不是感覺這個 constructor 很難理解又很沒用的感覺,

.

.

.

.

.

真的就是這麼沒用。

如果還有思緒沒理清的小夥伴們最後搭配這張更簡易的關係圖倒著回去看應該能有幫助。

img


instanceOf

instanceOf 運算符可以用來判斷 Objectprototype 屬性是否存在於 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 只會檢查該物件,並不會檢查整串原型鏈。

另外注意:

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 屬性就沒有被列出來。

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 的原型與原型鏈真的挺難懂的,至少無法只看個兩三遍就能理解對於我而言,所以我也不斷的在網上爬文,很感謝寫過相同主題的這些前輩們讓我能涵蓋更廣的知識點,文中若有觀念錯誤的地方還請各路大神不吝指教!


參考資料