碰到舊系統+新工具的組合,最常見的卡點不是功能,而是「介面對不起來」。方法名字不一樣、參數順序顛倒、回傳型態不一致,甚至一個走 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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
