如果你常在專案裡複寫一段流程:先驗證、再轉換、最後儲存,然後只因格式或來源不同就開一個新檔案,那其實已經踩在 Template Method 的領地上。
這個模式強調「先定流程,再換細節」:父類掌握節奏與不變條件,子類只補上該做的功課。好處是可讀、可測、好擴充;壞處是抽象過度會變成新手地獄。
本文用現代 JavaScript 示範同步與 async/await 版本,串起三個日常案例(匯出、表單、API 重試),也把常見錯誤列給你:骨架被亂改、同步非同步混用、契約不一致等。目標很簡單——讓你的流程真的「長得一樣」,而不是看起來一樣。希望本篇文章可以幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要 Template Method?
當一段流程「大方向固定,但步驟細節會依情境或平台不同而變化」時,我們常見到兩種極端:
重複貼上同樣流程,只改細節 → 維護成本爆炸。
過度抽象成巨型工具類別 → 新人難以上手,修改風險高。
Template Method(模板方法 / 範本方法)給出折衷:
以父類別定義流程骨架(不可被子類改動),
把可變細節留作抽象步驟(子類必須覆寫)或選配的 Hook(可覆寫可不覆寫)。
模式定義與核心概念
定義(經典表述)
在父類別中定義演算法的骨架,將某些步驟延遲到子類別,使子類別可以在不改變演算法結構的情況下重定義某些步驟。
關鍵元素
Template Method:不可改動的「流程主方法」,負責按順序呼叫各步驟。
Primitive Operations(原語步驟):子類必需實作的抽象步驟。
Hook:可選擇覆寫的步驟,提供微調能力;父類給出預設空實作或安全預設值。
Invariants(不變條件):流程中必須成立的條件;父類需確保、驗證、或在必要時中止。
在 JavaScript 的注意點
JS 沒有「abstract」「final」「protected」等修飾詞,我們常見做法是:
用註解或丟出錯誤來表示「必須覆寫」。
用命名慣例(例如 _doStep())表示「請勿在外部直接呼叫」。
用 Object.defineProperty 或 Object.freeze 限制「流程主方法」被改寫(視需求與成本取捨)。
什麼情境該用?什麼情境不該用?
適合
流程固定、細節多變:例如「匯出報表 → 準備資料 → 轉檔 → 儲存/上傳」。
有必須遵守的不變條件:例如「送出表單前一定要通過驗證」。
希望以繼承為主要擴充點:不同子類改同一流程的少數步驟即可。
不適合
步驟組合可能性極多、未來變化不可預期:建議用 Strategy 或 Pipeline/中介器 組合更彈性。
不希望強耦合於單一父類結構:傾向組合優於繼承,用策略、事件或函式合成(functional composition)更好。
JavaScript 實作:同步版骨架
// Template(父類):定義流程骨架 + 不變條件
class ExportTemplate {
// 流程主方法(Template Method):**請勿覆寫**
export(filename) {
this._validateFilename(filename); // 不變條件之一
const data = this.fetchData(); // 必須覆寫
const normalized = this.normalize(data); // 可覆寫(Hook),預設直接回傳
const content = this.convert(normalized); // 必須覆寫
this.save(filename, content); // 可覆寫(預設本地存檔)
this.afterExport?.(filename); // Hook:可選
}
// --- 必須覆寫的抽象步驟 ---
fetchData() {
throw new Error('fetchData() must be overridden');
}
convert(/* normalized */) {
throw new Error('convert() must be overridden');
}
// --- Hook:可覆寫可不覆寫 ---
normalize(data) { return data; }
save(filename, content) {
// 預設:瀏覽器下載
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
afterExport(/* filename */) {}
// --- Invariant:不變條件與共用驗證 ---
_validateFilename(name) {
if (!name || typeof name !== 'string') throw new Error('Invalid filename');
}
}
// 子類:只負責差異(資料來源、轉檔格式)
class CsvExporter extends ExportTemplate {
fetchData() {
return [
{ id: 1, name: 'Alice', score: 95 },
{ id: 2, name: 'Bob', score: 88 }
];
}
normalize(rows) {
// 排序 + 去除多餘欄位等
return rows.sort((a,b) => b.score - a.score);
}
convert(rows) {
const header = ['id','name','score'];
const body = rows.map(r => [r.id, r.name, r.score]);
return [header, ...body].map(arr => arr.join(',')).join('\n');
}
}
class JsonExporter extends ExportTemplate {
fetchData() { return { meta: { ver: 1 }, items: [1,2,3] }; }
convert(obj) { return JSON.stringify(obj, null, 2); }
}
// 使用
new CsvExporter().export('report.csv');
new JsonExporter().export('data.json');
重點:
export() 是固定流程,裡面按順序呼叫各步驟。
子類只覆寫必要步驟(fetchData、convert),其餘可透過 Hook 客製。
不變條件(檔名格式)由父類統一把關。
非同步版骨架(async/await)
真實專案多半牽涉 API、IO 或大型運算,步驟常是非同步。主流程就應是 async,並且在每一步 await 適當的 Promise。
class AsyncRequestTemplate {
// **不可覆寫**的流程骨架
async execute(input) {
const prepared = await this.prepare(input); // 必要步驟
await this.beforeSend?.(prepared); // Hook
const response = await this.send(prepared); // 必要步驟
const validated = await this.validate(response); // 必要步驟
const result = await this.transform(validated); // Hook/必要皆可,視設計
await this.afterReceive?.(result); // Hook
return result;
}
// 必須覆寫
async prepare(input) { throw new Error('prepare() must be overridden'); }
async send(prepared) { throw new Error('send() must be overridden'); }
async validate(resp) { throw new Error('validate() must be overridden'); }
// Hook(有預設)
async transform(data) { return data; }
async beforeSend(/* prepared */) {}
async afterReceive(/* result */) {}
}
// 子類:帶重試與退避
class ResilientGetUser extends AsyncRequestTemplate {
async prepare(userId) {
if (!Number.isInteger(userId)) throw new Error('userId must be int');
return { url: `/api/users/${userId}`, retries: 3 };
}
async send({ url, retries }) {
let attempt = 0, lastErr;
while (attempt <= retries) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
} catch (e) {
lastErr = e;
await new Promise(res => setTimeout(res, 2 ** attempt * 200)); // 指數退避
attempt++;
}
}
throw lastErr;
}
async validate(json) {
if (!json || typeof json.id !== 'number') throw new Error('Invalid schema');
return json;
}
async transform(user) {
// 正規化欄位
return { id: user.id, name: user.name ?? 'N/A' };
}
}
// 使用
const user = await new ResilientGetUser().execute(42);
console.log(user);
要點:
Template Method 用 async execute(),子類在非同步步驟中只關注細節。
錯誤處理集中在必要步驟或骨架中,不破壞流程順序。
三個實戰案例
1. 檔案匯出(CSV/JSON/PDF)
固定流程:收集資料 → 正規化 → 轉檔 → 儲存/上傳 → 後置處理
彈性點:資料來源、轉檔格式、存放目標、完成後的通知或紀錄。
上文同步版 ExportTemplate 已示範。若要支援 PDF,可在子類 convert() 呼叫 PDF 工具(例如 pdf-lib)即可;若要上傳 S3,覆寫 save()。
2. 表單送出與驗證
固定流程:
1. 蒐集欄位值
2. 前端驗證(必填、格式、跨欄位規則)
3. 正規化(trim、型別轉換、白名單)
4. 送出請求(防重複點擊/節流)
5. 處理結果(成功提示或錯誤聚合)
class FormSubmitTemplate {
submit() {
const values = this.collect(); // 必須覆寫
const errors = this.validate(values); // 必須覆寫
if (errors.length) return this.onError(errors); // Hook
const normalized = this.normalize(values); // Hook
return this.send(normalized) // 必須覆寫
.then((res) => this.onSuccess(res)) // Hook
.catch((err) => this.onError([err.message]));// Hook
}
collect() { throw new Error('collect() must be overridden'); }
validate(/* values */) { throw new Error('validate() must be overridden'); }
send(/* normalized */) { throw new Error('send() must be overridden'); }
normalize(values) { return values; }
onSuccess(/* res */) {}
onError(/* errors */) { console.error('Form errors:', errors); }
}
class SignupFormSubmit extends FormSubmitTemplate {
collect() {
return {
email: document.querySelector('#email').value,
pwd: document.querySelector('#pwd').value,
agree: document.querySelector('#agree').checked,
};
}
validate(v) {
const errs = [];
if (!/^\S+@\S+$/.test(v.email)) errs.push('Email 格式錯誤');
if (v.pwd.length < 8) errs.push('密碼至少 8 碼');
if (!v.agree) errs.push('需勾選同意條款');
return errs;
}
normalize(v) { return { ...v, email: v.email.trim() }; }
send(v) { return fetch('/api/signup', { method:'POST', body:JSON.stringify(v) }); }
onSuccess() { alert('註冊成功'); }
}
3. 可靠 API 請求(重試/退避/校驗)
固定流程:組請求 → 發送 → 校驗 → 轉換 → 回傳
彈性點:重試策略、退避算法、驗證 schema、轉換規則。
與其他模式的取捨與搭配
Template Method vs Strategy
Template Method:繼承導向、骨架固定、少數步驟可變。
Strategy:組合導向、步驟可以換整顆策略(更彈性)。
實務上可混用:Template 固定流程,某一步交給 Strategy 物件處理。
Template Method + Factory
在骨架裡需要建立不同型別物件時,由 Factory 產生,避免子類散落 new 細節。
Template Method + Hook/事件
讓外部在關鍵節點插入行為(analytics、loading 標示),不破壞流程主體。
測試與重構建議
父類測試
驗證流程順序(可用假子類記錄呼叫序列)。
驗證不變條件:錯誤時中止、訊息清楚。
子類測試
僅測差異行為:覆寫的步驟是否滿足契約。
非同步步驟要測試超時/重試/錯誤分支。
重構信號
子類激增且只為微小差異 → 抽離差異成 Strategy 或 函式參數。
父類 Template Method 過長 → 拆更小的 Hook、加命名良好的輔助方法。
常見錯誤與雷點
1. 把主流程也讓子類覆寫
風險:骨架被改壞、流程順序無保證。
對策:標註「請勿覆寫」,必要時 Object.defineProperty(proto, 'export', { writable:false })。
2. 缺少不變條件檢查
例如檔名/必填欄位/Schema 未檢查就往下走。
對策:所有必須成立的條件統一由父類檢查,早失敗、錯誤易追。
3. 抽象步驟回傳型別不一致
例:有些子類 convert() 回傳字串,有些回傳 Blob。
對策:以註解/型別說明(若用 TS 更佳)強化契約,父類在邊界處再檢。
4. 同步/非同步混用導致順序錯亂
反例:
async transform(d) { setTimeout(() => doSomething(d), 0); return d; }
這會破壞預期的串接順序。
對策:非同步一律 await,不要把步驟丟到事件迴圈之外。
5. 錯把 Template Method 當 Strategy
需要隨時替換的步驟卻寫死在繼承樹中。
對策:此步驟改用策略物件注入,或把 Template Method 拆小。
6. 子類過度複寫 Hook
Hook 本意是微調,過度複寫表示骨架抽象不當。
對策:回頭檢視流程切分,避免 Hook 變成另一個「主流程」。
7. 例外處理散落
每個子類都各自 try/catch,行為不一致。
對策:錯誤處理集中在骨架或在特定步驟統一拋出與轉譯。
8. 測試只測子類,不測骨架
導致流程改壞無人發現。
對策:為父類寫「呼叫序列快照測試」,確保順序不被破壞。
9. 過度依賴繼承、違反「組合優於繼承」
子類層級越長越難維護。
對策:差異提煉成獨立模組/策略,Template Method 只保留真正的骨架。
10. 在瀏覽器與 Node 共用時忽略環境差異
例如 save() 在 Node 沒有 DOM。
對策:把 I/O 步驟抽成策略,或以環境變數/DI 注入不同實作。
11. 沒有清楚的命名與註解
新人不知道哪些必覆寫、哪些可覆寫。
對策:在父類方法上方加 JSDoc,標示 @abstract/@hook(語義性標註)。
12. 忽略效能熱點
轉檔、排序、序列化在大資料下會卡。
對策:將重運算步驟抽離、可並行則並行、必要時 Web Worker。
總結
Template Method 的價值,在於穩住流程、釋放細節。
它讓團隊在「不破壞骨架」的前提下,新增或調整子類即可改變行為。
與 Strategy/Factory/Hook 搭配,可以在「骨架固定」與「細節彈性」之間取得健康的平衡。
落地時務必重視:不變條件、非同步順序、契約一致性與測試覆蓋。
若你的需求是「同一套流程,不同產品線只改 1–2 個步驟」,那就很適合用 Template Method;若你的需求是「步驟經常換、組合很多變」,請改用 Strategy 或函式合成,再視情況用 Template Method 綁住最外層流程。這樣的分工,才耐久。
延伸閱讀推薦:
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( 觀察者 )
