JavaScript – 原型與繼承(Prototype & Inheritance)
主題:建構函式(Constructor)
簡介
在 JavaScript 中,建構函式是創建物件的核心機制之一。它不僅負責把屬性與方法初始化,還與原型(prototype)緊密結合,形成 JavaScript 獨特的繼承模型。掌握建構函式的寫法與運作原理,能讓你在開發大型應用時,寫出結構清晰、可維護性高的程式碼。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出真實的應用情境,幫助 初學者到中級開發者 全面了解建構函式在原型與繼承中的角色。
核心概念
1. 什麼是建構函式?
建構函式是一種普通的函式,但慣例上會以 大寫開頭 命名(例如 Person、Car),並透過 new 關鍵字呼叫。使用 new 時,JavaScript 會自動執行以下步驟:
- 創建一個全新的空物件。
- 設定該物件的
[[Prototype]](即__proto__)指向建構函式的prototype物件。 - 執行建構函式,將
this綁定到新物件,讓屬性與方法被初始化。 - 若建構函式沒有顯式回傳物件,則回傳步驟 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 指向全域物件 (window 或 undefined 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]' 或檢查必須的屬性/方法。 |
最佳實踐
- 統一命名規則:建構函式首字母大寫,普通函式則小寫。
- 使用
Object.create建立原型鏈,避免直接指派實例。 - 保持原型乾淨:只放方法,避免在原型上放置可變狀態。
- 在 ES6 環境優先使用
class,但仍了解底層運作,以便在需要手寫原型時不出錯。 - 加入
hasOwnProperty防止原型污染:在遍歷屬性時,先檢查obj.hasOwnProperty(key)。
實際應用場景
表單模型(Form Model)
- 每一筆表單資料可用建構函式
FormData包裝,提供validate()、reset()等共用方法,且每筆資料都有自己的屬性值。
- 每一筆表單資料可用建構函式
遊戲角色系統
- 基礎角色
Character包含血量、位置等屬性,子類別Warrior、Mage繼承並加入各自的技能方法。這樣的結構讓角色行為可在原型上共享,提高效能。
- 基礎角色
前端 UI 元件庫
- 如自訂的
Modal、Tooltip,用建構函式產生實例,並把共用的渲染、事件綁定寫在prototype,減少重複程式碼。
- 如自訂的
Node.js 資料存取層
Database建構函式負責連線設定,UserModel、ProductModel繼承Database,共用query()、close()方法,同時保有各自的資料表名稱。
跨平台程式碼共享
- 在同一套程式碼庫中,部分模組仍需使用舊式建構函式(因為第三方套件只接受
function),而新開發的模組則使用class。了解兩者的等價關係,可讓專案平滑過渡。
- 在同一套程式碼庫中,部分模組仍需使用舊式建構函式(因為第三方套件只接受
總結
- 建構函式是 JavaScript 物件導向的根基,透過
new、prototype與原型鏈,實現了彈性且記憶體友善的繼承機制。 - 正確使用
prototype能讓所有實例共享方法,避免不必要的記憶體開銷;同時要小心 可變資料 不要放在原型上。 - 常見的陷阱包括忘記
new、原型被意外覆寫、跨環境的instanceof判斷等,遵循 命名規則、Object.create、保持原型乾淨 的最佳實踐可減少錯誤。 - 在表單模型、遊戲角色、UI 元件、資料存取層等真實專案中,建構函式與原型繼承提供了可擴充、易維護的程式結構。
掌握了建構函式的運作後,你就能在 JavaScript 中自如地設計物件、實作繼承,為日後使用更高階的 class、Object.setPrototypeOf 或 Proxy 打下堅實基礎。祝你寫程式愉快,持續探索 JavaScript 的無限可能!