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

 


碰到舊系統+新工具的組合,最常見的卡點不是功能,而是「介面對不起來」。方法名字不一樣、參數順序顛倒、回傳型態不一致,甚至一個走 callback、一個走 Promise——結果就是每接一套 SDK,就得修改一堆呼叫點。

Adapter(轉接器)模式的核心,就是在「呼叫端與被整合端」中間加一層薄薄的翻譯:把你想要的 Target 介面定清楚,其他不相容的東西,全都在 Adapter 裡轉成一樣的語言。這招的好處不浮誇:既有程式不用大改,替換供應商不會天搖地動,測試也能鎖在合約上跑。

本文會用 JavaScript 帶你從最小可行範例開始,走過命名、資料結構、同步非同步的轉接情境,再聊到常見雷點、效能、小型到大型專案的導入策略。讀完,你就能把「混亂的接口」整理成一條乾淨的通道。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


什麼是 Adapter?為什麼需要它

Adapter(轉接器)模式屬於「結構型設計模式」。核心目標是:讓本來介面不相容的兩個類別/模組可以合作。當你想導入新套件、複用老程式碼、或在重構時保留既有呼叫點不變,Adapter 就是解法。

典型不相容的原因:

        方法名稱不同、參數順序不同、回傳型態不同(例如單值 vs 陣列)。

        同步 vs 非同步(callback、Promise、事件流差異)。

        資料格式不同(snake_case ↔ camelCase、扁平 vs 巢狀)。

        舊 API 已經到 EOL,但大量呼叫點暫時不能改。


核心角色與結構

Client:使用者(呼叫端)。

Target:Client 期望面對的統一介面(你定義的合約)。

Adaptee:現有但接口不合的被整合對象(舊系統或第三方)。

Adapter:把 Target 與 Adaptee「轉接」起來的薄層。


在 JS 中,我們多半用物件組合(Object Adapter)而非繼承(Class Adapter)。組合比較彈性、耦合更低、也更適合多數函式/模組式程式。


最小可行範例(Before/After)

情境:既有程式呼叫 pay(amount);新金流 SDK 只有 charge({ total })。

Before(舊呼叫點)

// client.js
function checkout(pay) {
  // Client 期望一個 Target:pay(amount) -> Promise<boolean>
  return pay(500).then(ok => {
    if (ok) console.log('付款成功');
    else console.log('付款失敗');
  });
}


Adaptee(新 SDK)

// newGateway.js
export const newGateway = {
  charge(payload) {
    // 回傳 Promise,resolve: { success: boolean }
    return Promise.resolve({ success: payload.total > 0 });
  }
};


Adapter(轉接器)

// payAdapter.js
import { newGateway } from './newGateway.js';

// Target: pay(amount:number) => Promise<boolean>
export function payAdapter(amount) {
  return newGateway.charge({ total: amount }).then(r => r.success);
}


After(無痛導入)

import { payAdapter } from './payAdapter.js';
checkout(payAdapter); // 原有 checkout 不用改


三種常見適配情境

1. 命名與參數位置改造

// Adaptee: 老 SDK
const legacy = {
  // 參數順序: currency 在前、金額在後
  payBy(currency, amount) { return amount > 0; }
};

// Target:pay(amount, currency) -> boolean
function payAdapter(amount, currency = 'USD') {
  return legacy.payBy(currency, amount);
}


2. 資料結構轉換(snake_case ↔ camelCase)

// Adaptee 回傳 snake_case
function fetchUserLegacy(id) {
  return Promise.resolve({ user_id: id, user_name: 'Alice' });
}

// Target 想要 camelCase
function fetchUserAdapter(id) {
  return fetchUserLegacy(id).then(({ user_id, user_name }) => ({
    userId: user_id,
    userName: user_name
  }));
}


3. 同步 ↔ 非同步(callback ↔ Promise)

把 callback 風格轉成 Promise:

// Adaptee (callback 風格)
function readFileCb(path, cb) {
  setTimeout(() => cb(null, 'file-content'), 10);
}

// Adapter
function readFileAsync(path) {
  return new Promise((resolve, reject) => {
    readFileCb(path, (err, data) => (err ? reject(err) : resolve(data)));
  });
}

// 使用
readFileAsync('./a.txt').then(console.log).catch(console.error);


反向:把 Promise 轉成 callback(為了相容舊呼叫點):

// Adaptee (Promise 風格)
function fetchJSON(url) {
  return fetch(url).then(r => r.json());
}

// Target (callback 風格):(url, cb)
function fetchJSONCb(url, cb) {
  fetchJSON(url).then(data => cb(null, data)).catch(err => cb(err));
}


封裝第三方 SDK:把供應商差異隱藏起來

目標:用一個統一的 analytics.track(event, props) 在程式各處埋點,至於底層是 GA、Mixpanel 或自研,外部不要知道。

// Adaptees
const ga = { send(event, params) { console.log('[GA]', event, params); } };
const mp = { track(name, payload) { console.log('[MP]', name, payload); } };

// Target
const analytics = {
  track(event, props) {
    // Adapter 內部可切換供應商或同時上報
    ga.send(event, props);
    mp.track(event, props);
  }
};

// 使用端
analytics.track('CheckoutCompleted', { value: 500, currency: 'TWD' });


這層 Adapter 的價值在於:呼叫點完全不感知供應商,未來替換 SDK 風險與成本都降到最低。


前端與後端的實戰片段

1.    前端:用 Adapter 讓 fetch 長得像 $.ajax

// Adaptee: fetch
function fetchJson(url, options = {}) {
  return fetch(url, options).then(r => {
    if (!r.ok) throw new Error(r.statusText);
    return r.json();
  });
}

// Target:$.ajax 風格 (options: { url, method, data, success, error })
function ajaxAdapter(options) {
  const { url, method = 'GET', data, success, error } = options;
  const fetchOpts = { method, headers: { 'Content-Type': 'application/json' } };
  if (data) fetchOpts.body = JSON.stringify(data);

  fetchJson(url, fetchOpts).then(
    (res) => success && success(res),
    (err) => error && error(err)
  );
}

// 使用端保留舊寫法
ajaxAdapter({
  url: '/api/user',
  method: 'POST',
  data: { name: 'Alice' },
  success: (res) => console.log('ok', res),
  error: (e) => console.error('fail', e)
});


2.    後端(Node.js):fs callback → Promise

import fs from 'node:fs';

function readFileP(path, encoding = 'utf8') {
  return new Promise((resolve, reject) => {
    fs.readFile(path, encoding, (err, data) => (err ? reject(err) : resolve(data)));
  });
}


與其他模式的差異

Adapter:接口對不上的橋接,讓「不相容」能合作。

Facade:提供更簡化的外觀介面,內部可能仍是同一套系統;通常沒有「不相容」的問題。

Decorator:在不改變介面的情況下加行為。

Proxy:在相同介面前加一層控制/快取/延遲載入。

Bridge:把抽象與實作分離,讓兩者可以獨立演進,關注點不同於單純「轉接」。


設計準則與檢查清單

原則:

    SRP:Adapter 只做轉接,不摻雜商業邏輯。

    DIP:Client 依賴 Target(抽象合約),不要直接綁 Adaptee。

    小而清晰:一個 Adapter 解一件事,避免巨石式「萬能轉接器」。

    可撤換:讓 Adaptee 可被替換(DI/工廠注入),便於測試與升級。


檢查清單:

1.    Target 是否被明確定義(方法名、參數、回傳值)?

2.    有沒有把同步/非同步差異轉接完整?

3.    錯誤是否被正確轉譯(error code ↔ exception)?

4.    資料格式轉換是否涵蓋邊界(空值、陣列空、型別不符)?

5.    Adapter 是否無狀態或狀態可被清楚管理?

6.    單元測試是否以 Target 合約為基準撰寫?


常見錯誤與雷點

1.    吞掉錯誤或改變錯誤型態

反例:

function payAdapter(amount) {
  return newGateway.charge({ total: amount }).then(r => r.success).catch(() => false);
}


問題:把任何錯誤都變成 false,讓上層以為是「正常但失敗」,除錯困難。

正解:

function payAdapter(amount) {
  return newGateway.charge({ total: amount }).then(r => r.success);
  // 把錯誤原樣拋出,或在此處「有意識地」轉譯為自訂錯誤型別
}


2.    狀態同步不一致(特別是事件型 Adaptee)

反例:

// 只轉接 start,卻忘了 stop / error / progress


建議:釐清事件全集(start/stop/error/progress),在 Adapter 中一一對應到 Target 事件,並處理解除監聽。


3.    把商業邏輯塞進 Adapter

Adapter 僅負責「介面與資料」轉接。業務規則(例如折扣、會員等級)應該放在 Use Case/Service,不要讓 Adapter 難以替換。


4.    過度繼承,污染原型鏈

JS 偏向組合。用繼承做 Class Adapter 可能引入不可預期的原型行為與多重來源耦合。


5.    非同步語意轉譯不完整

callback → Promise:要處理「回調可能被呼叫多次」或「永不呼叫」的情況(加入 timeout 或防重入)。

Promise → callback:記得 try/catch 與 then/catch 皆覆蓋。


6.    格式轉換漏邊界

// 反例:未處理 null/undefined
const userName = legacy.user_name.toUpperCase(); // 可能炸


正解:

const userName = (legacy.user_name ?? '').toString().toUpperCase();


7.    效能忽略

大量資料轉換(深拷貝、JSON 轉來轉去)可能成為熱點。

建議:以流式/疊代方式處理、必要時快取固定對映。


測試策略:合約與邊界

重點:先寫 Target 的合約測試(Contract Test),再把同一組測試套用到不同 Adapter/Adaptee 上,驗證一致性。

// contract.test.js(以 Jest 為例)
export function runPaymentContractCases(pay) {
  test('正金額應成功', async () => {
    await expect(pay(500)).resolves.toBe(true);
  });
  test('零或負金額應失敗', async () => {
    await expect(pay(0)).resolves.toBe(false);
  });
}


// payAdapter.test.js
import { runPaymentContractCases } from './contract.test.js';
import { payAdapter } from './payAdapter.js';

describe('Payment Adapter Contract', () => {
  runPaymentContractCases(payAdapter);
});


邊界清單:

        空值、極端值、超大輸入

        非法格式(字串當數字)

        Adaptee 超時或不回調

        事件不對稱(只觸發 start 不觸發 end)


何時用/何時不用:決策速查

該用:

        大量既有呼叫點不便修改,但底層供應商/SDK 必須更換。

        多來源資料輸入,必須呈現統一介面。

        要把 callback 風格過渡到 Promise/async/await。


先別用:

        只是想「包一層好看」:這較像 Facade。

        只想加行為、不改介面:用 Decorator。

        需要存取控制/快取/延遲載入:用 Proxy。

        長期要讓抽象與實作獨立演進:考慮 Bridge。


完整範例:統一 CSV/REST/GraphQL 為一個 DataSource

目標:上層只呼叫 getUsers(),不關心底層來源是 CSV 檔、REST API 或 GraphQL。

// Target(上層期望的統一介面)
export class UserDataSource {
  async getUsers() { throw new Error('not implemented'); }
}

// Adaptee 1:CSV 檔
import fs from 'node:fs/promises';
class CsvClient {
  async read(path) {
    const text = await fs.readFile(path, 'utf8');
    return text.split('\n').slice(1).map(line => {
      const [id, name] = line.split(',');
      return { id: Number(id), name };
    });
  }
}

// Adaptee 2:REST
class RestClient {
  async listUsers() {
    const res = await fetch('https://api.example.com/users');
    if (!res.ok) throw new Error('REST failed: ' + res.status);
    return res.json(); // 假設回傳 [{ id, name }]
  }
}

// Adaptee 3:GraphQL
class GraphQLClient {
  async query(q) {
    const res = await fetch('https://gql.example.com', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: q })
    });
    const json = await res.json();
    if (json.errors) throw new Error('GQL failed');
    return json.data;
  }
}

// Adapter:CSV → Target
export class CsvUserDataSource extends UserDataSource {
  constructor(csvClient, path) {
    super();
    this.csv = csvClient;
    this.path = path;
  }
  async getUsers() {
    const rows = await this.csv.read(this.path);
    // 確保輸出一致格式
    return rows.map(r => ({ id: r.id, name: r.name }));
  }
}

// Adapter:REST → Target
export class RestUserDataSource extends UserDataSource {
  constructor(restClient) { super(); this.rest = restClient; }
  async getUsers() {
    const list = await this.rest.listUsers();
    return list.map(u => ({ id: u.id, name: u.name }));
  }
}

// Adapter:GraphQL → Target
export class GqlUserDataSource extends UserDataSource {
  constructor(gqlClient) { super(); this.gql = gqlClient; }
  async getUsers() {
    const data = await this.gql.query(`{ users { id name } }`);
    return data.users.map(u => ({ id: Number(u.id), name: u.name }));
  }
}

// ---- 用法(上層)
async function renderUsers(ds /* UserDataSource */) {
  const users = await ds.getUsers();
  console.table(users);
}

// 切換資料來源只換 Adapter
renderUsers(new CsvUserDataSource(new CsvClient(), './users.csv'));
// renderUsers(new RestUserDataSource(new RestClient()));
// renderUsers(new GqlUserDataSource(new GraphQLClient()));


重點:

        三種 Adaptee 格式各異,但都被轉接成 [{ id, name }]。

        上層 renderUsers 完全不需知道底層差異。

        未來新增來源(例如 Firestore)只需再寫一個 Adapter 類別。


問題集

Q1:Adapter 會不會讓程式層數太多?

視專案而定。若只是小區塊、短期過渡,輕量函式型 Adapter 即可;若是長期維護、供應商易更換,投資一個明確 Target 與多個 Adapter 很值得。

Q2:與 TypeScript 的型別搭配?

即便用純 JS,也建議用 JSDoc 註解 Target 介面(或在 TS 中以 interface 明確化),有助於合約測試與 IDE 提示。

Q3:如何逐步導入?

先圈定「呼叫點最多」或「風險最高」的子系統,定義 Target,包出第一個 Adapter,跑通測試,再逐步替換其他呼叫點。


總結

Adapter 模式的價值,在於維持呼叫點穩定與降低替換成本。你先定義一個「大家都能講的語言」(Target),再用一層薄薄的 Adapter 去翻譯各家 Adaptee。

落地時,記住三件事:

1.    只做轉接:不要把商業邏輯塞進 Adapter。

2.    轉譯完整:名稱、參數、資料格式、錯誤與非同步語意都要顧到。

3.    可測可換:以 Target 為中心寫合約測試,讓替換供應商像換電池一樣簡單。


延伸閱讀推薦:

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

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

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

javaScript設計模式 : Prototype(原型)

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

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

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