程式開發是個集眾人智慧的工作,開發者希望能站在巨人的肩膀,繼承(inheritance)既有的程式所有功能,在上面開發新功能,不用每次都重新造輪子。在其他程式語言裡有類別(class)的設計讓延伸的子類別繼承主類別的所有功能及特性。JavaScript 沒有「真正」類別的設計(原因下一篇文章會說到),而是使用原型建立類似類別的效果。
前面我們學到,物件原型讓物件可以透過 prototype chain 存取其他物件的屬性,函式原型讓同一個 constructor function 建立的 instance 可以共用相同方法。這些特性使得 constructor function 成為 JavaScript 實現類別的方式。
就像任天堂創造了寶可夢,底下又分成好幾種類型(火系、水系、草系等等),每一種類型雖然有特別的能力,但還是保有共同的寶可夢動物特性。如果今天我們想要建立一個新的 constructor function,希望由它建立的物件能擁有既有 constructor function 的所有功能,然後我們可以增添自定的功能,要如何用原型實現呢?
// 建立主要的 Pokemon constructor function,
// 在原型上加入 fight 方法
function Pokemon() {}
Pokemon.prototype.fight = function() {};
// 另外建立 Pikachu constructor function
function Pikachu() {}
// 把 Pikachu 的函式原型設為 Pokemon 的 instance
Pikachu.prototype = new Pokemon();
// 在這個 instance 上增加 Pikachu 的自有方法
Pikachu.prototype.thunderShock = function() {};
// 新建立的 pikachu 物件同時是 Pikachu 和 Pokemon 的 instance
// 也可以存取 fight 和 thunderShock 方法
const pikachu = new Pikachu();
console.log(pikachu instanceof Pikachu); // true
console.log(pikachu instanceof Pokemon); // true
console.log(typeof pikachu.fight === "function"); // true
console.log(typeof pikachu.thunderShock === "function"); // true
// 增加一個 Ditto 類別
function Ditto() {}
// 函式原型是另一個 Pokemon instance
Ditto.prototype = new Pokemon();
// Ditto 有自己的 transform 方法
Ditto.prototype.transform = function() {};
// 由 Ditto 產生的物件同時是 Pokemon 和 Ditto 的 instance,但不是 Pikachu 的 instance
// 除了自己的和主類別的方法外,無法存取 Pikachu 的方法
const ditto = new Ditto();
console.log(ditto instanceof Pokemon); // true
console.log(ditto instanceof Ditto); // true
console.log(ditto instanceof Pikachu); // false
console.log(typeof ditto.fight === "function"); // true
console.log(typeof ditto.transform === "function"); // true
console.log(ditto.thunderShock); // undefined
具體作法是把既有的 constructor function 的 instance 設為新的 constructor function 的函式原型,如此一來子類別的物件能透過 prototype chain 存取到主類別的所有方法。
函式在建立時同時會自動生成它的函式原型,並且個別以prototype及constructor二個屬性記錄對對方的參照。在上面的例子裡,我們卻將函式原型重新指向給新的物件,也就是說更改了prototype的對象,然而constructor的對象卻沒有變,這麼一來會造成一些問題。
以邏輯來看,pikachu instance 是從 Pikachu constructor function 建立出來的,所以我們預期這樣的檢查是通過的:
console.log(pikachu.constructor === Pikachu);
然而我們卻得到false的結果!仔細看上面的圖發現,因為重新定義函式原型,pikachu instance 已經失去和原本的Pikachu函式原型的關聯,在它的 prototype chain 上其實是Pokemon instance 和Pokemon的函式原型。所以當搜尋constructor屬性時,在 prototype chain 上找到的是在Pokemon函式原型裡的constructor屬性,而它指向的對象是Pokemon constructor function!
constructor屬性可以用來判斷這個 instance 是從哪個 constructor function 建立的,其他的開發者預期得到的是正確的結果,但是我們卻弄壞它。幸好可以用 Object.defineProperty()來改寫屬性。
這個方法的內容在這裡就不多說,請參考 MDN 的說明文件。
Object.defineProperty(Pikachu.prototype, "constructor", {
enumerable: false,
value: Pikachu,
writable: true
});
console.log(pikachu.constructor === Pikachu); // true
在Pikachu的函式原型裡增加了constructor屬性,重建屬性和原型之間的關聯,再次檢查就會得到正確的值,現在我們可以判斷出pikachu instance 是由Pikachu函式所建立的,我們的類別功能總算是完成了!