每次新功能一來,物件的參數就像是滾雪球:尺寸要選、預設要調、還有一堆可有可無的小配料。硬把它們塞進 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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
