本文 AI 產出,尚未審核

JavaScript – 原型與繼承(Prototype & Inheritance)

主題:建構函式(Constructor)


簡介

在 JavaScript 中,建構函式是創建物件的核心機制之一。它不僅負責把屬性與方法初始化,還與原型(prototype)緊密結合,形成 JavaScript 獨特的繼承模型。掌握建構函式的寫法與運作原理,能讓你在開發大型應用時,寫出結構清晰、可維護性高的程式碼。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出真實的應用情境,幫助 初學者到中級開發者 全面了解建構函式在原型與繼承中的角色。


核心概念

1. 什麼是建構函式?

建構函式是一種普通的函式,但慣例上會以 大寫開頭 命名(例如 PersonCar),並透過 new 關鍵字呼叫。使用 new 時,JavaScript 會自動執行以下步驟:

  1. 創建一個全新的空物件。
  2. 設定該物件的 [[Prototype]](即 __proto__)指向建構函式的 prototype 物件。
  3. 執行建構函式,將 this 綁定到新物件,讓屬性與方法被初始化。
  4. 若建構函式沒有顯式回傳物件,則回傳步驟 1 所建立的物件。

重點new 讓每一次呼叫都得到一個獨立的實例,且該實例會共享建構函式原型上的成員。

2. prototype 屬性的意義

每個函式都有一個 prototype 屬性,這是一個普通的物件。只有透過 new 建立的實例,才會把這個 prototype 當作自己的原型。因此,把共用的方法放在 prototype 上,可讓所有實例共享同一份函式,節省記憶體。

function Person(name, age) {
  this.name = name;      // 實例屬性
  this.age = age;        // 實例屬性
}

// 放在 prototype 上的方法,所有 Person 實例共享
Person.prototype.greet = function () {
  console.log(`嗨,我是 ${this.name},今年 ${this.age} 歲。`);
};

3. 繼承的實作方式(ES5 風格)

在 ES5 以前,常見的繼承寫法是 原型鏈繼承組合繼承。以下示範最常用的「組合繼承」:

// 父類別
function Animal(kind) {
  this.kind = kind;            // 父類別的實例屬性
}
Animal.prototype.sayKind = function () {
  console.log(`我是 ${this.kind}`);
};

// 子類別
function Dog(name) {
  // 1. 呼叫父類別建構函式,繼承實例屬性
  Animal.call(this, '狗');
  this.name = name;            // 子類別自己的屬性
}

// 2. 設定子類別的 prototype 為父類別的 prototype(建立原型鏈)
Dog.prototype = Object.create(Animal.prototype);
// 3. 修正 constructor 指向
Dog.prototype.constructor = Dog;

// 子類別自己的方法
Dog.prototype.bark = function () {
  console.log(`${this.name} 汪汪!`);
};

4. ES6 class 與建構函式的關係

ES6 引入 class 語法,其實只是 語法糖,底層仍然是建構函式與原型鏈。了解 class 的運作原理,有助於在需要手寫原型時不會出錯。

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`嗨,我是 ${this.name}`);
  }
}

// 等同於
function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.greet = function () {
  console.log(`嗨,我是 ${this.name}`);
};

程式碼範例

以下提供 5 個實用範例,從最基礎的建構函式到結合繼承與靜態屬性的完整示範。每段程式碼皆附上說明註解,方便閱讀。

範例 1:最簡單的建構函式

function Book(title, author) {
  // 這裡的 this 會指向 new 出來的物件
  this.title = title;
  this.author = author;
}

// 為所有 Book 實例加入共用方法
Book.prototype.getInfo = function () {
  return `${this.title} — ${this.author}`;
};

// 使用
const book1 = new Book('深度學習入門', '王小明');
console.log(book1.getInfo()); // 深度學習入門 — 王小明

範例 2:使用 call 繼承父類別屬性(組合繼承)

function Vehicle(type) {
  this.type = type;
}
Vehicle.prototype.describe = function () {
  console.log(`這是一輛 ${this.type}`);
};

function Car(brand, model) {
  // 繼承 Vehicle 的實例屬性
  Vehicle.call(this, '汽車');
  this.brand = brand;
  this.model = model;
}

// 建立原型鏈,讓 Car 可以使用 Vehicle 的方法
Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.constructor = Car;

// Car 自己的 method
Car.prototype.show = function () {
  console.log(`品牌:${this.brand},型號:${this.model}`);
};

const myCar = new Car('Toyota', 'Corolla');
myCar.describe(); // 這是一輛 汽車
myCar.show();     // 品牌:Toyota,型號:Corolla

範例 3:在原型上定義「靜態」屬性(其實是建構函式本身的屬性)

function Circle(radius) {
  this.radius = radius;
}

// 靜態屬性:計算圓面積的公式(不會被實例繼承)
Circle.PI = Math.PI;

// 原型方法:計算面積
Circle.prototype.area = function () {
  return Circle.PI * this.radius ** 2;
};

const c = new Circle(5);
console.log(Circle.PI);      // 3.141592653589793
console.log(c.area());       // 78.53981633974483

範例 4:利用 Object.defineProperty 為建構函式加上唯讀屬性

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

// 設定唯讀的類別名稱
Object.defineProperty(User, 'type', {
  value: '普通使用者',
  writable: false,   // 不允許改寫
  enumerable: true,
});

console.log(User.type); // 普通使用者
// User.type = '管理員'; // 在嚴格模式下會拋錯,非嚴格模式則不會改變

範例 5:混合 ES6 class 與舊式建構函式的遺產系統

// 舊式建構函式
function Shape(color) {
  this.color = color;
}
Shape.prototype.draw = function () {
  console.log(`以 ${this.color} 畫出圖形`);
};

// ES6 class 繼承舊式建構函式
class Rectangle extends Shape {
  constructor(color, width, height) {
    // 必須呼叫 super,等同於 Shape.call(this, color)
    super(color);
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }
}

const rect = new Rectangle('紅色', 10, 5);
rect.draw();           // 以 紅色 畫出圖形
console.log(rect.area()); // 50

常見陷阱與最佳實踐

常見問題 為何會發生 正確做法
忘記使用 new 直接呼叫建構函式會把 this 指向全域物件 (windowundefined in strict mode)。 必須以 new Person() 方式產生實例,或在函式內加 if (!(this instanceof Person)) return new Person(...); 以防止遺漏。
原型被意外覆寫 直接把 Dog.prototype = new Animal(); 會把 constructor 變成 Animal,導致 instanceof 判斷錯誤。 使用 Object.create(Animal.prototype),並手動修正 constructor
在原型上放置可變資料 若在 prototype 中放置陣列或物件,所有實例會共享同一個參考,容易產生互相干擾的 bug。 把可變資料放在建構函式內部 (this.list = []);原型只放方法。
忘記回傳 this 某些情況下想要鏈式呼叫,但建構函式自行回傳了其他物件,會破壞預期行為。 建構函式一般不需要自行 return,讓 new 自動回傳 this
使用 instanceof 判斷跨框架的物件 不同執行環境(iframe、Node VM)會有不同的全局建構函式,instanceof 可能失效。 改用 Object.prototype.toString.call(obj) === '[object ClassName]' 或檢查必須的屬性/方法。

最佳實踐

  1. 統一命名規則:建構函式首字母大寫,普通函式則小寫。
  2. 使用 Object.create 建立原型鏈,避免直接指派實例。
  3. 保持原型乾淨:只放方法,避免在原型上放置可變狀態。
  4. 在 ES6 環境優先使用 class,但仍了解底層運作,以便在需要手寫原型時不出錯。
  5. 加入 hasOwnProperty 防止原型污染:在遍歷屬性時,先檢查 obj.hasOwnProperty(key)

實際應用場景

  1. 表單模型(Form Model)

    • 每一筆表單資料可用建構函式 FormData 包裝,提供 validate()reset() 等共用方法,且每筆資料都有自己的屬性值。
  2. 遊戲角色系統

    • 基礎角色 Character 包含血量、位置等屬性,子類別 WarriorMage 繼承並加入各自的技能方法。這樣的結構讓角色行為可在原型上共享,提高效能。
  3. 前端 UI 元件庫

    • 如自訂的 ModalTooltip,用建構函式產生實例,並把共用的渲染、事件綁定寫在 prototype,減少重複程式碼。
  4. Node.js 資料存取層

    • Database 建構函式負責連線設定,UserModelProductModel 繼承 Database,共用 query()close() 方法,同時保有各自的資料表名稱。
  5. 跨平台程式碼共享

    • 在同一套程式碼庫中,部分模組仍需使用舊式建構函式(因為第三方套件只接受 function),而新開發的模組則使用 class。了解兩者的等價關係,可讓專案平滑過渡。

總結

  • 建構函式是 JavaScript 物件導向的根基,透過 newprototype 與原型鏈,實現了彈性且記憶體友善的繼承機制。
  • 正確使用 prototype 能讓所有實例共享方法,避免不必要的記憶體開銷;同時要小心 可變資料 不要放在原型上。
  • 常見的陷阱包括忘記 new、原型被意外覆寫、跨環境的 instanceof 判斷等,遵循 命名規則、Object.create、保持原型乾淨 的最佳實踐可減少錯誤。
  • 在表單模型、遊戲角色、UI 元件、資料存取層等真實專案中,建構函式與原型繼承提供了可擴充、易維護的程式結構。

掌握了建構函式的運作後,你就能在 JavaScript 中自如地設計物件、實作繼承,為日後使用更高階的 classObject.setPrototypeOfProxy 打下堅實基礎。祝你寫程式愉快,持續探索 JavaScript 的無限可能!