寫前端或 Node,一定遇過這種窘境:功能長得快,底層卻老愛換。今天用 DOM,明天改 Canvas;剛接好 Stripe,客戶一句話就要換 PayPal。
若把「對外行為」和「底層實作」綁死在同一層,每次調整都牽一髮動全身。橋接模式的想法很單純:把抽象和實作拆開,各走各的路,用一條「橋」連起來。抽象層負責你想提供的行為,實作層專心對接真正的 API 或系統。
這樣要換渲染引擎、金流供應商、甚至把同步改成非同步,都不會炸到上層。本文用貼近實務的例子講透它:從遙控器/裝置的經典範本,到網頁渲染與支付流程,再談常見雷點、測試策略與擴充思路。希望本篇文章能夠幫助到需要的您。
目錄
{tocify} $title={目錄}
什麼是橋接模式?為何在 JavaScript 特別好用
橋接模式(Bridge Pattern)屬於結構型設計模式,核心思想是:
把「抽象(Abstraction)」與「實作(Implementor)」解耦,讓兩邊能各自獨立演進。
在動態的 JavaScript 世界裡,我們經常面對:
多平台(瀏覽器/Node.js/小程式容器)
多實作(不同 API、不同第三方 SDK)
需求迭代(功能升級,而底層供應商或技術棧也常更換)
直接把「功能」和「供應商」綁死在同一層,長期會造成類別爆炸與修改牽一髮動全身。橋接模式透過介面/協議把抽象面向與實作面向分拆,你可以:
在不動抽象層的情況下替換底層實作(例如 DOM ⇄ Canvas ⇄ WebGL)。
在不動實作層的情況下擴充功能(例如增加新工具列、新操作邏輯)。
與其他模式的差異(避免混淆)
Adapter(轉接器):重點是相容,把「既有介面」轉成「想要的介面」。
問題背景:我已經有 A 介面,但手上只有 B 物件,如何轉一下就能用?
橋接則是先天設計的分離,不是事後補救的轉接。
Strategy(策略):重點是同一抽象下的可替換演算法。
問題背景:我要切換不同策略(排序、壓縮、計價),但抽象與策略仍屬同一層次。
橋接則是抽象層 × 實作層兩條維度,可以各自增長。
Abstract Factory:重點是成族產品的建立。
橋接關心行為對接與長期演進,而非產品家族的建立細節。
簡短比喻:
Adapter 是「接頭轉換」。
Strategy 是「同功不同法」。
Bridge 是「兩岸蓋橋,各自長大」。
標準結構與名詞對照
Abstraction(抽象):
對外暴露的高階操作(例如「遙控器」的開關、音量)。
RefinedAbstraction:
抽象的變體或延伸(例如高階遙控器)。
Implementor(實作介面):
抽象所依賴的底層能力協議(例如「裝置」要能開關、調音量)。
ConcreteImplementor:
實際落地的實作(例如 TV、Radio、Speaker)。
兩層以組合(composition)連結,而非繼承。Abstraction 手上握著 Implementor 的實例。
基礎範例:遙控器(Abstraction)與裝置(Implementor)
1. 定義 Implementor 介面(以 JSDoc 表意)
/**
* @interface Device
* @typedef {Object} Device
* @property {() => boolean} isEnabled
* @property {() => void} enable
* @property {() => void} disable
* @property {() => number} getVolume
* @property {(v:number) => void} setVolume
* @property {() => string} getName
*/
2. ConcreteImplementor:TV、Radio
class TV {
#enabled = false;
#volume = 10;
isEnabled() { return this.#enabled; }
enable() { this.#enabled = true; }
disable() { this.#enabled = false; }
getVolume() { return this.#volume; }
setVolume(v) { this.#volume = Math.max(0, Math.min(100, v)); }
getName() { return 'TV'; }
}
class Radio {
#enabled = false;
#volume = 20;
isEnabled() { return this.#enabled; }
enable() { this.#enabled = true; }
disable() { this.#enabled = false; }
getVolume() { return this.#volume; }
setVolume(v) { this.#volume = Math.max(0, Math.min(100, v)); }
getName() { return 'Radio'; }
}
3. Abstraction:Remote 與 RefinedAbstraction:AdvancedRemote
class Remote {
/** @param {Device} device */
constructor(device) {
this.device = device;
}
togglePower() {
if (this.device.isEnabled()) this.device.disable();
else this.device.enable();
console.log(`${this.device.getName()} power: ${this.device.isEnabled()}`);
}
volumeDown() {
this.device.setVolume(this.device.getVolume() - 5);
console.log(`${this.device.getName()} volume: ${this.device.getVolume()}`);
}
volumeUp() {
this.device.setVolume(this.device.getVolume() + 5);
console.log(`${this.device.getName()} volume: ${this.device.getVolume()}`);
}
}
class AdvancedRemote extends Remote {
mute() {
this.device.setVolume(0);
console.log(`${this.device.getName()} muted`);
}
}
// 使用
const tvRemote = new AdvancedRemote(new TV());
tvRemote.togglePower();
tvRemote.volumeUp();
tvRemote.mute();
const radioRemote = new Remote(new Radio());
radioRemote.togglePower();
radioRemote.volumeDown();
重點:Remote 完全不關心底下是 TV 或 Radio;只要滿足 Device 協議,就能自由替換。
網頁實務:渲染引擎的橋接(DOM/Canvas/WebGL)
需求:你有一組「圖形操作」抽象(例如 drawCircle, drawRect),但在不同平台或效能需求下,渲染後端可能是 DOM、Canvas 或 WebGL。用橋接把「繪圖 API(Implementor)」與「圖形抽象(Abstraction)」分離,可在不中斷既有業務的情況下換引擎。
1. Implementor:Renderer 介面與三種實作
/**
* @typedef {Object} Renderer
* @property {(x:number, y:number, r:number) => void} drawCircle
* @property {(x:number, y:number, w:number, h:number) => void} drawRect
*/
class DomRenderer {
drawCircle(x, y, r) {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = `${x - r}px`;
el.style.top = `${y - r}px`;
el.style.width = `${r*2}px`;
el.style.height = `${r*2}px`;
el.style.borderRadius = '50%';
el.style.border = '1px solid #333';
document.body.appendChild(el);
}
drawRect(x, y, w, h) {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.border = '1px solid #333';
document.body.appendChild(el);
}
}
// CanvasRenderer / WebGLRenderer(此處略寫核心接口)
class CanvasRenderer {
constructor(ctx) { this.ctx = ctx; }
drawCircle(x, y, r) {
this.ctx.beginPath();
this.ctx.arc(x, y, r, 0, Math.PI * 2);
this.ctx.stroke();
}
drawRect(x, y, w, h) {
this.ctx.strokeRect(x, y, w, h);
}
}
2. Abstraction:Shape 與具體變體
class Shape {
/** @param {Renderer} renderer */
constructor(renderer) {
this.renderer = renderer;
}
}
class Circle extends Shape {
constructor(renderer, x, y, r) {
super(renderer);
Object.assign(this, { x, y, r });
}
draw() { this.renderer.drawCircle(this.x, this.y, this.r); }
}
class Rectangle extends Shape {
constructor(renderer, x, y, w, h) {
super(renderer);
Object.assign(this, { x, y, w, h });
}
draw() { this.renderer.drawRect(this.x, this.y, this.w, this.h); }
}
// 使用:可隨時替換後端
const domRenderer = new DomRenderer();
new Circle(domRenderer, 80, 80, 40).draw();
new Rectangle(domRenderer, 150, 50, 120, 60).draw();
好處:想改用 CanvasRenderer?只需把 renderer 換掉;Circle/Rectangle 不用動。
後端實務:支付流程 × 金流 Implementor(含非同步)
支付往往牽涉第三方 SDK/REST API,也經常有非同步流程、重試與錯誤處理。橋接模式能讓「支付流程抽象」與「金流供應商實作」分離。
1. Implementor:PaymentGateway(回傳 Promise)
/**
* @typedef {Object} PaymentGateway
* @property {(amount:number, currency:string, meta?:object) => Promise<{id:string,status:'ok'|'failed'}>} pay
* @property {(id:string) => Promise<'ok'|'failed'|'pending'>} getStatus
*/
class StripeGateway {
async pay(amount, currency, meta = {}) {
// 假裝呼叫 Stripe API
return { id: 'st_' + Date.now(), status: 'ok' };
}
async getStatus(id) {
// 假裝查詢
return 'ok';
}
}
class PaypalGateway {
async pay(amount, currency, meta = {}) {
// 假裝呼叫 PayPal API
return { id: 'pp_' + Date.now(), status: 'ok' };
}
async getStatus(id) {
return 'ok';
}
}
2. Abstraction:Payment 與延伸(一次性/訂閱)
class Payment {
/** @param {PaymentGateway} gateway */
constructor(gateway) { this.gateway = gateway; }
/**
* 統一對外接口:進行支付
* @param {{amount:number, currency:string, meta?:object}} dto
*/
async charge(dto) {
if (dto.amount <= 0) throw new Error('amount must be > 0');
return this.gateway.pay(dto.amount, dto.currency, dto.meta);
}
async status(id) {
return this.gateway.getStatus(id);
}
}
class SubscriptionPayment extends Payment {
async charge(dto) {
// 在抽象層加入「訂閱」邏輯(例如先驗證方案等)
const res = await super.charge(dto);
// 可在此記錄訂閱資訊、開立發票…
return res;
}
}
// 使用:可自由替換金流
const payment = new SubscriptionPayment(new StripeGateway());
payment.charge({ amount: 499, currency: 'USD', meta: { plan: 'pro' } })
.then(res => console.log('paid:', res))
.catch(console.error);
重點:抽象層(一次性/訂閱)與實作層(Stripe/PayPal)可以獨立升級與替換;非同步細節(Promise)被實作層消化。
何時應用橋接模式:判斷準則與重構路徑
適用時機:
功能抽象與底層實作會雙向擴張(各自增加新成員)。
需支援多個後端(不同渲染、不同金流、不同儲存)。
你想降低變動半徑:換供應商不影響上層 API;擴功能不碰底層。
從既有程式重構的步驟:
1. 蒐集變異點:整理抽象層(用戶看到的操作)與實作層(對外或底層 API)。
2. 定義協議:先寫出 Implementor 介面(用 JSDoc 或 TS 型別)。
3. 抽出 Abstraction:把高階流程與驗證留在抽象層。
4. 移動實作細節:把供應商 SDK、API 呼叫等放進 ConcreteImplementor。
5. 建立薄耦合:透過建構子注入 Implementor 實例;避免在抽象層 new 具體實作。
6. 補上契約測試:確保任一 Implementor 都符合協議。
常見錯誤與雷點
1. 把 Bridge 寫成繼承層級
雷點:Abstraction 去繼承 Implementor 或相反,導致強耦合。
解法:用組合,abstraction.hasOne(implementor)。
2. Implementor 介面定義含糊
雷點:不同實作返回不同格式。
解法:以 JSDoc/TypeScript 嚴格定義輸入輸出。
3. 抽象層洩漏實作細節
雷點:Remote 暴露 getHDMIPorts() 之類實作限定 API。
解法:抽象層只保留跨實作共通能力;少數差異以能力探測(capability)包裝。
4. 建構子內部自行 new 實作
雷點:無法替換;測試痛苦。
解法:依賴注入(constructor 注入)、或工廠注入。
5. 把 Adapter 誤當 Bridge
雷點:為了轉接舊 API 卻搞了一堆抽象層。
解法:舊介面相容問題 → 用 Adapter;雙維度演進 → 用 Bridge。
6. 策略(Strategy)與橋接混用不清
小訣竅:策略處理「算法可替換」,橋接處理「抽象 × 實作」兩軸成長。
7. 非同步處理散落抽象層
雷點:抽象層同時管流程與重試、退避。
解法:把非同步重試等放到 Implementor,抽象層只處理業務流程。
8. 測試只測一邊
雷點:只測 Abstraction,Implementor 一換就炸。
解法:契約測試:同一組測試套件跑過所有 Implementor。
9. 過度工程化
雷點:只有一種實作也硬上 Bridge,增加心智負擔。
解法:先簡後繁:變異點變多再抽橋接。
10. 版本相容性忽略
雷點:第三方 SDK 版本升級破壞 Implementor 約定。
解法:在 Implementor 層做版本門面(facade),維持抽象層不變。
11. 資源生命週期管理不當
雷點:Implementor 握有連線/Context 卻沒釋放。
解法:在 Abstraction 加上 dispose(),由 Implementor 實作釋放。
12. 錯誤分類不清
雷點:所有錯誤都往上丟,抽象層無法判斷可重試。
解法:Implementor 自訂錯誤型別(或 code),抽象層據以決策。
測試策略:契約測試、雙層 mock 與覆蓋
契約測試(Contract Test):
寫一組針對 Implementor 介面的測試,用資料驅動跑過 TV、Radio、Stripe、PayPal 等全部實作。
雙層 mock:
Abstraction 測試時 mock 掉 Implementor,驗證抽象流程。
Implementor 測試時以假伺服器/測試金鑰隔離外部依賴。
覆蓋面:
抽象層:流程、邏輯分支(含錯誤與重試)。
實作層:I/O 轉換、例外 mapping、邊界值。
總結
橋接模式是面向長期維護的結構型設計法。當你的功能抽象與底層實作都在不斷生長,Bridge 能把變動隔離在該去的地方,避免需求一變就地震全局。
在 JavaScript 中,特別面向多端渲染、多家金流、多資料儲存這些情境,Bridge 會讓你感覺像事先搭好高速公路匝道——上層與下層各走各的,卻能順暢銜接。
延伸方向:可與 Factory(選擇 Implementor)、Facade(隱藏複雜 SDK)協作;也能與 Strategy(抽象層內部可替換策略)同時使用,但務必清楚邊界。
附錄:簡潔對照範例(Functional 風格)
有時你不想用 class,也能以閉包呈現橋接:
// Implementor
const makeGateway = (api) => ({
pay: (amount, currency, meta={}) => api.post('/pay', { amount, currency, meta }),
getStatus: (id) => api.get(`/pay/${id}`)
});
// Abstraction
const makePayment = (gateway) => ({
charge: ({ amount, currency, meta }) => {
if (amount <= 0) return Promise.reject(new Error('amount must be > 0'));
return gateway.pay(amount, currency, meta);
},
status: (id) => gateway.getStatus(id)
});
// 使用
const api = { post: async () => ({ id: 'ok', status: 'ok' }), get: async () => 'ok' };
const gateway = makeGateway(api);
const payment = makePayment(gateway);
payment.charge({ amount: 100, currency: 'USD' }).then(console.log);
延伸閱讀推薦:
javaScript設計模式 : Factory Method (工廠方法)
javaScript設計模式 : Abstract Factory(抽象工廠)
javaScript設計模式 : Builder(建造者模式)
javaScript設計模式 : Prototype(原型)
javaScript設計模式 : Singleton (單例模式)
javaScript設計模式 : Adapter(轉接器模式)
javaScript設計模式 : Composite(組合模式)
javaScript設計模式 : Decorator(裝飾者)
javaScript設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
