javaScript設計模式 : Composite(組合模式)

 


如果你常在專案裡糾結「這段要不要暴露 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('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}
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設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)


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