javaScript設計模式 : Facade(外觀模式)

 


如果你常遇到「功能做得出來,但每次要改都像拆炸彈」的情況,很可能是入口太多、耦合太散。

Facade(外觀模式)的思路是把麻煩收進去,把好用留在外面:把一串跨模組的步驟(像庫存預留、請款、託運、寫訂單)包裝成一兩個清楚的方法;呼叫端只面對新的門面,內部流程與第三方差異就不會蔓延。本文從實務出發,用 JavaScript 展示如何為瀏覽器常見操作做門面、如何在 Node.js 把文件處理管線整起來,並討論錯誤回滾、逾時、重試、熔斷這些「門後的眉角」。另外也會澄清 Facade 和 Adapter、Proxy、Mediator 的差別,與避免把 Facade 寫成巨大控制中心。想讓程式更好測、更穩、更能面對變動,從這一扇門開始。


目錄

{tocify} $title={目錄} 


Facade 是什麼?一句話與一張心法

一句話版:

Facade 把多個子系統(Class/模組/服務)「統一入口」起來,對外提供更簡單、更一致的 API;內部怎麼分工、流程多複雜,呼叫端都不必懂。


心法重點:

簡化:把多步驟流程包成 1~2 個方法。

隔離:封裝底層變動(第三方 SDK、瀏覽器相容性、後端版本差異)。

減耦:呼叫端不再依賴一堆具體實作,只依賴 Facade 介面。


何時該用 Facade?

需要一次串多個 API/SDK/元件,呼叫端只想「一鍵搞定」。

底層經常變動(框架版本、第三方金流/雲端服務),不想每個呼叫點都跟著改。

專案開始出現「散落的跨模組呼叫」,修改時牽一髮動全身。

測試困難,想用一個穩定入口做 stub/mock。


Facade 與其他模式的差異

Adapter(轉接器):

        把「不相容介面」轉成我能用的介面;Facade 則是把多個介面簡化整合。

Mediator(仲介者):

        強調物件之間的互動協調;Facade 強調對外提供簡單入口。

Proxy(代理):

        在同一介面前加上快取/權限/遠端代理;Facade 是新介面。

Decorator(裝飾):

        不改行為本質,外掛功能(log、計時);Facade 是改介面維度的簡化。


實戰 1:瀏覽器常用能力,一個 Facade 全包(前端)

假設你常在各頁面重覆處理:通知權限、LocalStorage、定位、剪貼簿、錯誤處理。用 Facade 統一入口:

// web-toolkit.js
export class WebToolkit {
  static async #ensurePermission(name) {
    if (!navigator.permissions) return 'prompt';
    try {
      const status = await navigator.permissions.query({ name });
      return status.state; // 'granted' | 'denied' | 'prompt'
    } catch {
      return 'prompt';
    }
  }

  static save(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }

  static read(key, fallback = null) {
    const raw = localStorage.getItem(key);
    try { return raw ? JSON.parse(raw) : fallback; }
    catch { return fallback; }
  }

  static async notify(title, options = {}) {
    const state = await this.#ensurePermission('notifications');
    if (state === 'denied') return false;
    if (state !== 'granted' && Notification?.requestPermission) {
      const perm = await Notification.requestPermission();
      if (perm !== 'granted') return false;
    }
    new Notification(title, options);
    return true;
  }

  static async geoCurrentPosition(opts = { enableHighAccuracy: true, timeout: 8000 }) {
    if (!navigator.geolocation) throw new Error('Geolocation not supported');
    return new Promise((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(
        pos => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
        err => reject(new Error(`Geo error: ${err.code}`)),
        opts
      );
    });
  }

  static async copy(text) {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
    // fallback
    const ta = document.createElement('textarea');
    ta.value = text; document.body.appendChild(ta);
    ta.select(); const ok = document.execCommand('copy');
    ta.remove(); return ok;
  }
}


使用端只面對 WebToolkit,頁面更乾淨、相容性和權限細節都被吃掉:

import { WebToolkit } from './web-toolkit.js';

async function onShareLocation() {
  try {
    const loc = await WebToolkit.geoCurrentPosition();
    WebToolkit.save('lastLocation', loc);
    await WebToolkit.notify('已取得定位', { body: `${loc.lat}, ${loc.lng}` });
  } catch (e) {
    await WebToolkit.notify('定位失敗', { body: e.message });
  }
}


實戰 2:電商結帳 Facade(前端/後端皆可移植思維)

結帳常牽涉:庫存預留 → 金流請款 → 運送產生託運單 → 寫入訂單 → 記錄事件。用 Facade 包一條龍。

// services(可換成實際實作/SDK)
class InventoryService {
  async reserve(items) { /* ... */ return { ok: true, reservationId: 'rv_123' }; }
  async release(reservationId) { /* ... */ }
}

class PaymentGateway {
  async charge({ amount, token, orderId }) { /* ... */ return { ok: true, txnId: 'txn_888' }; }
  async refund(txnId) { /* ... */ }
}

class ShippingService {
  async createLabel({ address, items }) { /* ... */ return { ok: true, labelId: 'lbl_456' }; }
}

class OrderRepository {
  async create(order) { /* ... */ return { id: 'ord_001', ...order }; }
}

class AuditLogger { info(msg, meta){/*...*/} error(msg, meta){/*...*/} }


Facade 把錯誤回滾與順序都包起來:

// checkout-facade.js
export class CheckoutFacade {
  constructor({ inventory, payment, shipping, repo, logger }) {
    this.inv = inventory; this.pay = payment;
    this.ship = shipping; this.repo = repo; this.log = logger;
  }

  /**
   * @param {Object} payload
   * @param {Array} payload.items
   * @param {number} payload.amount
   * @param {string} payload.paymentToken
   * @param {Object} payload.address
   */
  async checkout({ items, amount, paymentToken, address }) {
    let reservationId, txnId;
    try {
      this.log.info('Checkout start', { items, amount });
      const r = await this.inv.reserve(items);
      if (!r.ok) throw new Error('庫存預留失敗');
      reservationId = r.reservationId;

      const pay = await this.pay.charge({ amount, token: paymentToken, orderId: 'temp' });
      if (!pay.ok) throw new Error('請款失敗');
      txnId = pay.txnId;

      const label = await this.ship.createLabel({ address, items });
      if (!label.ok) throw new Error('建立託運單失敗');

      const order = await this.repo.create({ items, amount, txnId, reservationId, labelId: label.labelId });
      this.log.info('Checkout success', { orderId: order.id });
      return { ok: true, order };
    } catch (err) {
      this.log.error('Checkout failed', { err: err.message, reservationId, txnId });
      // 回滾策略
      if (txnId) await this.pay.refund(txnId).catch(() => {});
      if (reservationId) await this.inv.release(reservationId).catch(() => {});
      return { ok: false, error: err.message };
    }
  }
}


使用端:

import { CheckoutFacade } from './checkout-facade.js';

const facade = new CheckoutFacade({
  inventory: new InventoryService(),
  payment: new PaymentGateway(),
  shipping: new ShippingService(),
  repo: new OrderRepository(),
  logger: new AuditLogger()
});

const result = await facade.checkout({
  items: [{ sku: 'A001', qty: 2 }],
  amount: 1990,
  paymentToken: 'tok_abc',
  address: { city: 'Taipei', zip: '100' }
});


好處:呼叫端只負責「把資料給對的人」,流程控制、錯誤回滾、紀錄都鎖進 Facade,之後要換金流/物流,只改 Facade 內線路即可。


實戰 3:Node.js 文件處理管線 Facade(後端)

把「病毒掃描 → 轉檔 → 上傳 S3 → 發事件」收斂成單一路徑:

// doc-pipeline-facade.js
export class DocumentPipelineFacade {
  constructor({ antivirus, converter, storage, bus, logger }) {
    this.antivirus = antivirus; this.converter = converter;
    this.storage = storage; this.bus = bus; this.log = logger;
  }

  async ingest({ buffer, filename, mime }) {
    this.log.info('Ingest start', { filename });
    await this.antivirus.scan(buffer); // throw on virus
    const pdf = await this.converter.toPDF(buffer, mime);
    const url = await this.storage.upload(pdf, `${filename}.pdf`, 'application/pdf');
    await this.bus.publish('doc.converted', { url, src: filename });
    this.log.info('Ingest done', { url });
    return { ok: true, url };
  }
}

 

測試時就能把 antivirus/converter/storage/bus/logger 全部換成 stub/mock,單測超單純。


如何讓 Facade 更好測(DI、介面、Mock)

依賴注入(DI):把所有依賴從建構子傳入(見上例),不要在 Facade 內 new。

對抽象程式:呼叫端只知道「有 .charge() 就能用」,不在意是哪家金流。

錯誤分支要可控:依賴可以「假造失敗」,確保回滾與例外流程有測到。

簡易 Jest 範例(概念):

test('checkout rollback on shipping failure', async () => {
  const inventory = { reserve: jest.fn().mockResolvedValue({ ok: true, reservationId: 'rv' }), release: jest.fn() };
  const payment = { charge: jest.fn().mockResolvedValue({ ok: true, txnId: 'tx' }), refund: jest.fn() };
  const shipping = { createLabel: jest.fn().mockResolvedValue({ ok: false }) };
  const repo = { create: jest.fn() };
  const logger = { info: jest.fn(), error: jest.fn() };

  const facade = new CheckoutFacade({ inventory, payment, shipping, repo, logger });
  const r = await facade.checkout({ items: [], amount: 100, paymentToken: 't', address: {} });

  expect(r.ok).toBe(false);
  expect(payment.refund).toHaveBeenCalledWith('tx');
  expect(inventory.release).toHaveBeenCalledWith('rv');
});


設計 API 的 7 條準則

語意清楚:checkout()、notify()、save() 一看就懂。

輸入/輸出穩定:物件參數 + 預設值,避免多個位置參數順序地雷。

錯誤要結構化:回傳 { ok: false, error: '...' } 或丟出自定義錯誤類別。

非同步一律 async/await:可讀、可測。

不可洩漏內部細節:回傳「需要的結果」,不要把整包第三方的原始回應丟出去。

可擴充:保留 options;不要把 enum 寫死在 10 個 if-else 裡。

可觀測性:內含 log/metrics 務必統一定義(但可透過 DI 注入)。


常見錯誤與雷點

1.    把 Facade 寫成「上帝物件」

症狀:AppFacade 裡面 3000 行、什麼都管。

後果:難維護、修改一處全炸。

修正:領域切分:AuthFacade、CheckoutFacade、MediaFacade…小而清。


2.     介面洩漏(Leaky Abstraction)

反例:checkout() 直接回傳金流原始 response,呼叫端還要解析 provider A/B 的不同格式。

修正:統一定義 PaymentResult,外部永遠拿到同一結構。


3.     強耦合第三方 SDK

反例:在 Facade 內部大量 import someSdk from 'vendor' 且四處使用靜態呼叫。

修正:依賴注入:把第三方包裝成「小介面」,注入 Facade,未來更換只改 Adapter。


4.    錯誤處理四散

反例:呼叫端/Facade/子服務各自 try-catch,彼此不一致。

修正:在 Facade 做流程級錯誤統一處理(如回滾、補償),呼叫端只關心成功/失敗。


5.    測試不了解流程

反例:只能做整合測試,單元測試很痛。

修正:把流程關鍵點做成可替換依賴,對 Facade 下 stub,測回滾與邊界。


6.    把 Facade 拿去做「跨領域混搭」

反例:一個 Facade 同時做上傳檔案、寄信、登入、結帳。

修正:單一職責,每個 Facade 對應一條主流程。


7.    誤用 Singleton

反例:為了好存取,把 Facade 做成全域單例,結果把狀態、連線、設定纏死。

修正:無狀態(stateless)優先;必要狀態由外部管理,或以工廠函式提供實例。


8.    參數爆炸、順序地雷

反例:checkout(items, amount, token, address, coupon, tax, options...)。

修正:物件參數 + 合理預設值 + 驗證。


9.    回傳過度龐大

反例:把 10 個內部呼叫的中間資料全數回傳。

修正:回傳呼叫端真正需要的結果;其餘進 log/trace。


10.    隱藏的非同步競態

反例:Facade 內有並行作業,但沒 await/Promise.all 管理。

修正:明確 await;用 Promise.allSettled 管理部分失敗。


11.    硬編時序耦合

反例:把「先付款再預留庫存」寫死,換供應商後要改順序就痛。

修正:把步驟獨立成方法,順序由 Facade 主流程決定,好替換、好 A/B。


12.    日誌缺失或過度

反例:沒有 log 或每步都 dump 大量個資/敏感資訊。

修正:結構化、安全的 log:關鍵節點 + 追蹤 ID,避免個資外洩。


效能與穩定性:Facade 能做的 5 件事

批次與快取:把多次底層呼叫做合併/快取(例如地址驗證、費率查詢)。

節流與重試:在 Facade 層加 retry/backoff、節流與併發上限。

逾時控制:每個子系統呼叫都應有 timeout,避免卡住整條鏈。

熔斷與降級:一個服務掛了,切到備援或回傳可預期的降級結果。

可觀測性:在 Facade 統一打 metrics(成功率、延遲、錯誤碼),更容易定位瓶頸。


問題集

Q1:小專案需要 Facade 嗎?

A:若只有單一呼叫、流程不變,直接用函式即可;但一旦出現第二個頁面/服務要重用,就值得加 Facade。

Q2:Facade 會不會跟「模組」重疊?

A:模組解決檔案/命名空間;Facade 解決對外介面。常見做法是以模組承載 Facade。

Q3:跟 Clean Architecture 的 Use Case 有關係嗎?

A:很像同一種精神:用貼近情境的入口隔離基礎設施差異。

Q4:前端專案放哪裡?

A:通常在 src/facades/ 或依領域分 src/checkout/checkout-facade.ts(JS 也可),方便定位。


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

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

 javaScript設計模式 : Decorator(裝飾者)

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)
較新的 較舊