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

 


每次新功能一來,物件的參數就像是滾雪球:尺寸要選、預設要調、還有一堆可有可無的小配料。硬把它們塞進 constructor,讀起來像咒語;用工廠硬生生拼一個,也常留下一堆 if/else 傷痕。

這篇想聊的是 Builder(建造者模式):把複雜的「組裝流程」拆成好懂的步驟,再用流暢 API 串起來。你會看到它怎麼讓設定更有秩序、驗證更到位、測試更單純,也會知道什麼時候別亂用(過度工程很常見)。我們會用 JavaScript 寫實例、拆常見錯誤、補上小撇步,讓你在面對「參數爆炸」時,不用再靠記憶或運氣過關。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


前言:當「選項太多」時,建構物件也需要一位「工頭」

在產品開發中,某些物件可能有許多可選參數、複雜的預設值、或必須依序完成多個步驟才能有效使用。若把這些需求通通塞進建構子(constructor),不是形成「望而生畏」的超長參數列,就是導致大量重載與 if/else。Builder(建造者模式)正是為了解這種「複雜物件的組裝」而生:將建構過程(步驟)與最終表示(產品)分離,讓你能以可讀、可測、可擴充的方式,把一個龐雜物件一步一步完成。這是經典 GoF 二十三種設計模式中的創建型模式之一。


什麼是 Builder?一句話定義與關鍵價值

定義(意譯):Builder 是一種創建型設計模式,將複雜物件的建構拆分為一連串明確的步驟,並將「建構過程」與「物件表示」解耦;同一套建構流程可以產生不同表示或配置。


帶來的好處:

可讀性高:透過「一步一步建」與鏈式呼叫(fluent API)讓程式像自然語言。

彈性大:同一建構步驟可組合出多種配置,易於擴充新配方。

易測試:每個步驟可被獨立測試;也容易做預設值、驗證。

隔離複雜度:把「怎麼做」封裝在 Builder 中,領域物件更乾淨。


與 Factory/Abstract Factory/Fluent Interface 的關係與差異

Factory Method / Abstract Factory:

重點在決定要建出哪個類別/家族的物件;Builder 重點在「怎麼建」,將建構流程拆步驟。你甚至可以先決定產品,再用 Builder 完成配置。

Fluent Interface(流暢介面):

是一種 API 設計風格,常用方法鏈(method chaining)提高可讀性;Builder 常用 fluent 風格來表達步驟,但兩者不是同一件事。


經典結構(心裡的 UML)

Product(產品):最後要拿來用的物件。

Builder(抽象建造者):定義建構步驟的介面。

ConcreteBuilder(具體建造者):實作各步驟與輸出 build()。

Director(指揮者,可選):

        封裝固定配方(標準組裝流程),一鍵生產常見配置。GoF 論述中 Director 是結構的一部分,但在實務上常可省略或內聯到服務層。


基礎版實作:漢堡 Builder(ES6)

這個例子示範可選配料、預設值、驗證與重置(reset),並回傳不可變產品。

// Product:不可變的 Burger
class Burger {
  constructor({ size, cheese = false, bacon = false, lettuce = false, sauces = [] }) {
    this.size = size;           // 'S' | 'M' | 'L'(必填)
    this.cheese = cheese;
    this.bacon = bacon;
    this.lettuce = lettuce;
    this.sauces = Object.freeze([...sauces]); // 防止外部修改
    Object.freeze(this);
  }
}

// ConcreteBuilder:鏈式呼叫 + 驗證 + 預設值 + reset
class BurgerBuilder {
  constructor() { this.reset(); }

  reset() {
    this._size = undefined;
    this._cheese = false;
    this._bacon = false;
    this._lettuce = false;
    this._sauces = [];
    return this;
  }

  size(v) {
    if (!['S', 'M', 'L'].includes(v)) throw new Error('size 僅能是 S/M/L');
    this._size = v;
    return this;
  }
  cheese(on = true) { this._cheese = !!on; return this; }
  bacon(on = true)  { this._bacon = !!on;  return this; }
  lettuce(on = true){ this._lettuce = !!on; return this; }
  addSauce(name)    { if (name) this._sauces.push(String(name)); return this; }

  #validate() {
    const missing = [];
    if (!this._size) missing.push('size');
    if (missing.length) throw new Error(`缺少必要欄位:${missing.join(', ')}`);
  }

  build() {
    this.#validate();
    const burger = new Burger({
      size: this._size,
      cheese: this._cheese,
      bacon: this._bacon,
      lettuce: this._lettuce,
      sauces: this._sauces
    });
    this.reset(); // 重要:避免狀態外溢
    return burger;
  }
}

// 使用
const bb = new BurgerBuilder();
const myBurger = bb.size('M').cheese().addSauce('BBQ').addSauce('Mayo').build();
// 再建一個新配方(不會受上一顆影響,因為 reset() 了)
const lightBurger = bb.size('S').lettuce().build();


加入 Director:固定配方一鍵出餐

若你的系統中有常見配方(如「經典款」、「豪華款」),用 Director 集中管理。

class BurgerDirector {
  constructor(builder) { this.builder = builder; }

  classic() {
    return this.builder.reset()
      .size('M').cheese(true).lettuce(true).addSauce('Ketchup')
      .build();
  }

  deluxe() {
    return this.builder.reset()
      .size('L').cheese(true).bacon(true).lettuce(true)
      .addSauce('BBQ').addSauce('Mustard')
      .build();
  }
}

// 使用
const director = new BurgerDirector(new BurgerBuilder());
const classic = director.classic();
const deluxe  = director.deluxe();


實務上,很多團隊不單獨維護 Director 類別,而是用服務方法或工廠函式包裝常見配方即可。


Fluent API 寫法要點(可讀 ≠ 隨便)

Fluent Interface 是 builder 常見的表達方式:每個設定方法回傳 this,形成自然語意的鏈式呼叫。注意命名要「動詞 + 名詞」而非僅設欄位,並確保每個方法都回傳 this。


進階技巧 A:Immutable Builder(以拷貝保護狀態)

若對「重用 Builder 產生分支配置」有需求,可採拷貝-寫入(copy-on-write)或用淺拷貝建立新 Builder,避免狀態交叉污染。

class SafeBurgerBuilder extends BurgerBuilder {
  clone() {
    const b = new SafeBurgerBuilder();
    // 複製目前暫存狀態
    b._size    = this._size;
    b._cheese  = this._cheese;
    b._bacon   = this._bacon;
    b._lettuce = this._lettuce;
    b._sauces  = [...this._sauces];
    return b;
  }
}

// 分支配置
const base = new SafeBurgerBuilder().size('L').cheese();
const spicy = base.clone().addSauce('Chili').build();
const sweet = base.clone().addSauce('Honey').build();


流暢介面在可維護性上也有爭論,請平衡可讀性與設計純度。


進階技巧 B:Step Builder(防呆:強迫順序)

在 JS 沒有型別階段約束,但可以透過閉包回傳不同階段的 API,讓使用者只能照順序呼叫。

function createUserStepBuilder() {
  const state = {};
  return {
    name(n) {
      state.name = String(n);
      return {
        email(e) {
          state.email = String(e);
          return {
            optional() {
              return {
                phone(p) { state.phone = String(p); return this; },
                marketing(ok = false) { state.marketing = !!ok; return this; },
                build() {
                  if (!state.name || !state.email) throw new Error('name/email 必填');
                  return Object.freeze({ ...state });
                }
              };
            }
          };
        }
      };
    }
  };
}

// 使用(強制 name → email → optional → build 順序)
const user = createUserStepBuilder()
  .name('Kelly')
  .email('kelly@example.com')
  .optional().marketing(true).build();


實戰案例 1:Chart/報表設定 Builder(輸出純 JSON)

class ChartConfigBuilder {
  constructor() { this.reset(); }
  reset() {
    this._type = 'bar';
    this._title = '';
    this._x = { field: null };
    this._y = { field: null, aggregate: null };
    this._series = [];
    this._theme = 'light';
    return this;
  }
  type(t)           { this._type = t; return this; }
  title(text)       { this._title = text; return this; }
  xField(field)     { this._x.field = field; return this; }
  yField(field)     { this._y.field = field; return this; }
  yAggregate(agg)   { this._y.aggregate = agg; return this; }
  addSeries(name,f) { this._series.push({ name, field: f }); return this; }
  theme(name)       { this._theme = name; return this; }
  build() {
    if (!this._x.field || !this._y.field) {
      throw new Error('xField/yField 必填');
    }
    const cfg = {
      type: this._type,
      title: this._title,
      x: { ...this._x },
      y: { ...this._y },
      series: this._series.map(s => ({ ...s })),
      theme: this._theme
    };
    this.reset();
    return cfg; // 純 JSON,交給任意圖表庫使用
  }
}


實戰案例 2:HTTP 請求 Builder(headers/query/body + async)

class HttpRequestBuilder {
  constructor(baseURL = '') {
    this._baseURL = baseURL;
    this.reset();
  }
  reset() {
    this._url = '';
    this._method = 'GET';
    this._headers = {};
    this._query = new URLSearchParams();
    this._body = undefined;
    return this;
  }
  url(path)        { this._url = path; return this; }
  method(m)        { this._method = m.toUpperCase(); return this; }
  header(k, v)     { this._headers[k] = String(v); return this; }
  query(k, v)      { this._query.append(k, v); return this; }
  json(obj)        {
    this._body = JSON.stringify(obj);
    this.header('Content-Type', 'application/json');
    return this;
  }
  buildRequest() {
    const url = new URL(this._url, this._baseURL);
    const qs = this._query.toString();
    if (qs) url.search = qs;
    const init = {
      method: this._method,
      headers: { ...this._headers }
    };
    if (!['GET', 'HEAD'].includes(this._method)) init.body = this._body;
    const req = new Request(url.toString(), init);
    this.reset();
    return req;
  }
  async send(fetchImpl = fetch) {
    const req = this.buildRequest();
    const res = await fetchImpl(req);
    return res;
  }
}

// 使用
const api = new HttpRequestBuilder('https://api.example.com');
const res = await api
  .url('/orders')
  .method('POST')
  .json({ sku: 'ABC', qty: 2 })
  .send();

 

測試建議與可維護性

單元測試:

        build() 的必填驗證、預設值、不可變性(Object.isFrozen)。

        reset 行為:同一 Builder 建第二個產品不應受前一次影響。

快照/序列化:對輸出 JSON 的 Builder(如報表設定),用快照測試很實用。

分層:把商業規則放在 Builder,資料來源留給上層;保持單一職責。


何時不要用 Builder?

        物件屬性少、結構單純,用物件實字或小型工廠函式即可。

        配置不常改動且流程非常固定,一個工廠方法可能就足夠。

        僅為了「鏈式呼叫很帥」而導入,過度工程會讓維護成本升高。


常見錯誤與雷點

1.    忘了回傳 this,斷鏈

class Bad {
  a() { /*...*/ } // 沒 return this
  b() { return this; }
}
// Bad().a().b() // TypeError


修正:每個設定方法都 return this;。


2.    沒有 reset,狀態外溢

連續 build() 之後,第二個產品意外帶到前一次的 sauces。

class BadBuilder {
  constructor(){ this.sauces = []; }
  addSauce(s){ this.sauces.push(s); return this; }
  build(){ return { sauces: this.sauces }; } // 沒拷貝/沒 reset
}
// 第二次 build 會共用同一個陣列參考


修正:build() 時做深/淺拷貝,並reset內部狀態。


3.    讓 Product 可變,導致跨模組被竄改

修正:回傳不可變物件或僅暴露唯讀介面(Object.freeze/拷貝)。


4.    驗證太晚或沒驗證

到 runtime 才噴錯,而且錯不明確。

修正:在 build() 前或內部的 #validate() 一次報清楚缺漏欄位。


5.    把 Director 誤當萬能工廠

Director 負責配方流程,不是塞一堆業務邏輯。若配方少,可以不需要 Director。


Builder 做太多:I/O、資料存取、網路重試全包

修正:保持 Builder 單一職責;網路、重試、快取交給外部協作物件。


6.    非同步步驟未 await

await builder.send() 忘了加,回傳的是 Promise 造成後續鏈式崩潰。

修正:清楚區分 build()(同步產物)與 send()(非同步行為),文件與型別註解標明。


7.    以為 Builder 一定要有繼承層級

在 JS 可用物件組合或閉包 API,不必為了「長得像 UML」而強塞繼承。


8.    Fluent 介面濫用

過長的鍊會難讀難除錯。修正:適度斷行、意義分段,或提供預設配方。


9.    共享可變參考(如陣列/物件)

修正:在 build() 做複製,或在設定時就拷貝輸入。


效能與延遲建構(Lazy Build)

延遲計算:在 build() 才計算昂貴欄位(例如複雜合併)。

重用模板:用 clone() 從基底配置分支,避免重複設定。

避免深層巢狀:深層物件可拆成子 Builder,最終在 build() 合併。


問題集

Q1:Director 一定要嗎?

不一定。若只有一兩個常見配方,直接用工廠函式或靜態方法即可;配方多、要跨處共用才考慮 Director。

Q2:Builder 跟 Fluent Interface 是同一件事嗎?

不是。Fluent 是API 風格;Builder 是設計模式。Builder 常用 fluent,但兩者各自獨立。

Q3:與 Factory/Abstract Factory 怎麼分?

Factory 著重建哪個類別,Builder 著重怎麼一步步建;兩者可互補。


對照表

主題 重點 一句話記憶
使用時機 參數多、步驟多、順序重要 不要把所有東西塞進 constructor
關鍵要素 Product、Builder、ConcreteBuilder、(可選)Director 流程與表示分離
好處 可讀、可測、可擴充 配方化,把複雜變簡單
風格 常搭配 Fluent Interface 方法鏈像說話
不適用 簡單物件、固定流程 用物件實字/小工廠更乾脆


延伸閱讀推薦:

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

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

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設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)

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