軌道
/
JavaScript
JavaScript
/
課程大綱
/
原型與類別
原型

原型與類別 在 JavaScript

32 個練習

關於 原型與類別

JavaScript 具備物件導向程式設計(OOP)的能力。在 OOP 中,我們會想從「模板」(類別)建立物件(實例),讓這些物件包含特定的資料與功能。在 OOP 的脈絡下,存放資料的屬性稱為_欄位_,存放函式的屬性則稱為_方法_。

JavaScript 在 2015 年把類別納入語言規範之前,根本沒有類別這回事,但仍可以透過以原型為基礎的繼承來進行物件導向程式設計。即使現在已經有class關鍵字可用,JavaScript 依然是一門_以原型為基礎_的語言。

為了理解「以原型為基礎的語言」代表什麼,以及 JavaScript 實際上如何運作,我們要回到還沒有類別的那個年代。

原型語法

建構函式

在 JavaScript 中,模板(類別)是由一個普通函式來實現的。當某個函式要用來當作這樣的模板時,就稱為_建構函式_,而慣例是函式名稱要以大寫字母開頭。呼叫建構函式時,會用new關鍵字從模板衍生出實例(物件)。

function Car() {
  // ...
}

const myCar = new Car();
const yourCar = new Car();

有一點很重要:在 JavaScript 中,即使實例已經建立完成,實例與建構函式之間仍會彼此保有關聯。每個實例物件都包含一個隱藏的內部屬性,在語言規範中稱為[[prototype]]。它會保存一個指向建構函式prototype鍵之值的參考。沒錯,你沒看錯,JavaScript 函式可以擁有鍵值對,因為它在幕後同時也是個物件。

從 2015 年起,可以透過Object.getPrototypeOf()存取[[prototype]]。在那之前,許多環境可以透過__proto__這個鍵來存取它。

別把物件的原型([[prototype]])與建構函式的prototype屬性搞混了。

Note

總結來說:

  • JavaScript 中的建構函式就是普通函式。
  • 建立新實例時會產生一個物件,它與其建構函式之間的關聯稱為它的_原型_。
  • 函式是物件(可呼叫的物件),因此可以擁有屬性。
  • 建構函式(這個函式)的prototype屬性會成為實例的_原型_。

實例欄位

我們經常希望所有衍生出的物件(實例)都包含某些欄位,並在建立物件時為這些欄位傳入初始值。這可以透過 this 關鍵字來實現。在建構函式內部,this代表將透過new建立的新物件。當建構函式以new呼叫時,this會自動被回傳。

這表示我們可以在建構函式中把欄位加到this上,藉此為新實例新增欄位。

function Car(color, weight) {
  this.color = color;
  this.weight = weight;
  this.engineRunning = false;
}

const myCar = new Car('red', '2mt');
myCar.color;
// => 'red'
myCar.engineRunning;
// => false

實例方法

方法是透過建構函式的prototype屬性加入的。在方法內部,你可以透過this存取實例的欄位。之所以能這樣運作,是因為下面這條通則。

當函式以物件的方法被呼叫時,它的this會設為該方法所屬的物件。1

function Car() {
  this.engineRunning = false;
  // ...
}

Car.prototype.startEngine = function () {
  this.engineRunning = true;
};

Car.prototype.addGas = function (litre) {
  // ...
};

const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true

原型鏈

上面例子中的myCar是一般的 JavaScript 物件,如果我們去檢視它(例如在瀏覽器主控台裡),並不會在myCar物件本身直接找到值為函式的startEngine屬性。那麼上面的程式碼到底是怎麼運作的呢?

這裡的祕密就叫做_原型鏈_。當你試著存取物件上的任何屬性(欄位或方法)時,JavaScript 會先檢查該鍵是否能直接在物件本身找到。如果找不到,就會繼續到原物件[[prototype]]屬性所參考的物件中尋找該鍵。如前所述,在我們的例子中,[[prototype]]指向建構函式的prototype屬性。JavaScript 就是在那裡找到startEngine函式的,因為我們把它加在該處。

function Car() {
  // ...
}

Car.prototype.startEngine = function () {
  // ...
};

而這條鏈還沒結束。Car.prototype的[[prototype]]屬性(也就是myCar.[[prototype]].[[prototype]])會參考Object.prototype(Object建構函式的prototype屬性)。它包含了所有 JavaScript 物件都能使用的一般方法,例如toString()。Object的[[prototype]]通常是null,所以原型鏈就到此結束。總結來說,你可以呼叫myCar.toString(),而這個方法之所以存在,是因為 JavaScript 會沿著整條原型鏈搜尋該方法。你可以在 MDN 文章「繼承與原型鏈」 找到詳細的範例。

Caution

請注意,只有在取值時才會沿著原型鏈走訪。直接設定屬性或刪除實例物件的屬性,只會影響那個特定的實例。如果你習慣的是以類別為基礎的繼承語言,這可能和你想的不太一樣。

動態方法(為所有現有實例新增方法)

JavaScript 允許你在所有現有實例建立之後,再為它們新增方法。

我們學過,每個實例都會保存一個指向建構函式prototype屬性的參考。這表示如果你在該prototype物件上新增一筆項目,這個新項目(例如一個新方法)會立即可供所有從該建構函式建立的實例使用。

function Car() {
  this.engineRunning = false;
}

const myCar = new Car();
// Calling myCar.startEngine() here would result in "TypeError:
// myCar.startEngine is not a function".

Car.prototype.startEngine = function () {
  this.engineRunning = true;
};

myCar.startEngine();
// This works, even though myCar was created before the method
// was added.

理論上,動態方法甚至可以用來擴充Object或Array這類內建物件的功能,做法是修改它們的原型。這稱為_猴子補丁_。由於這種變更會影響整個應用程式,應該避免使用,以免產生非預期的副作用。唯一合理的用途,是為舊環境中缺少的方法提供 polyfill。

類別語法

現在 JavaScript 支援用class關鍵字來定義類別。這項功能在 2015 年被加入語言規範。一方面,它提供了語法糖,讓類別更容易閱讀與撰寫。新語法和 C++ 或 Java 等語言撰寫類別的方式更為相似,從那些語言轉換過來的開發者也更容易適應。另一方面,類別語法也為原型語法所沒有的新語言功能鋪路。

類別宣告

使用新語法時,類別會以class關鍵字定義,後面接著類別名稱,以及用大括號包住的類別主體。主體中包含建構函式的定義,也就是一個名為constructor的特殊方法。這個函式的運作方式,就和原型語法中的建構函式一樣。類別主體也包含所有方法的定義。方法的語法和我們看過的簡寫表示法類似,也就是把函式當作值加入物件裡,請參考物件概念。

class Car {
  constructor(color, weight) {
    this.color = color;
    this.weight = weight;
    this.engineRunning = false;
  }

  startEngine() {
    this.engineRunning = true;
  }

  addGas(litre) {
    // ...
  }
}

const myCar = new Car();
myCar.startEngine();
myCar.engineRunning;
// => true

和函式宣告、函式運算式類似,除了上面看到的_類別宣告_之外,JavaScript 也支援類別運算式。

請記住,在幕後,JavaScript 仍然是一門以原型為基礎的語言。我們在上面「原型語法」一節學到的所有機制仍然適用。

私有欄位、Getter 與 Setter

在 JavaScript 中,所有實例欄位預設都是公開的,可以直接存取與指定。

把真正的私有欄位加入語言規範的工作仍在進行中,詳情請參閱提案文件。

在此期間,你可以沿用既有的慣例:以底線開頭的欄位與方法都應視為私有,絕對不該從類別外部直接存取它們。

私有欄位有時會搭配 getter 與 setter。使用get與set關鍵字,你可以定義一些函式,當存取或指定與該函式同名的屬性時,這些函式就會被執行。

class Car {
  constructor() {
    this._mileage = 0;
  }

  get mileage() {
    return this._mileage;
  }

  set mileage(value) {
    throw new Error(`Mileage cannot be manipulated, ${value} is ignored.`);
    // Just an example, usually you would not provide a setter in this case.
  }
}

const myCar = new Car();
myCar.mileage;
// => 0
myCar.mileage = 100;
// => Error: Mileage cannot be manipulated, 100 is ignored.

類別欄位與類別方法

在 OOP 中,有時候你會想提供不依賴特定實例的工具欄位或方法,而是把它們定義在類別本身。這可以透過static關鍵字達成。

class Car {
  static type = 'vehicle';

  static isType(targetType) {
    return targetType === 'vehicle';
  }
}

Car.type;
// => 'vehicle'

Car.isType('road sign');
// => false

以類別為基礎的繼承

除了我們先前看到沿著原型鏈的繼承之外,在 JavaScript 中也能表示類別之間的繼承。這部分會在繼承概念中介紹。


  1. this 範例 - 作為物件方法,MDN。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#as_an_object_method(引用於 2021 年 12 月 3 日) ↩

透過 GitHub 編輯 連結會在新視窗或分頁中開啟

學習 原型與類別