javaScript : 物件導向從原型到 class、從封裝到組合

 


前端寫久了就知道:功能會變、版位會換、需求永遠來回改。這時如果把資料和行為好好封裝,日後改起來會輕鬆很多。JavaScript 有 class,但底層其實靠原型在跑;它既能用傳統 OOP 的寫法,也能走工廠函式、Mixin、甚至組合式設計。

這篇不談艱澀理論,直接聚焦「怎麼在真實專案用 OOP 把複雜度關進籠子」,像是怎麼做私有成員、什麼時候該繼承、什麼時候改用組合、this 到底為什麼老是跑丟。文中有可複製的範例、清楚的步驟、以及一張「常見雷點對照表」幫你避坑。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


 為什麼在 JS 要學 OOP

可讀性與協作:團隊開發時,把資料與行為包成物件,最容易對齊邏輯邊界(例如:Cart、Order、Invoice)。

封裝複雜度:對外只曝露必要 API,內部細節可替換。

長期維護:當規模擴大,物件邊界=維護邊界。

非二選一:JS 很自由,OOP 與 FP(函數式)可以並存。你會常在同一專案看到兩者混用。


JS 與「真正」OOP 語言的差異

面向 Java/C# 等典型 OOP JavaScript
物件模型 類別(class)產生實例 原型(prototype)為核心,class 是語法糖
型別系統 靜態、強型別 動態、弱型別(可用 TS 補強)
繼承 類別繼承 原型鍊繼承;class extends 映射到原型
私有成員 語言層面支持(private) 近年有 # 私有欄位;也可用閉包/WeakMap
多型 類別階層、介面 鴨子型別、結構化型別思維
介面 類語言關鍵字(interface) 原生無;可用 TS 或以文件/慣例/測試約束


關鍵結論:JS 的 OOP 更像一組可自由搭配的工具箱。不要硬搬傳統 OOP 教科書做法;你常會用「組合」比「深繼承」更久更穩。


兩大支柱:原型與 class

1.    原型(Prototype)基礎

每個物件都有一個隱含連結到「原型物件」。找不到屬性時,會沿著原型鍊往上找。

const base = { kind: 'base', say() { return `I am ${this.kind}`; } };
const child = Object.create(base);
child.kind = 'child';

console.log(child.say()); // I am child(方法來自原型,但 this 指向 child)


2.    建構子與 prototype

function Person(name) {
  this.name = name;
}
Person.prototype.greet = function () {
  return `Hi, I'm ${this.name}`;
};

const p = new Person('Ada');
console.log(p.greet()); // Hi, I'm Ada


3.    class 是語法糖

class 寫法更直觀,但底層仍是原型。

class Person {
  constructor(name) { this.name = name; }
  greet() { return `Hi, I'm ${this.name}`; }
}


4.    extends 與 super

class Employee extends Person {
  constructor(name, title) {
    super(name);          // 呼叫父類建構子
    this.title = title;
  }
  greet() {               // 覆寫(polymorphism)
    return `${super.greet()} — ${this.title}`;
  }
}


封裝:私有成員 4 招

1.    私有欄位   #  

原生、性能佳、工具鏈支援好。

class Counter {
  #value = 0;
  inc() { this.#value += 1; }
  get value() { return this.#value; }
}

2.    閉包(Closure)

函數工廠式封裝,但每個實例都各自攜帶方法拷貝,記憶體略高。

function createCounter() {
  let value = 0;
  return {
    inc() { value += 1; },
    get value() { return value; }
  };
}


3.     WeakMap

較進階,用於把私有資料外掛到實例鍵上。

const _secret = new WeakMap();
class Box {
  constructor() { _secret.set(this, { token: Math.random() }); }
  get token() { return _secret.get(this).token; }
}


4.    命名慣例

用底線 _internal 標示私有意圖(非強制)。僅在舊環境或過渡期使用。



繼承與為何偏好「組合」

1.    原型鍊與 extends

上述已示範。缺點是繼承階層一深,改動成本急升。需求變動時,你會開始到處小心翼翼「不破壞父類」。

2.    組合(Composition)優勢

把功能拆成「可組積木」,各司其職:小、專、可替換。

例:withLogging(obj), withCache(obj), withDiscount(obj)

以 Mixin 或函式回傳新物件的方式使用

const withLogging = (o) => ({
  ...o,
  log(msg) { console.log(`[${o.name}]`, msg); }
});
const withDiscount = (o) => ({
  ...o,
  priceAfterDiscount(rate) { return o.price * (1 - rate); }
});

const product = withDiscount(withLogging({ name: 'Pen', price: 10 }));
product.log('created');
console.log(product.priceAfterDiscount(0.2)); // 8


多型與介面風格:鴨子型別

JS 天生就是「長得像鴨子,就當鴨子」。若物件有 render(),不在乎它是不是某個 class 的子類,只要能呼叫就行。這給你彈性,也要求紀律(用文件、測試或 TS 來約束)。


物件建立的主流模式

1.    class / 建構子

優點:方法放在原型上,記憶體省;IDE 自動提示友善;super/extends 清晰。

缺點:在高度組合的場景,class 容易變成「萬能類別」。

2.    工廠函式(Factory Function)

優點:閉包易做私有化;自由組合;依賴注入自然。

缺點:每個實例攜帶方法,若方法多、實例多,記憶體較高(可把公共方法抽出去共享)。

const createUser = (name) => {
  let _role = 'guest';
  return {
    get name() { return name; },
    get role() { return _role; },
    promote() { _role = 'member'; }
  };
};


3.    Mixin

用函式把行為「塗抹」到物件或原型上,注意命名衝突與來源追蹤。

4.    組合模組(Composition over Inheritance)

傾向由多個小功能組起來;對大型前端(React/Vue)或 Node 服務都適用。


this 與箭頭函式的坑

方法宣告:建議用一般方法,不要用箭頭函式當「原型方法」,因為箭頭會捕捉定義當下的 this,不會因呼叫者改變。

事件/回呼:把方法傳出去時,容易丟失 this,可用 obj.method.bind(obj) 或在建構子綁定。

class fields + 箭頭:handler = () => { ... } 可保 this,但方法會在每個實例上各自建立(記憶體取捨)。


常見錯誤與雷點

1.    把箭頭函式放在原型方法上

問題:this 被靜態捕捉,換呼叫者就壞。

對策:原型方法用一般函式;需要保 this 的事件處理,建構子 bind 或用 class field 箭頭(取捨記憶體)。

2.    丟失 this

問題:把方法當回呼傳出去時 this 變 undefined。

對策:obj.method.bind(obj);或在使用處用箭頭包起來 () => obj.method()。

3.    過度繼承

問題:三層以上就難維護,需求一變牽一髮動全身。

對策:組合優先,把功能拆小、可替換。

4.    共享可變狀態

問題:把可變資料放在原型上,所有實例共用,造成汙染。

對策:在建構子或工廠內建立實例私有狀態;原型只放方法。

5.    私有不真私有

問題:用底線命名 _secret 當私有,卻被外部改到。

對策:用 #field 或閉包/WeakMap。

6.    JSON 直接深拷貝

問題:JSON.parse(JSON.stringify(obj)) 會丟掉方法、Date、Map。

對策:使用專用深拷貝(如結構化拷貝 structuredClone),或自行實作保留型別。

7.    instanceof 在跨 iframe/Realm 失效

對策:用「結構檢查」(鴨子型別)或 Symbol.hasInstance/自訂守衛。

8.    忘記 super()

問題:在子類建構子使用 this 前沒呼叫 super()。

對策:第一行先 super(...)。

9.    錯把資料物件塞滿行為

問題:一個類別同時做 IO、計算、狀態管理,難測又耦合。

對策:SRP(單一職責),把 IO 與純邏輯分層,依賴注入。

10.    把錯誤用回傳碼帶過

對策:擲出 Error、提供明確訊息;外部捕捉並轉為使用者可理解的結果。


實用小抄

A. class vs 工廠:何時用?

        class:需要原型共享、繼承層次淺、IDE 輔助強。

        工廠:強調封裝與組合、邏輯拼裝、依賴注入、快速原型。

B. 私有欄位選擇

        現代瀏覽器/Node:用 #。

        要與舊工具或序列化互動:閉包或 WeakMap。

C. this 導航

        原型方法→一般函式;事件→bind 或 class field;回呼→箭頭包裹或 bind。


延伸閱讀推薦:






張貼留言 (0)
較新的 較舊