在 JavaScript 中,prototype (原型) 是最基礎又最重要的觀念,我們可以透過 prototypal inheritance (原型繼承) 達到復用程式碼的效果,本篇文章將介紹 prototype 以及 prototype chain (原型鏈) 的基本觀念,與 class (類別) 的不同之處,以及如何用 prototype 模擬 class 中的繼承,就讓我們一起來搞懂 prototype 的觀念吧!

Prototype

Photo by Hal Gatewood on Unsplash

JavaScript Prototype 原型

JavaScript 中的每個物件都有一個隱藏的屬性 [[Protytype]],我們稱它為 prototype (原型)。Prototype 只能是一個物件或是 null。我們有一個非標準的方法可以存取 prototype:__proto__

例如,我們可以用 __proto__dog 物件的 prototype 指定為 animal 物件:

const animal = {};
const dog = {};

dog.__proto__ = animal;

將 A 物件的 prototype 設定為 B 物件,就是 A 繼承 (inherit) 了 B。

繼承關係還可以更長,例如我們可以再創造一個物件繼承 dog:

const goofy = {
  __proto__: dog
};

那麼設定完物件的 prototype 以後可以幹嘛呢?

接下來要介紹 prototypal inheritance (原型繼承) 的特性了,讓我們一起往下看!

Prototypal Inheritance 原型繼承

Prototype 的功能是:當我們在一個物件查詢某個屬性或方法,找不到的時候,我們會到它的 prototype 裡去查詢。換句話說:

JavaScript 的物件能夠「繼承」其 prototype 的屬性或方法。

例如,我們在 dog 物件裡找不到 isAnimal 屬性,於是我們到它的 prototype,也就是 animal 物件裡查詢 isAnimal 屬性,結果找到了:

const animal = {
  isAnimal: true
};

const dog = {
  __proto__: animal
};

console.log(dog.isAnimal) // true

同樣的原理也適用於 object method (物件方法),例如我們可以在 animal 物件上定義 eat() 方法,並且呼叫 dog.eat()

const animal = {
  eat() {
    console.log('Eat!');
  }
};

const dog = {
  __proto__: animal
};

dog.eat(); // Eat!

Prototypal Inheritance 原型繼承 vs. Class Inheritance 類別繼承

當一個物件繼承自另一個 prototype 物件,它就可以「繼承」 prototype 物件上的屬性和方法。這就是所謂的 prototypal inheritance,原型繼承。

那麼使用 prototypal inheritance 原型繼承有什麼好處呢?簡單地說,「繼承」是一種代碼復用的手段。大部分的程式語言可以透過 Class (類別) 繼承達到這個效果,例如:假設我們有 DogCat 兩種物件,都是動物但又各自有些不同的地方,那麼我們可以定義一個 Animal class 實作了所有動物的共通點,再定義 DogCat 類別在 Animal 的基礎上各自增加特性。

而 JavaScript 中,我們可以透過 prototype 達成同樣的效果。和 class 繼承最大的差別在於:JavaScript 中物件是繼承自 prototype,而 prototype 本身也是一個物件。繼承自 prototype 就好比你在創造物件時有一個可以效仿的實體,而 class 則像是一張參考的藍圖。

延伸閱讀:[教學] 深入淺出 JavaScript ES6 Class (類別)

Constructor Function (建構函式) 的 Prototype

我們知道 JavaScript 可以用 new 運算子加上 constructor function (建構函式) 建立新物件:

function Animal(name) {
  this.name = name;
}

const dog = new Animal('Barley');

如果想知道用 new 建立新物件的詳細原理,可以看一下這篇:

延伸閱讀:[教學] JavaScript new、Function Constructor (建構函式) 及 Object.create()

然而只有屬性的物件並不是太有用,我們希望建立出來的新物件有一些方法 (method) 可以呼叫。那我們該如何幫新物件增加方法呢?這時候 prototype 就可以派上用場了。

直接說結論:我們得將方法定義在 constructor function 的 prototype 屬性上。例如,我們希望建立的新物件有 eat() 方法,那我們就得定義 Animal.prototype.eat

Animal.prototype.eat = function() {
  console.log('Eat!');
}

const dog = new Animal('Barley');
dog.eat(); // Eat!

如果你只想知道怎麼定義一個有方法的物件,那看到這邊就可以了。

但是如果你想知道這個寫法的原理是什麼的話,我們就來一起往下看吧!

F.prototype

在 JavaScript 中,constructor 的 prototype 屬性是一個特殊的屬性,當我們把一個 function (這裏假設是 F) 當成 constructor 使用時,F.prototype 會多一個特殊的用途,讓 JavaScript engine 知道:

當我建立新物件的時候,新物件的 prototype 要等於 F.prototype

舉上面的例子來說,dog 物件的 prototype 是 F.prototype;換句話說,dog 繼承自 F.prototype

我們可以測試 dog.__proto__ 屬性來印證:

const dog = new Animal('Barley');
dog.__proto__ === Animal.prototype; // true

簡單地說,因為建立的新物件會繼承 F.prototype,所以我們在 F.prototype 上定義的方法或屬性,也可以被建立的新物件存取。

這就是為什麼我們要將方法定義在 F.prototype 上。

F.prototype 的預設值

F.prototype 如果沒有特別指定,預設值會是一個物件,帶有 constructor 屬性,指向 constructor 本身:

function Animal() {}

console.log(Animal.prototype); // { constructor: Animal }
console.log(Animal.prototype.constructor === Animal) // true

F.prototype.constructor 屬性

F.prototype 預設會擁有 constructor 屬性。我們可以透過 constructor 屬性得知一個物件如何被創造出來的。甚至還可以用來創造新物件!

const dog = new Animal('Barley');
const cat = new dog.constructor('Chris');

如何利用 Prototypal Inheritance (原型繼承) 模擬 class inheriance

為了達到程式碼復用,我們可能會想讓 Dog 可以繼承 Animal 上的屬性和方法。常見的物件導向語言可以讓 child class (子類別) 繼承 parent class (父類別),也就是類似 class Dog extends Animal 之類的方式。那使用 prototype 的 JavaScript 該如何達到類似的效果呢?

首先我們要定義 Dog constructor。這裏假設 Dog 額外帶有一個 breed 屬性,用來表示狗的品種。我們需要在 Dog constructor 中呼叫 Animal constructor:

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

Animal.call(this, name) 是為了執行 Animal constructor 內所有的初始化動作,包含讓建立的新物件帶有 Animal 建立物件的屬性 (這裡指的是 name)。透過 Animal.call(this, name) 我們不用把重複的代碼全部貼到 Dog,達到程式碼復用的效果。

現在有另外一個問題:我們沒辦法存取 Animal 定義的方法:

const dog = Dog('Barley', 'Golden Retriever');
dog.eat(); // Uncaught TypeError: dog.eat is not a function

為什麼呢?答案在於 Dog.prototype 在沒有特別指定的情況下是預設的 prototype,上面查詢不到任何 Animal 的方法。怎麼辦呢?解法很簡單,我們只要讓 Dog.prototype 繼承 Animal.prototype 就行了:

Dog.prototype = Object.create(Animal.prototype);

如果不熟悉 Object.create() 的讀者,可以看一下這篇唷。

延伸閱讀:[教學] JavaScript new、Function Constructor (建構函式) 及 Object.create()

這個做法還會衍生一個問題,就是 Dog.prototype.constructor 的值會變成 Animal,因為 Dog.prototype 繼承 Animal.prototype,而 Animal.prototype.constructor === Animal

解法是我們要幫 Dog.prototype 手動加上 contructor 屬性:

Object.defineProperty(Dog.prototype, 'constructor', {
  value: Dog,
  enumerable: false, // so that it does not appear in 'for in' loop
  writable: true
});

大功告成!當然你也可以不用那麼費工,直接用 ES6 Class,可以省去一堆冗長的語法。

如果想看 JavaScript Class 的教學,可以看這篇:

延伸閱讀:[教學] JavaScript ES6 Class

Reference

粉絲團開張囉!

按讚才不會錯過更多優質技術文章的更新通知喔!