透過組裝 Effect 建構程式 (一)
在講怎麼組裝 Effect 建構程式之前,本文會先比較兩種建立 pipeline(指一系列的步驟,每個步驟都會接收前一個步驟的輸出,並回傳新的輸出) 的方式。並解釋為何在 Effect 的設計裡,Function pipeline 更為契合:
- Method chaining(物件導向,透過同一個實例連續呼叫)
- Function pipeline(函數式,透過純函數逐步合成)
1. Method chaining
例如,在價格計算的情境中,我們會先套用折扣,接著加上稅金,最後將結果取整數。
class PriceChain {
constructor(private currentAmount: number) {}
applyDiscount(percent: number) {
this.currentAmount = Math.round(this.currentAmount * (1 - percent))
return this
}
addTax(rate: number) {
this.currentAmount = Math.round(this.currentAmount * (1 + rate))
return this
}
roundToDollar() {
this.currentAmount = Math.round(this.currentAmount)
return this
}
total() {
return this.currentAmount
}
}
const total = new PriceChain(1000)
.applyDiscount(0.1)
.addTax(0.05)
.roundToDollar()
.total()
console.log(total) // 945可變 vs 不可變 method chaining
因為 method chaining 會把狀態綁在同一個實例上,呼叫順序會影響結果。
class MutablePriceChain {
constructor(private amount: number) {}
applyDiscount(percent: number) {
this.amount = Math.round(this.amount * (1 - percent))
return this
}
addTax(rate: number) {
this.amount = Math.round(this.amount * (1 + rate))
return this
}
total() {
return this.amount
}
}
const shared = new MutablePriceChain(1000)
function flowA() {
return shared.applyDiscount(0.1).total() // shared 變為 900(狀態被改動)
}
function flowB() {
return shared.addTax(0.05).total() // 對 900 加稅,而非預期的 1000
}
const a = flowA()
const b = flowB()
console.log(a) // 900
console.log(b) // 945(非預期,原本可能期望 1050)不過這個問題還是有解決方法,就是改成不可變的設計,讓每步驟都產生新的實例,避免共享狀態污染。
class ImmutablePriceChain {
constructor(private readonly amount: number) {}
applyDiscount(percent: number) {
return new ImmutablePriceChain(Math.round(this.amount * (1 - percent)))
}
addTax(rate: number) {
return new ImmutablePriceChain(Math.round(this.amount * (1 + rate)))
}
total() {
return this.amount
}
}
const base = new ImmutablePriceChain(1000)
function flowA() {
return base.applyDiscount(0.1).total() // 900(base 本身不變)
}
function flowB() {
return base.addTax(0.05).total() // 1050(如預期,未受 flowA 影響)
}
const a = flowA()
const b = flowB()
console.log(a) // 900
console.log(b) // 1050但使用 method chaining 仍把行為綁在 class 內。所以擴充與重用仍受限於 class 的邊界。
優點
- 直覺、語義化:就像在操作一個「價格計算器」。
- 狀態內建,不需要顯式傳遞。
這種寫法也更貼近業務語意:方法名稱通常就是業務動詞(如 applyDiscount、addTax、roundToDollar),同一個實例對應同一個業務實體(如訂單、購物車、查詢),讀起來像自然語言的「對這個東西做事」。同時,不變條件與驗證能封裝在 method 內,呼叫端就像使用領域語言(DSL);再加上 IDE 的自動完成可列出可用動作,探索成本更低。
取捨
- 封閉性高:每個步驟都必須是該 class 的 method,在別的情境不容易直接拿來用。
- 擴充困難:新增邏輯多半得修改 class;不同流程需要額外子類或旗標。
適合流程固定、語義明確的業務邏輯(如 ORM Query Builder:常見步驟固定,像選表/欄位 → 篩選 → 連接 → 排序 → 分頁,且一路操作同一個查詢實例)。
2. Function pipeline
函數式的作法是將每個步驟設計為「接收輸入、回傳輸出」的純函數。假設我們已有步驟函數(如 applyDiscount、addTax、roundToDollar),可以這樣手動串接:
function applyDiscount(percent: number): (amount: number) => number {
function apply(amount: number): number {
return Math.round(amount * (1 - percent))
}
return apply
}
function addTax(rate: number): (amount: number) => number {
function add(amount: number): number {
return Math.round(amount * (1 + rate))
}
return add
}
function roundToDollar(): (amount: number) => number {
function round(amount: number): number {
return Math.round(amount)
}
return round
}
const s1 = applyDiscount(0.1)(1000)
const s2 = addTax(0.05)(s1)
const totalManual = roundToDollar()(s2)
console.log(totalManual) // 945問題:
- 需要建立多個中間變數(
s1、s2),樣板碼偏多。 - 流程變長或調整步驟順序時,容易遺漏或傳錯變數。
- 可讀性受中間變數命名品質影響,難以一眼看出資料流是線性的「由左到右」。
使用 reduce 建立 pipe helper function
function pipe<T>(input: T, ...steps: Array<(value: any) => any>) {
return steps.reduce((acc, step) => step(acc), input)
}
const total = pipe(1000, applyDiscount(0.1), addTax(0.05), roundToDollar())
console.log(total) // 945優化了什麼
- 由左到右的線性可讀性,不必維護多個中間變數。
- 想換步驟、加步驟、調整順序時,只要改
pipe裡面的函數清單,不用改一堆中間變數,也比較不會傳錯值。 - 保持單參數步驟設計,型別推斷沿著資料流更連貫。
為什麼在大型前端專案中更實用
- 更易測試:每個步驟都是純函數,輸入/輸出可預期,不需建立實例或依賴生命週期。
- 更友善打包(tree-shaking):只引入用到的函數,不會把整個類別或原型一起打包。
- 更符合 Effect 的精神:把副作用與計算拆成小步驟,逐步組裝;每一步都可被替換、重試或監控。
Function pipeline 是一種「顯式傳遞」
- 每個步驟都只接收一個參數並回傳新值,函式外不藏任何 state。好處:不會共享或污染狀態,流程可預測;壞處:每一步都得把上一段的回傳值再傳給下一段,寫法較為冗長(尤其要攜帶多個值時)。
pipe只是把累積值acc逐步傳遞給下一個函式。
3. 選擇指南(Checklist)
- 偏好 method chaining
- 需要一個貼近業務語言、像在操作一個東西一樣直覺的 API。
- 流程固定、步驟不多;邊界清楚
- 偏好 function pipeline
- 步驟需要高度重用與自由組合(資料轉換、解析、資料檢查)。
- 想要好測試(給定輸入就有固定輸出)、能把結果暫存起來重用、可同時跑多條流程;也希望不要把 API 呼叫或時間這種外部影響藏在步驟裡。
- 希望與函數式工具鏈無縫整合。
總結
Effect 採用函數式組合(例如 pipe)塑造資料流,強調長期可維護性(可組合、可重用、易抽象)。對於顯式傳遞依賴的不便,提供 Context 與 Layer 進行依賴管理與組裝,並透過 Effect.gen 以更直觀的順序式風格組合 Effect。下一篇將示範如何用 Effect 建構資料流。
參考資料
- GPT-5
Last updated on