如果你常遇到「功能做得出來,但每次要改都像拆炸彈」的情況,很可能是入口太多、耦合太散。
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設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
