javaScript設計模式 : Template Method (模板方法)

 


如果你常在專案裡複寫一段流程:先驗證、再轉換、最後儲存,然後只因格式或來源不同就開一個新檔案,那其實已經踩在 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設計模式 : 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設計模式 : Visitor(訪問者模式)


張貼留言 (0)
較新的 較舊