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

 


寫前端或 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設計模式 : 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)
較新的 較舊