本文 AI 產出,尚未審核

JavaScript – 原型與繼承:實例方法 vs 原型方法


簡介

在 JavaScript 中,物件的行為(方法)可以直接寫在每個實例上,也可以放在建構函式的原型(prototype)上。兩者看起來差不多,但在記憶體使用、效能與程式可維護性上會產生顯著差異。
了解 實例方法原型方法 的差別,是掌握 JavaScript 原型繼承(prototype inheritance)與設計高效能物件導向程式的關鍵。

本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,逐步帶領讀者認識何時該使用實例方法、何時該使用原型方法,並提供實務上常見的應用情境。


核心概念

1. 什麼是實例方法?

實例方法是 在每一次 new 時直接寫入物件本身 的函式。

function Person(name) {
  this.name = name;               // 實例屬性
  this.sayHello = function () {  // **實例方法**
    console.log(`Hello, I am ${this.name}`);
  };
}
  • 每建立一個 Person 實例,都會產生一個 獨立的函式物件
  • 這樣做的好處是 每個實例可以擁有自己的閉包環境,方便存取建構時傳入的私有變數。
  • 缺點是 記憶體開銷較大,大量物件會導致相同的函式被重複建立。

2. 什麼是原型方法?

原型方法是 放在建構函式的 prototype 物件上,所有實例共用同一個函式。

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

// **原型方法**
Person.prototype.sayHello = function () {
  console.log(`Hello, I am ${this.name}`);
};
  • 所有 Person 實例在查找屬性時,若在自身找不到,就會往 prototype 鏈上尋找。
  • 因此 同一個函式只會被建立一次,記憶體使用更節省,且方法的更新會即時影響所有實例。

3. 為什麼要區分?

項目 實例方法 原型方法
建立時機 每次 new 時建立新函式 建構函式定義時建立一次
記憶體消耗 較高(每個實例都有副本) 較低(全體共享)
封閉性 可捕獲建構時的私有變數 無法直接存取私有變數(除非使用 WeakMap 等技巧)
執行效能 呼叫時稍慢(因為函式較多) 呼叫時較快(同一函式共享)
動態修改 必須逐一修改每個實例 只要改 prototype 即可影響全部

4. 何時選擇實例方法?

  • 需要封閉變數:例如每個實例都有獨立的計數器或私有狀態。
  • 方法的行為會因實例而異:例如根據建構參數決定不同的實作。

5. 何時選擇原型方法?

  • 方法是「共通」的:大多數情況下,所有實例都會執行相同的邏輯。
  • 需要節省記憶體:大量建立物件時,原型方法是首選。
  • 想要在執行時動態「擴充」或「覆寫」:只要改變 prototype,所有實例立即生效。

程式碼範例

以下示範 4 個常見情境,分別使用實例方法或原型方法,並說明背後的原因。

範例 1️⃣:簡單的 Dog 類別(使用原型方法)

function Dog(name) {
  this.name = name; // 實例屬性
}

// **原型方法**:所有 Dog 都會說 "Woof!"
Dog.prototype.bark = function () {
  console.log(`${this.name} says: Woof!`);
};

const d1 = new Dog('Lucky');
const d2 = new Dog('Buddy');

d1.bark(); // Lucky says: Woof!
d2.bark(); // Buddy says: Woof!

重點bark 只被建立一次,兩個實例共用同一個函式,記憶體使用最小。


範例 2️⃣:需要私有變數的 Counter(使用實例方法)

function Counter(initial = 0) {
  let count = initial; // **私有變數**,只能被閉包存取

  // **實例方法**,每個實例都有自己的閉包
  this.increment = function () {
    count++;
    console.log(`Current count: ${count}`);
  };

  this.decrement = function () {
    count--;
    console.log(`Current count: ${count}`);
  };
}

const c1 = new Counter(5);
const c2 = new Counter(10);

c1.increment(); // Current count: 6
c2.increment(); // Current count: 11

說明:若把 incrementdecrement 放到 prototype,就無法直接存取 count,因此必須使用實例方法。


範例 3️⃣:混合使用 – 共享工具方法、私有實例方法

function Person(name, birthYear) {
  this.name = name;
  this.birthYear = birthYear;

  // 只在建構時需要一次的閉包
  const now = new Date().getFullYear();

  // **實例方法**:計算年齡時使用私有變數 now
  this.getAge = function () {
    return now - this.birthYear;
  };
}

// **原型方法**:所有 Person 都會有 greet
Person.prototype.greet = function () {
  console.log(`Hi, I'm ${this.name}.`);
};

const p1 = new Person('Alice', 1990);
const p2 = new Person('Bob', 1985);

p1.greet(); // Hi, I'm Alice.
p2.greet(); // Hi, I'm Bob.
console.log(p1.getAge()); // 33(假設今年 2023)

技巧:把不需要私有變數的通用行為放到 prototype,只把需要閉包的功能保留為實例方法。


範例 4️⃣:動態改寫原型方法(熱更新)

function Car(model) {
  this.model = model;
}

// 原型方法
Car.prototype.start = function () {
  console.log(`${this.model} engine started.`);
};

const carA = new Car('Toyota');
carA.start(); // Toyota engine started.

// 之後想要加入 log 功能,只要改 prototype
Car.prototype.start = function () {
  console.log(`[LOG] ${new Date().toISOString()}`);
  console.log(`${this.model} engine started.`);
};

carA.start(); // [LOG] ...  Toyota engine started.
const carB = new Car('Honda');
carB.start(); // 同樣會套用新實作

重點只要改變 prototype,所有既有與未來的實例都會立即使用新版本,這在插件系統或熱更新時非常有用。


常見陷阱與最佳實踐

1️⃣ 忘記 new,導致 this 指向全域物件

function User(name) {
  this.name = name;
}
User.prototype.sayHi = function () {
  console.log(`Hi, ${this.name}`);
};

const u = User('Tom'); // ← 忘記 new
// 此時 this === window (或 undefined in strict mode)

解法:在建構函式內部加入保護:

function User(name) {
  if (!(this instanceof User)) return new User(name);
  this.name = name;
}

2️⃣ 在原型上放置 可變的物件(共享問題)

function Box() {}
Box.prototype.items = []; // 所有 Box 共享同一個陣列

const b1 = new Box();
b1.items.push('apple');

const b2 = new Box();
console.log(b2.items); // ['apple']  ← 不符合預期

最佳實踐:把可變的屬性放在建構子裡:

function Box() {
  this.items = []; // 每個實例各自擁有自己的陣列
}

3️⃣ 不必要的實例方法造成記憶體浪費

function Point(x, y) {
  this.x = x;
  this.y = y;
  this.distanceFromOrigin = function () {
    return Math.hypot(this.x, this.y);
  };
}

改寫

function Point(x, y) {
  this.x = x;
  this.y = y;
}
Point.prototype.distanceFromOrigin = function () {
  return Math.hypot(this.x, this.y);
};

4️⃣ 失誤使用 prototype 替代 class 語法(可讀性)

ES6 class 其實是 語法糖,底層仍然是原型繼承。對於新手,建議先使用 class,再逐步了解背後的 prototype 機制。

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} makes a sound.`);
  }
}

實際應用場景

場景 建議使用 為什麼
大量資料模型(例如 ORM、資料表映射) 原型方法 需要高效能、低記憶體開銷,且方法大多相同。
UI 元件庫(每個元件都有獨立的狀態) 混合:狀態屬於實例,公共 API 放在原型 可共享渲染邏輯,同時保護元件私有狀態。
函式式編程或 immutable 物件 原型方法,配合 Object.freeze 只需要純粹的行為,避免不必要的閉包。
需要私有變數的封裝(例如計數器、事件監聽) 實例方法 + 關閉變數 只有實例方法才能持有私有資料。
插件/擴充系統(需要在執行時改寫行為) 原型方法(動態改寫) 只要改 prototype,所有已載入的插件即時生效。

總結

  • 實例方法適合需要私有閉包或每個實例行為不盡相同的情況,但會產生較大的記憶體開銷。
  • 原型方法則是 共享、效能與記憶體最佳化 的首選,特別是大量建立相同類型物件時。
  • 在實務開發中,混合使用往往是最靈活的策略:把共通、純粹的行為放在原型,把需要私有狀態的功能留給實例方法
  • 注意避免 共享可變屬性忘記 new、以及 不必要的實例方法,這些都是新手常踩的坑。
  • 最後,掌握了原型與實例方法的差異,就能更自如地設計 可維護、效能佳 的 JavaScript 程式碼,為日後的框架開發或大型專案奠定堅實基礎。

祝你在 JavaScript 的原型世界中寫出更乾淨、更高效的程式! 🚀