課程:JavaScript 與 React 底層原理 第 6 堂:Topic 3 收尾與複習
15 組合 vs 繼承
想像你正在開發一個角色扮演遊戲。一開始,你設計了一個 Character 物件,接著你想要一個「戰士(Warrior)」,於是你讓戰士繼承自角色;然後你想要一個「法師(Mage)」,他也繼承自角色。這一切看起來都很完美,直到有一天,策劃告訴你:「我們想要一個『魔法戰士』,他既能揮劍又能施法。」
這時,你該怎麼辦?讓 MagicWarrior 同時繼承 Warrior 和 Mage?在 JavaScript 中,這是不可能的,因為一個物件只能有一個原型。如果你強行建立一個深層的繼承鏈,比如 Character -> Mage -> Warrior -> MagicWarrior,你會發現這個戰士莫名其妙地背負了許多他可能不需要的法師屬性,且一旦你修改了最頂層的 Character,所有子類別都可能產生意想不到的連鎖崩潰。
這就是我們今天要探討的核心:為什麼在現代軟體工程中,我們越來越傾向於「組合(Composition)」,而不是傳統的「繼承(Inheritance)」。
繼承的甜蜜陷阱:當「Is-a」變得沉重
在物件導向的思維中,繼承強調的是 「Is-a(是一個)」 的關係。企鵝「是一個」鳥,戰士「是一個」角色。這種思維在分類學上很直觀,但在軟體變動頻繁的現實中,它會帶來巨大的代價。
脆弱的基類問題(Fragile Base Class)
當你建立一個繼承體系時,子物件會緊緊地耦合在父物件(原型)之上。這意味著:
- 牽一髮而動全身:如果你修改了原型物件中的一個方法名稱,你必須確保所有繼承它的子物件都沒有因為這個修改而壞掉。
- 被迫繼承不需要的東西:這就是著名的「大猩猩與香蕉」問題(由 Erlang 創始人 Joe Armstrong 提出):「你想要的是一根香蕉,但你得到的是一隻拿著香蕉的大猩猩,以及整個熱帶雨林。」當你繼承一個物件來獲得某個小功能時,你被迫接收了它所有的狀態與行為,這增加了記憶體負擔與維護難度。
預測失敗:分類的僵化
繼承要求你在寫程式碼的第一天,就精確預測未來所有的分類結構。然而,需求總是會變的。如果你發現原本屬於 Bird 類別的 Penguin 其實不會飛,你該把 fly() 方法放在哪裡?如果放在 Bird 上,企鵝就有了錯誤的行為;如果不放,那會飛的鳥又得各自實作 fly()。
這種分類的混亂,正是我們轉向「組合」的契機。
組合的靈活性:物件的「Has-a」哲學
與其問「這個物件是什麼(Is-a)」,組合建議我們問:「這個物件擁有什麼能力(Has-a)」。
組合就像是玩樂高積木。我們不預定義一個完整的、僵化的實體,而是定義一個個細小的、功能單一的「行為片斷」。當我們需要一個特定的物件時,再將這些行為「組裝」起來。
- 繼承:你出生在一個木匠家庭,所以你必須是一個木匠。
- 組合:你學會了木工、學會了繪畫、又學會了程式設計。你是一個「擁有這三種技能」的人。
這種思維將關注點從「身份」轉移到了「功能」。
實戰:打造多功能機器人
讓我們透過一個機器人的例子,來看看這兩種思維的差異。假設我們的系統中需要三種機器人:
- 掃地機器人:會掃地。
- 音樂機器人:會唱歌。
- 全能清潔管家:既會掃地,又會唱歌。
傳統繼承的困局
如果你嘗試用原型繼承來實作:
const robot = {
drive() { console.log("行進中..."); }
};
const cleanerRobot = Object.create(robot);
cleanerRobot.sweep = function() { console.log("正在掃地..."); };
const singingRobot = Object.create(robot);
singingRobot.sing = function() { console.log("正在唱歌..."); };
// 現在,全能清潔管家該繼承誰?
// 如果繼承 cleanerRobot,就拿不到 sing;
// 如果繼承 singingRobot,就拿不到 sweep。
在單一繼承的限制下,你可能被迫重複實作程式碼,或者建立一個極其複雜的層次結構,這顯然不是長久之計。
組合方案:行為的按需分配
現在,我們換個思路。我們將「行為」抽離成獨立的函數,這些函數接收一個物件並賦予它能力:
// 定義行為片斷 (Behaviors)
const canDrive = () => ({
drive: () => console.log("行進中...")
});
const canSweep = () => ({
sweep: () => console.log("正在掃地...")
});
const canSing = () => ({
sing: () => console.log("La La La~")
});
// 透過組合來建立物件
const createCleanerRobot = (name) => {
const robot = { name };
// 將行為「混入」物件中
return Object.assign(robot, canDrive(), canSweep());
};
const createSingingRobot = (name) => {
const robot = { name };
return Object.assign(robot, canDrive(), canSing());
};
const createAllInOneRobot = (name) => {
const robot = { name };
// 輕而易舉地組合多種行為
return Object.assign(robot, canDrive(), canSweep(), canSing());
};
const myRobot = createAllInOneRobot("阿福");
myRobot.drive(); // 行進中...
myRobot.sweep(); // 正在掃地...
myRobot.sing(); // La La La~
在這個例子中,createAllInOneRobot 並不「屬於」掃地機器人或音樂機器人,它只是「擁有」了掃地與唱歌的能力。這種結構極其扁平且靈活。如果明天需要一個會跳舞的機器人,我們只需要增加一個 canDance 行為,並將其加入組合清單即可。
為什麼 JavaScript 是組合的天堂
JavaScript 的動態特性與原型系統,讓組合變得異常簡單。
1. Object.assign 的威力
Object.assign(target, ...sources) 是實現組合的核心工具。它能將多個來源物件的屬性與方法複製到目標物件中。這在底層其實就是一種「屬性合併」。
預測與思考:
如果兩個行為片斷中有同名的方法(例如 canSweep 和 canSing 都有一個 init 方法),Object.assign 會發生什麼事?
揭曉: 後面的來源物件會覆蓋前面的。這雖然是一個潛在的命名衝突問題,但也賦予了我們「覆寫(Override)」預設行為的能力。
2. 展開運算子(Spread Operator)
ES6 的 ... 語法讓組合物件變得更加優雅:
const superRobot = {
...canDrive(),
...canSweep(),
...canSing(),
name: "超能機器人"
};
這種語法糖在本質上與 Object.assign 相似,它鼓勵我們將物件視為可以隨意拆解與重組的「屬性集合」,而非不可變動的實體。
React 的根基:元件組合 (Component Composition)
你可能會問:這跟 React 有什麼關係?
事實上,「組合優先於繼承」是 React 最核心的設計哲學之一。如果你去查閱 React 官方文件,你會發現它明確建議開發者 「不要使用繼承來建立元件層級」。
在 React 中,我們不會建立一個 BaseButton 然後讓 SubmitButton 繼承它。相反地,我們會建立一個通用的 Button 元件,然後透過組合來使用它。
透過 Props 進行組合
// 通用元件
function Dialog(props) {
return (
<div className="dialog">
<h1 className="title">{props.title}</h1>
<div className="content">
{props.children} {/* 組合的核心:預留插槽 */}
</div>
</div>
);
}
// 專用元件(組合後的結果)
function WelcomeDialog() {
return (
<Dialog title="歡迎光臨">
<p>感謝你訪問我們的應用程式!</p>
</Dialog>
);
}
這就是 props.children 的威力。它允許一個元件「包裹」並「擁有」另一個元件的內容,而不需要知道對方的內部細節。這就是典型的 Has-a 關係:WelcomeDialog 擁有一個 Dialog 的外殼。
這種模式之所以強大,是因為它保持了元件之間的 低耦合。你可以隨時更換 Dialog 內部的 p 標籤為一個 Image 或另一個 Component,而不需要去修改 Dialog 的原型鏈。
深度總結
從 JS 的原型物件到 React 的元件設計,「組合」代表了一種從「靜態分類」到「動態組裝」的思維轉變。
- 繼承(Inheritance) 是關於你是誰(Identity)。它在處理嚴格的層次結構時有用,但容易導致設計僵化與脆性基類問題。
- 組合(Composition) 是關於你能做什麼(Capability)。它透過細粒度的行為拆解,讓物件能像樂高一樣靈活組裝,極大地提升了程式碼的複用性與可維護性。
在 JavaScript 的世界裡,因為我們有 Object.assign、展開運算子以及強大的閉包機制,實作組合模式幾乎沒有門檻。這也解釋了為什麼 React Hooks(本質上也是一種組合函數行為的方式)會取代原本帶有繼承色彩的 Class Component,成為現代前端的主流。
邁向實作:Mixin 模式
理解了組合的哲學後,你可能會好奇:在工程實務中,有沒有更標準化、更安全的方式來進行這些「行為混入」?如果多個行為之間發生衝突,或者我們需要更私密的狀態封裝,該如何處理?
下一部分,我們將深入探討 Mixin(混入)模式。我們會學習如何利用 JavaScript 的特性,將這些行為片斷以更優雅、更具防禦性的方式注入到物件中,並探討這套模式如何演進成為我們今天在 React 中熟知的 Hooks 模式。