如果你常在專案裡糾結「這段要不要暴露 children?」「呼叫端要不要先判斷型別?」那其實你已經在撞 Composite 的牆了。組合模式的價值在於:將「結構的層級感」折疊成一套一致的操作界面,讓 Leaf 與 Composite 在使用上看起來一樣。這不只讓程式碼變乾淨,也方便擴充與測試。
本文用 JavaScript 拆解核心角色(Component/Leaf/Composite)、透明式與安全式 API 的取捨、DFS/BFS 的遍歷策略,並補上非同步聚合與快取失效的做法。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要 Composite?一句話定義與核心動機
Composite(組合)模式讓單一物件(Leaf)與容器物件(Composite)在介面上看起來一樣,你可以用一致的方式對待樹狀結構中的每一個節點。 好處是把「使用端」從「如何遍歷樹」與「節點到底是葉子還是容器」的判斷中解放出來,讓程式更簡潔、可擴充。
一句話:
把單個與多個的差異藏起來,對使用者呈現「一個節點就是一個節點」的介面。
什麼時候該用、什麼時候不該用
適用情境:
UI 元件樹(如選單、表單節點、圖層)
檔案系統(資料夾 vs. 檔案)
組裝產品(零件 vs. 組件)
權限/策略樹、規則引擎樹、章節/段落目錄(ToC)
不適用情境:
結構扁平、只有 1–2 層,硬套 Composite 會過度設計
子節點共享與計算副作用複雜(需 DAG/避免重複計算,改用快取或專用圖結構)
模式結構與名詞對照
Component:抽象介面,規範所有節點能做的事(例如 operation())。
Leaf:葉節點,沒有子節點,實作 operation()。
Composite:容器節點,持有多個子節點,並將 operation() 委派/聚合到所有子節點。
兩種常見風格:
透明式:在 Component 就定義 add/remove,Leaf 也有此方法但預設丟錯;呼叫端邏輯最單純。
安全式:add/remove 僅存在 Composite;呼叫前需用型別守衛判斷是否為容器。
用 ES6 實作:基礎版(透明式)
// Component:所有節點的共同介面
class Component {
constructor(name) {
this.name = name;
}
add(child) {
throw new Error(`${this.constructor.name} 不支援 add()`);
}
remove(child) {
throw new Error(`${this.constructor.name} 不支援 remove()`);
}
isComposite() {
return false;
}
operation() {
throw new Error(`${this.constructor.name} 必須實作 operation()`);
}
}
// Leaf:葉節點
class Leaf extends Component {
operation() {
return `Leaf(${this.name})`;
}
}
// Composite:容器節點
class Composite extends Component {
constructor(name) {
super(name);
this.children = [];
}
isComposite() {
return true;
}
add(child) {
if (child === this) {
throw new Error('不能把節點加到自己底下(self-cycle)');
}
if (this._wouldCreateCycle(child)) {
throw new Error('偵測到循環參照,請確認樹結構');
}
this.children.push(child);
return this; // 方便鏈式呼叫
}
remove(child) {
this.children = this.children.filter(c => c !== child);
}
operation() {
const results = this.children.map(c => c.operation());
return `Composite(${this.name})[${results.join(', ')}]`;
}
_wouldCreateCycle(node) {
// 若把 node 掛到 this 底下,檢查 node 的子孫是否包含 this
const stack = [node];
while (stack.length) {
const cur = stack.pop();
if (cur === this) return true;
if (typeof cur.isComposite === 'function' && cur.isComposite()) {
stack.push(...cur.children);
}
}
return false;
}
}
// Demo
const root = new Composite('root');
const groupA = new Composite('A');
const groupB = new Composite('B');
const leaf1 = new Leaf('1');
const leaf2 = new Leaf('2');
root.add(groupA).add(groupB);
groupA.add(leaf1);
groupB.add(leaf2);
console.log(root.operation());
// => Composite(root)[Composite(A)[Leaf(1)], Composite(B)[Leaf(2)]]
為什麼先用透明式? 對呼叫端最直覺:永遠能呼叫 add/remove,不用先判斷型別。代價是 Leaf 上暴露了「看起來存在但會丟錯」的方法。下節將展示更「安全式」的寫法。
實務範例(一):網站選單樹(Menu Tree)
資料結構與需求
每個節點可能是可點擊連結(Leaf),也可能是下拉/巢狀選單(Composite)。
對外統一提供 render(),輸出 HTML 字串,供 SSR 或前端插入。
class MenuComponent {
constructor(label, href = null) {
this.label = label;
this.href = href; // 若為 null,表示它是群組節點
}
add(_) { throw new Error('不支援 add()'); }
remove(_) { throw new Error('不支援 remove()'); }
isComposite() { return false; }
render() { throw new Error('必須實作 render()'); }
}
class MenuItem extends MenuComponent {
constructor(label, href) {
super(label, href);
}
render() {
const safeLabel = escapeHtml(this.label);
const safeHref = escapeAttr(this.href);
return `<li><a href="${safeHref}">${safeLabel}</a></li>`;
}
}
class MenuGroup extends MenuComponent {
constructor(label) {
super(label, null);
this.children = [];
}
isComposite() { return true; }
add(child) {
this.children.push(child);
return this;
}
render() {
const safeLabel = escapeHtml(this.label);
const childrenHtml = this.children.map(c => c.render()).join('');
return `<li class="menu-group"><span>${safeLabel}</span><ul>${childrenHtml}</ul></li>`;
}
}
// 簡易 XSS 防護(範例用,實務請用成熟庫)
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function escapeAttr(s) { return escapeHtml(s); }
// 建立選單樹
const rootMenu = new MenuGroup('主選單')
.add(new MenuItem('首頁', '/'))
.add(
new MenuGroup('產品')
.add(new MenuItem('A 系列', '/products/a'))
.add(new MenuItem('B 系列', '/products/b'))
)
.add(
new MenuGroup('關於我們')
.add(new MenuItem('公司介紹', '/about'))
.add(new MenuItem('加入我們', '/careers'))
);
const html = `<ul class="menu-root">${rootMenu.children.map(c => c.render()).join('')}</ul>`;
console.log(html);
重點:不管是 MenuItem 還是 MenuGroup,外部都只呼叫 render(),呼叫者不需要知道它是哪一種型別。
實務範例(二):檔案系統大小統計(含快取)
需求:
File(Leaf)有固定大小;Folder(Composite)大小為子節點總和。
加速:對 Folder.size() 進行結果快取,當新增/移除子節點時再失效。
class Entry {
constructor(name) { this.name = name; }
size() { throw new Error('必須實作 size()'); }
add(_) { throw new Error('不支援 add()'); }
remove(_) { throw new Error('不支援 remove()'); }
isComposite() { return false; }
}
class FileEntry extends Entry {
constructor(name, bytes) {
super(name);
this.bytes = bytes;
}
size() { return this.bytes; }
}
class FolderEntry extends Entry {
constructor(name) {
super(name);
this.children = [];
this._cachedSize = null;
}
isComposite() { return true; }
add(child) { this.children.push(child); this._cachedSize = null; return this; }
remove(child) { this.children = this.children.filter(c => c !== child); this._cachedSize = null; }
size() {
if (this._cachedSize != null) return this._cachedSize;
const total = this.children.reduce((sum, c) => sum + c.size(), 0);
this._cachedSize = total;
return total;
}
}
// Demo
const root = new FolderEntry('root');
const src = new FolderEntry('src');
const img = new FolderEntry('img');
root.add(src).add(img);
src.add(new FileEntry('index.js', 4000)).add(new FileEntry('util.js', 2000));
img.add(new FileEntry('logo.png', 120000));
console.log(root.size()); // 126000
img.add(new FileEntry('banner.jpg', 300000)); // 變動 => 使快取失效
console.log(root.size()); // 426000
注意:快取是把「讀多寫少」的 Composite 變快。若樹頻繁變動,請權衡快取的效益與失效成本。
進階技巧
1. 非同步 Composite(如:遠端查詢、延遲計算)
若 Leaf.operation() 回傳 Promise,Composite 只需 Promise.all 聚合:
class AsyncLeaf {
constructor(name, fetchFn) { this.name = name; this.fetchFn = fetchFn; }
isComposite() { return false; }
async operation() { return this.fetchFn(this.name); }
}
class AsyncComposite {
constructor(name) { this.name = name; this.children = []; }
isComposite() { return true; }
add(c) { this.children.push(c); return this; }
async operation() {
const results = await Promise.all(this.children.map(c => c.operation()));
return { name: this.name, results };
}
}
2. 遍歷策略(DFS/BFS)
DFS(深度優先):容易用遞迴,記憶體省;適合大多數合成操作。
BFS(廣度優先):層級處理、最短路徑等需求。
迭代版 DFS(避免深遞迴爆棧):
function traverse(root, visit) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
visit(node);
if (node.isComposite?.()) {
// 反向推入以維持與遞迴相同的左到右順序
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
}
3. 不可變資料(Immutable)
避免「共享參照導致難以追蹤誰改了結構」,可改採回傳新樹而非原地修改:
function addImmutable(parent, child) {
if (!parent.isComposite?.()) throw new Error('只能對 Composite 新增子節點');
const copy = new parent.constructor(parent.name);
copy.children = [...parent.children, child];
return copy;
}
4. 安全式 API 與型別守衛
若你要防止呼叫者對 Leaf 誤用 add/remove:
function isCompositeNode(node) {
return typeof node.isComposite === 'function' && node.isComposite();
}
// 使用前判斷
if (isCompositeNode(node)) node.add(child);
效能與測試
1. 複雜度
單次 operation() 若需要遍歷全部子孫,時間複雜度 O(N)(N 為節點數)。
使用快取可把重複查詢壓低到 O(1),但新增/移除需要進行失效處理。
深樹請避免遞迴太深造成堆疊溢位,改用迭代或分批處理。
2. 記憶體與資源回收
remove() 時請清理事件監聽器、計時器與外部資源,避免記憶體洩漏。
若節點持有大型快取,請提供 dispose() 明確釋放。
3. 基礎單元測試(Node 內建 assert)
import assert from 'node:assert/strict';
const root = new FolderEntry('root');
const a = new FolderEntry('a').add(new FileEntry('x', 10));
root.add(a).add(new FileEntry('y', 5));
assert.equal(root.size(), 15);
a.add(new FileEntry('z', 7));
assert.equal(root.size(), 22); // 快取應失效後重算
常見錯誤與雷點
1. 在 Leaf 上呼叫 add/remove
症狀:執行期才丟錯。
修正:採安全式 API或在呼叫前用 isCompositeNode() 做守衛。
2. 忘了在 Composite 的 operation() 遍歷所有子節點
症狀:只有第一層生效。
修正:children.map(c => c.operation()) 並聚合結果。
3. 循環參照(把祖先加成子孫)
症狀:遞迴爆棧或無限迴圈。
修正:在 add() 時做祖先檢查(如 _wouldCreateCycle)。
4. 共享 Leaf 被重複計算
症狀:同一個 Leaf 掛在多個 Composite,彙總結果被重複加總。
修正:若要共享,請改為DAG 模型並在計算時去重或使用參照計數。
5. 快取未失效
症狀:加子節點後結果仍為舊值。
修正:在 add/remove 時清空 _cachedSize 或向上冒泡失效。
6. 遞迴過深導致堆疊溢位
修正:改用迭代 DFS/BFS;或在分段任務中分批處理(setTimeout/queueMicrotask)。
7. 把 children 公開導致外部亂改
修正:以私有欄位(#children)或唯讀存取器封裝,對外僅提供 add/remove。
8. 非同步聚合沒 await
症狀:拿到一堆 Promise。
修正:await Promise.all(children.map(c => c.operation()))。
9. 在 Leaf 上放了 UI 事件卻忘記在 remove() 清掉
修正:提供 dispose();在 Composite 的 remove() 中遞迴呼叫子節點 dispose()。
10. 透明式 API 被濫用
症狀:團隊以為 Leaf 也能安全 add,導致大量執行期錯誤。
修正:文件清楚標示、加上型別守衛或切換到安全式設計。
與其他模式對照
Decorator:
為單一物件動態套裝飾;Composite 是樹狀聚合。兩者可搭配,先組樹再以裝飾器包裝節點。
Iterator:
用來遍歷集合;Composite 常內建或外掛 Iterator 以走訪樹。
Visitor:
把「對節點做的事」抽離成訪客;當需要對不同節點型別做不同操作時,Visitor + Composite 很合拍。
Flyweight:
共享小而多的物件以省記憶體;用在大規模葉節點場景,避免重複分配。
問題集
Q1:樹非常深,會不會卡住頁面?
A:改用迭代版遍歷,或把長任務切片(requestIdleCallback、setTimeout)分段執行。非同步 operation() 可並行運算。
Q2:可以讓多個父節點共享同一個子節點嗎?
A:可以,但已脫離「樹」變成「有向無環圖(DAG)」;聚合數值時必須去重,或改以引用計數/記憶化避免重算。
Q3:我要不要用透明式 API?
A:小團隊/快速驗證可選透明式,迭代快;多人協作/長期維護建議安全式,加上型別守衛與明確的介面文件。
總結
Composite 的關鍵價值是統一介面與遞迴聚合:呼叫端只面對「節點」,而不是「節點種類」。當你的問題天然是樹(UI、選單、檔案、規則),Composite 能帶來更乾淨的結構與更容易擴充的實作。
延伸閱讀方向:Visitor(把操作抽離)、Iterator(遍歷抽象化)、Flyweight(大量葉節點的記憶體優化)。在工程實務裡,請同步關注快取失效、資源回收與非同步聚合這三個長期維護點。
附:安全式 API 的極簡版本(參考)
class SafeComponent {
operation() { throw new Error('必須實作 operation()'); }
isComposite() { return false; }
}
class SafeLeaf extends SafeComponent {
constructor(name) { super(); this.name = name; }
operation() { return `Leaf(${this.name})`; }
}
class SafeComposite extends SafeComponent {
constructor(name) { super(); this.name = name; this.children = []; }
isComposite() { return true; }
add(child) { this.children.push(child); return this; }
remove(child) { this.children = this.children.filter(c => c !== child); }
operation() { return this.children.map(c => c.operation()).join(' + '); }
}
// 使用端:先判斷再 add
function addIfComposite(node, child) {
if (node.isComposite()) node.add(child);
}
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Bridge( 橋接模式 )
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
