iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0
Modern Web

用 Effect 實現產品級軟體系列 第 9

[學習 Effect Day9] 透過組裝 Effect 建構程式 (一)

  • 分享至 

  • xImage
  •  

在講怎麼組裝 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 的邊界。

優點

  • 直覺、語義化:就像在操作一個「價格計算器」。
  • 狀態內建,不需要顯式傳遞。

這種寫法也更貼近業務語意:方法名稱通常就是業務動詞(如 applyDiscountaddTaxroundToDollar),同一個實例對應同一個業務實體(如訂單、購物車、查詢),讀起來像自然語言的「對這個東西做事」。同時,不變條件與驗證能封裝在 method 內,呼叫端就像使用領域語言(DSL);再加上 IDE 的自動完成可列出可用動作,探索成本更低。

取捨

  • 封閉性高:每個步驟都必須是該 class 的 method,在別的情境不容易直接拿來用。
  • 擴充困難:新增邏輯多半得修改 class;不同流程需要額外子類或旗標。

適合流程固定、語義明確的業務邏輯(如 ORM Query Builder:常見步驟固定,像選表/欄位 → 篩選 → 連接 → 排序 → 分頁,且一路操作同一個查詢實例)。


2. Function pipeline

函數式的作法是將每個步驟設計為「接收輸入、回傳輸出」的純函數。假設我們已有步驟函數(如 applyDiscountaddTaxroundToDollar),可以這樣手動串接:

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

問題:

  • 需要建立多個中間變數(s1s2),樣板碼偏多。
  • 流程變長或調整步驟順序時,容易遺漏或傳錯變數。
  • 可讀性受中間變數命名品質影響,難以一眼看出資料流是線性的「由左到右」。

使用 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)塑造資料流,強調長期可維護性(可組合、可重用、易抽象)。對於顯式傳遞依賴的不便,提供 ContextLayer 進行依賴管理與組裝,並透過 Effect.gen 以更直觀的順序式風格組合 Effect。下一篇將示範如何用 Effect 建構資料流。

參考資料

  • GPT-5

上一篇
[學習 Effect Day8] 執行 Effect
下一篇
[學習 Effect Day10] 透過組裝 Effect 建構程式 (二)
系列文
用 Effect 實現產品級軟體10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言