前端寫久了就知道:功能會變、版位會換、需求永遠來回改。這時如果把資料和行為好好封裝,日後改起來會輕鬆很多。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。
