你的類別還在自己 new
東西嗎?
當一個類別手動自己建立它所依賴的物件時,它就像一個什麼都要自己來的控制狂,最後會和具體的實作綁死,發生強耦合的臭味。
High-level modules should not depend on low-level modules. Both should depend on abstractions.
高階模組不應依賴低階模組,兩者都應依賴於抽象。
依賴注入 (Dependency Injection, DI) 正是實現此原則的優雅手段,達成控制反轉 (Inversion of Control, IoC) 也就是把「建立依賴」的控制權,從類別內部反轉到外部。
用「泡茶」這個簡單的流程,來理解這一切。
泡茶的 SOP:1. 燒開水 -> 2. 準備茶葉 -> 3. 沖泡 -> 4. 加入調味料。
其中,「燒開水」和「沖泡」是固定的主要流程,但「準備什麼茶葉」和「加什麼調味料」應該是彈性多變的。
一位「控制狂」 TeaMaker
什麼都想自己來。
它自己決定要用什麼茶葉、加什麼調味料。
這會導致什麼後果?
想換個口味,唯一的辦法就是直接修改 TeaMaker
的程式碼。
class BlackTea { getName = () => "錫蘭紅茶"; }
class Sugar { getName = () => "蔗糖"; }
class TeaMaker {
makeBlackTeaWithSugar() {
this.boilWater();
// 問題的根源:自己動手 new 依賴,將自己與「紅茶」、「糖」綁死
const teaLeaves = new BlackTea();
const condiment = new Sugar();
this.brew(teaLeaves);
this.addCondiment(condiment);
console.log(`一杯香甜的${teaLeaves.getName()}加${condiment.getName()}完成了!`);
}
// 想換口味?只能複製貼上,不斷增加新方法...
// makeGreenTea(), makeOolongWithMilk()... 程式碼開始腐壞
boilWater() { ......}
brew(teaLeaves) { ...... }
addCondiment(condiment) { ....... }
}
上面每一次的 new
,都是在把 TeaMaker
和特定的茶葉、特定的調味料綁得更緊,極度缺乏彈性。
想喝「綠茶加牛奶」?
你只能再去加一個 makeGreenTeaWithMilk
的函式,還必須修改 TeaMaker
的程式碼。
一個專業的 TeaMaker
(吧台師傅),應該專注於「泡茶」的技藝,並依賴一個「抽象」的契約,而不是具體的「實作」。
至於客人想用什麼茶葉、加什麼調味料,應該由「客人」(外部調用者)決定並提供給他。
我們會明確定義 ITeaLeaves
這樣的介面 (Interface)。
在 JavaScript 中,我們則依賴鴨子型別 (Duck Typing) —— 只要傳入的物件「看起來像茶葉」(也就是有 .getName()
方法),我們就視它為合法的依賴。
/**
* interface ITeaLeaves { getName(): string; }
* interface ICondiment { getName(): string; }
*/
// TeaMaker 不再控制依賴的產生,只專注於「泡茶」的核心流程
class TeaMaker {
// 這個方法乾淨專注、好擴充
// 它不在乎傳進來的是什麼茶葉、什麼調味料。
// 只要它們符合我們期待的「抽象」(有 getName 方法),就能完美協作。
makeTea(teaLeaves, condiment = null) {
this.boilWater();
this.brew(teaLeaves);
if (condiment) {
this.addCondiment(condiment);
}
const teaName = teaLeaves.getName();
const condimentName = condiment ? `,佐以 ${condiment.getName()}` : "";
console.log(`一杯為您客製的 ${teaName}${condimentName} 已經準備好了!`);
}
boilWater() { console.log("燒開水中..."); }
brew(teaLeaves) { console.log(`使用 ${teaLeaves.getName()} 進行沖泡...`); }
addCondiment(condiment) { console.log(`加入調味料:${condiment.getName()}...`); }
}
在外部,可以像樂高一樣自由組合,準備各種具體的「零件」。
// 「零件們」
class BlackTea { getName = () => "錫蘭紅茶"; }
class GreenTea { getName = () => "龍井綠茶"; }
class OolongTea { getName = () => "阿里山烏龍"; }
class Sugar { getName = () => "蔗糖"; }
class Milk { getName = () => "香醇牛奶"; }
const teaMaker = new TeaMaker();
// 客人 1: 我要紅茶加糖
teaMaker.makeTea(new BlackTea(), new Sugar());
// > 一杯為您客製的 錫蘭紅茶,佐以 蔗糖 已經準備好了!
// 客人 2: 我要綠茶加牛奶
teaMaker.makeTea(new GreenTea(), new Milk());
// > 一杯為您客製的 龍井綠茶,佐以 香醇牛奶 已經準備好了!
// 客人 3: 我只要一杯烏龍茶,謝謝
teaMaker.makeTea(new OolongTea());
// > 一杯為您客製的 阿里山烏龍 已經準備好了!
TeaMaker
類別本身完全不用修改,我們卻能創造出無限多種組合。
我們把「控制權」從 TeaMaker
內部反轉到了外部,這就是控制反轉 (Inversion of Control, IoC),而依賴注入 (Dependency Injection, DI) 是實現它的方法。
當應用變得複雜時,TeaMaker
變乾淨了,但我們把組裝的複雜性轉移到了「外部調用者」,這正是DI 容器 (DI Container) 存在的理由。
在大型框架中,你只需要宣告一個類別需要哪些依賴,DI 容器就會在幕後自動分析依賴樹,並為你完成所有 new
和組裝工作。
DI 容器雖然強大,但也引入了新的挑戰。
依賴關係由容器在背後串連,降低了程式碼的直接追蹤。
看著 Car
的建構子,不一定能立刻知道它會被注入哪個引擎實作。
這是一種用「可追蹤性」換取「高度彈性」的交易。
我們可以透過以下方式應對:
DI 不只有一種方法,注入的「時機」決定了依賴的性質。
最常見的兩種是「方法注入」與「建構子注入」。
TeaMaker
範例使用的是「方法注入」,它適合「執行某個任務時才需要」的依賴。
那如果某個依賴是類別「存在就必須擁有」的呢?
這時,建構子注入 (Constructor Injection) 是更好的選擇。
它能確保物件在被建立的當下,就是一個完整的、有效的狀態。
class Car {
// 一輛車被製造出來時,就必須要有引擎
constructor(engine) {
if (!engine) throw new Error("Car requires an engine to be created.");
this.engine = engine;
}
start() {
this.engine.ignite();
}
}
// 確保了 myCar 從誕生起就是一個合法的物件
const myCar = new Car(new V8Engine());
選擇原則:
DI 的目標不是消滅 new
,而是在正確的地方使用 new
。
問自己:「這個依賴在『測試環境』或『不同部署環境』下,有沒有可能需要被替換?」
應該「被」注入的依賴 (Inject):
服務 (Services):PaymentService
(因為測試時想換成假的支付服務)。
儲存庫 (Repositories):UserRepository
(因為測試時想換成記憶體資料庫)。
外部依賴:資料庫連線、API 客戶端、Logger (因為測試時不想真的寫入 Log)。
任何你想在測試中可以被替換 (Mock) 的東西。
可以安全 new
的對象 (new
):
值物件 (Value Objects):new Date()
、new Money(100, 'USD')
(它們穩定、內聚,除非你需要測試控制時間)。
資料傳輸物件 (DTOs):new UserDTO(data)
(它們只是單純的資料容器)。
例外物件:new Error('Something went wrong')
。
不包含外部依賴、穩定且內部的工具類。
對於生命週期短、需求極度穩定、或一次性的腳本,直接 new
的確更簡單高效。
DI 的威力體現在「應對變化」上,它的價值與專案的生命週期長度和需求變更頻率成正比。
引入 DI 是一種對「未來可維護性」的投資,請確保你的專案需要這份投資回報。
你可能會想,我用工廠模式 (Factory Pattern) 也能解決耦合問題。
沒錯!工廠模式主要解決「如何建立物件」的複雜性,而 DI 更關注「如何將已建立的物件串連起來」。
DI(特別是建構子注入)的核心優勢在於明確性 (Explicitness)。
它讓一個類別的依賴在其「合約 (contract)」(即建構函式) 中一目了然。
你一看就知道,這個類別沒有這些依賴就無法正常工作,這對程式碼的清晰度和可測試性是巨大的勝利。
停止在類別內部 new
外部依賴,改由外部傳入。
依賴注入的本質是 控制反轉 (IoC),將建立物件的控制權交給外部。
根據依賴的性質選擇注入方式:用「建構子注入」保證物件完整性,用「方法注入」提供任務彈性。
依賴注入能大幅降低類別之間的耦合度,也轉移了複雜性:在大型應用中,需要「DI 容器」來自動管理依賴的組裝。
解耦合能帶來三大好處:高彈性、高擴展性、高可測性。
寫死的依賴像強力膠,一體成形但無法拆換;而依賴注入則像樂高,結構穩固又能隨時拆解、替換與升級。
放開你的控制慾吧!
讓你的類別只專注於自己的主要職責,把依賴的控制權交出去。