iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0
Modern Web

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

[學習 Effect Day26] Effect 中的 Data Types(二)

  • 分享至 

  • xImage
  •  

昨天我們介紹了 Option、Either、Result、Exit、Cause 的用法,今天我們來介紹其他常用的 Data Types。我保證這篇是最後一篇 data types 的文章了。這部分我自己也覺得有點無聊。但是為了完整性,就介紹完唄~😌

Duration:不可變的時間長度(重試、超時、節流、排程)

  • 用途
    • 用不可變值表示時間長度;可比較、相加、倍數與單位轉換。
  • 適用情境
    • timeout、retry backoff、節流/防抖、工作排程。

常見操作

import { Duration } from "effect"

// 1) 建立時間長度(常用單位 + 無窮)
const ms100 = Duration.millis(100)
const oneSec = Duration.seconds(1)
const fiveMin = Duration.minutes(5)
const forever = Duration.infinity

// 2) 時間運算(加總 / 倍數)
const ninetySec = Duration.sum(Duration.seconds(30), Duration.minutes(1)) // 1m 30s
const backoff = Duration.times(oneSec, 4) // 4 秒

// 3) 比較
const isShorter = Duration.lessThan(Duration.millis(900), oneSec)

// 4) 轉字串(人類可讀)
const label = Duration.format(ninetySec) // "1m 30s"
  • 將 value 轉換成時間長度:
// 這裡列出常用的程式碼範例,更多時間單位可以去官方文件查看
Duration.decode(100) // same as Duration.millis(100)
Duration.decode("100 millis") // same as Duration.millis(100)
Duration.decode("2 seconds") // same as Duration.seconds(2)
Duration.decode("5 minutes") // same as Duration.minutes(5)
Duration.decode("7 hours") // same as Duration.hours(7)
Duration.decode("3 weeks") // same as Duration.weeks(3)
Duration.decode(Infinity) // same as Duration.infinity

時間單位一覽

單位 英文 建構函數 Decode 字串範例
毫秒 millis (milliseconds) Duration.millis(n) "100 millis" 或直接數字 100
seconds Duration.seconds(n) "2 seconds"
分鐘 minutes Duration.minutes(n) "5 minutes"
小時 hours Duration.hours(n) "7 hours"
days Duration.days(n) "3 days"
weeks Duration.weeks(n) "3 weeks"
無窮 infinity Duration.infinity Infinity

實務案例

案例 1:API 輪詢

每隔 750ms 輪詢一次,逾時 5 秒就停止。

import { Duration, Effect, Schedule } from "effect"

// 1) 固定間隔輪詢 + 總逾時
const interval = Duration.millis(750)
const totalTimeout = Duration.seconds(5)

// 模擬後端工作:第 5 次才完成
let pollCount = 0
function checkJobStatus() {
  return Effect.sync<"pending" | "done">(() => {
    pollCount++
    return pollCount >= 5 ? "done" : "pending"
  })
}

// 宣告式排程 + 全域逾時
const pollOnce = checkJobStatus().pipe(
  Effect.tap(
    (s) => Effect.sync(() => console.log(`🛰️ 輪詢 #${pollCount}: ${s}`))
  ),
  Effect.flatMap(
    (s) => (s === "done" ? Effect.succeed("done") : Effect.fail("pending"))
  )
)

const polling = Effect.retry(pollOnce, Schedule.spaced(interval))

const program = Effect.timeout(polling, totalTimeout)

void Effect.runPromise(
  Effect.match(program, {
    onSuccess: () => console.log("✅ 完成"),
    onFailure: () => console.log("⌛ 逾時")
  })
)

// 輸出:
// 🛰️ 輪詢 #1: pending
// 🛰️ 輪詢 #2: pending
// 🛰️ 輪詢 #3: pending
// 🛰️ 輪詢 #4: pending
// 🛰️ 輪詢 #5: done
// ✅ 完成

案例 2:滾動進度上傳

使用者滾動頁面時,紀錄「滾動進度百分比」給後端。但為了避免高頻打 API 造成壓力,限制最多每 300ms 上報一次(throttle)。這種情境通常採首次立即上報,之後需間隔 ≥ 300ms。

import { Duration, Effect } from "effect"

// 範例:滾動進度上傳 — 最多每 300ms 上傳一次
const minInterval = Duration.millis(300)
let lastEmitAt = 0

function reportScrollProgress(progressPercent: number) {
  const now = Date.now()
  const elapsedMs = now - lastEmitAt
  if (Duration.lessThan(Duration.millis(elapsedMs), minInterval)) return
  lastEmitAt = now
  console.log(`📊 ${new Date(now).toISOString()} | +${elapsedMs}ms | Scroll progress: ${progressPercent}%`)
}

// 模擬高頻事件(Effect 版):每 50ms 產生一次 scroll 進度
const simulation = Effect.gen(function*() {
  let progress = 0
  while (progress < 100) {
    progress = Math.min(100, progress + Math.floor(Math.random() * 7))
    reportScrollProgress(progress)
    yield* Effect.sleep(Duration.millis(50))
  }
  console.log("🛑 模擬結束")
})

Effect.runFork(simulation)
// 輸出:
// 📊 2025-10-09T17:23:06.337Z | +1760030586337ms | Scroll progress: 3%
// 📊 2025-10-09T17:23:06.645Z | +308ms | Scroll progress: 15%
// 📊 2025-10-09T17:23:06.956Z | +311ms | Scroll progress: 35%
// 📊 2025-10-09T17:23:07.263Z | +307ms | Scroll progress: 51%
// 📊 2025-10-09T17:23:07.573Z | +310ms | Scroll progress: 74%
// 📊 2025-10-09T17:23:07.880Z | +307ms | Scroll progress: 95%
// 🛑 模擬結束

補充:為什麼要記錄使用者「滾動進度百分比」?

  • 衡量內容黏著度:比單純的停留時間更精準,能看到使用者讀到內容的哪個深度(如 25%、50%、75%、100%),更能反映實際閱讀情況。
  • 找出閱讀斷點與版位優化:當大量使用者在某一深度離開,可針對該段落重新編排、補圖、調整節奏或插入重點提示。
  • 轉換率歸因更精準:搭配 CTA 點擊或轉換事件,能判斷「讀到 X% 才更容易轉換」,進而調整 CTA 位置與數量。
  • A/B 測試與內容策略:不同標題、導言或排版的實驗,以滾動深度作為成功指標之一,迭代內容品質。
  • 廣告與贊助曝光證明:可作為版位可見度與讀取深度的量化依據,提升商務合作透明度與信任。

案例 3:SSE 閒置逾時

在真實服務中,SSE(Server-Sent Events)常經過反向代理與雲端邊緣(如 Nginx、Application Load Balancer)。多數代理會在「連線一段時間沒有資料流動」時主動關閉(idle timeout)。若我們沒有定期送出心跳(heartbeat),SSE 連線就會被視為閒置而被切斷。

這個例子用 Duration 建模「閒置逾時」與「心跳維持」,並對比:

  • 無心跳:連線約在 3 秒閒置後被關閉。
  • 有心跳:定期送出 ping,讓代理認為連線仍有流動而保持打開,直到流程自然結束。

設計重點:

  • 每 250ms 模擬時間流逝;在第 0 與第 20 個 tick(一個時間步) 模擬收到資料(data event)。
  • 啟用心跳時,每 4 個 tick(約 1 秒)送一次 ping
  • 使用 Duration.lessThan 判斷 idleMs < idleTimeout,不需心算單位、語意更清晰。
import { Effect, Duration } from "effect"
const idleTimeout = Duration.seconds(3)

function simulate(label: string, enableHeartbeat: boolean) {
  return Effect.gen(function*() {
    console.log(`${label} OPEN`)
    const bootAt = Date.now()
    let lastActivityAt = bootAt

    for (let i = 0; i <= 24; i++) {
      const elapsedMs = Date.now() - bootAt

      if (i === 0 || i === 20) {
        lastActivityAt = Date.now()
        console.log(`${label} event: data`)
      }

      if (enableHeartbeat && i % 4 === 0) {
        lastActivityAt = Date.now()
        console.log(`${label} ping`)
      }

      const idleMs = Date.now() - lastActivityAt
      if (!Duration.lessThan(Duration.millis(idleMs), idleTimeout)) {
        console.log(`${label} CLOSED by proxy (idle ${idleMs}ms @ t≈${elapsedMs}ms)`)
        return
      }

      yield* Effect.sleep(Duration.millis(250))
    }

    console.log(`${label} END`)
  })
}

Effect.runFork(simulate("[no-heartbeat]", false))
// 輸出:
// [no-heartbeat] OPEN
// [no-heartbeat] event: data
// [no-heartbeat] CLOSED by proxy (idle 3028ms @ t≈3028ms)
Effect.runFork(simulate("[heartbeat]", true))
// 輸出:
// [heartbeat] OPEN
// [heartbeat] event: data
// [heartbeat] ping
// [heartbeat] ping
// [heartbeat] ping
// [heartbeat] ping
// [heartbeat] ping
// [heartbeat] event: data
// [heartbeat] ping
// [heartbeat] ping
// [heartbeat] END

程式碼解析

  • idleTimeout = Duration.seconds(3): 以不可變時間長度表達「閒置逾時」規則。
  • lastActivityAt: 代表最近一次「有流動」的時間戳(包含資料與心跳)。
  • Duration.lessThan(Duration.millis(idleMs), idleTimeout): 可讀性高的比較;若不小於(即 ≥ 逾時),就視為被代理關閉。
  • Effect.sleep(Duration.millis(250)): 以非阻塞方式推進時間,模擬長連線下的實際流逝。
  • Effect.runFork(Effect.sleep(Duration.infinity)): 讓程序持續運作,不因主流程結束而提前退出。

檢查是否生效

  • 無心跳應在 ~3s 看到「CLOSED by proxy」訊息;
  • 有心跳則會持續 ping 並走到 END,證明連線被維持。

實際案例探討:LLM 串流回答(含工具呼叫/檢索)經過 Cloudflare 與 ALB

  • 場景: 前端用 SSE 串流顯示 LLM 回答。流量路徑:Browser → Cloudflare(CDN/代理) → AWS ALB → 應用 → 第三方 LLM/向量庫/外部 API。
  • 為什麼會暫時沒資料:
    • LLM 進入「Function Calling/Tool Use」階段:模型先輸出少量 token,然後呼叫向量檢索或外部 API,這可能會等待 20–60 秒才有下一段回覆。
    • RAG 檢索或多工具編排:檔案很大、索引冷啟或 API 回應慢,出現長空窗。
    • 供應商節流/排隊:高峰期模型產能吃緊,第一段 token 延遲較長。
  • 為什麼需要心跳:
    • 這段「暫時無資料」容易超過 Cloudflare/ALB 的 idle timeout,被視為閒置而關閉,前端誤以為斷線。
  • 怎麼做:
    • 伺服器在工具呼叫/檢索等待期間,每 15–30 秒送一行極小的 SSE 心跳(例如 : ping),確保鏈路持續有位元組流動;一旦模型或工具回來,再恢復正常 token 串流。

小結

  • 說明
    • Duration 是非負時間長度的資料型別;常用於 timeout、重試 backoff、排程。
    • sum / times:合成更長的時間;lessThan:可讀的比較;format:顯示用途。
    • decode:解析 number(毫秒)與人類可讀字串(如 "1m 30s"),適合讀取設定/環境變數。
    • 需要「永不超時」的語意時使用 Duration.infinity

Chunk:不可變、有序、結構共享的序列

一種「類陣列的不可變序列」,針對「重複接合(反覆 concat/append/flatten)」最佳化,透過結構共享降低每次拼接的複製成本。

為什麼需要 Chunk(結構共享的核心)

  • 在 Functional Programming 的世界裡,「每次變更都要回傳新版本」。若用 Array 風格的 concat 重複接合。這種做法會不斷複製整份資料,空間成本極高。
  • Chunk 的做法是「結構共享」:新版本只複製「變動的尾端區塊」,其餘大部分結構直接沿用舊版的引用。
  • 好處:
    • 多次接合時,時間與記憶體成本主要隨「新增量」成長,而非每次都 O(n) 重拷貝。
    • 舊版本保持不變,天然支援時間旅行/回溯/並行讀取。

內部結構(概念示意)

不是單純一條連續陣列,而是由多個「分塊(block)」或「節點」組成,可視為分塊向量/淺層樹:

old:
┌───────┬───────┬───────┐
│ Blk#1 │ Blk#2 │ Blk#3 │
└───────┴───────┴───────┘

append [E,F] → new:
┌───────┬───────┬───────┬───────┐
│ Blk#1 │ Blk#2 │ Blk#3 │ Blk#4*│
└───────┴───────┴───────┴───────┘
^^^^^^^^^^^^^^^^^^^^^^ 共享  ^ 新建(只含新元素)

讀取時用 index 計算定位到對應的分塊與偏移;共享的是「節點引用」,不是共享某個索引值。

記憶體共享(old/new 區塊)

  • 未被改動的區塊會「共用同一個節點引用」(同一塊記憶體)。例如 old 的 Blk#1 與 new 的 Blk#1 指向相同節點,沒有重新拷貝。
  • 容器不可變:你無法透過 Chunk API 直接修改共享節點。若未來的更新落在 Blk#1 範圍,會建立「新節點」掛接到新版本,而不是就地改舊的。
  • 生命週期:只要仍有任何 Chunk 持有該節點引用,GC 就不會回收;當沒有持有者時才會釋放。

使用準則

  • 僅在需要「重複接合」時使用 Chunk(如流式累積、批次聚合、管線緩衝)。
  • 管線中途用 Chunk 高效累加,輸出前再轉 ReadonlyArrayChunk.toReadonlyArray)。
  • 元素建議也不可變;若放入可變物件,兩版本會共享該物件引用,外部突變會彼此看見。
  • 大型來源轉換可考慮 Chunk.unsafeFromArray 但務必確保來源不再被修改(繞過拷貝與不可變防護,需謹慎)。

何時使用

在迴圈或串流中需要「多次」把小陣列累積成大集合,例如批次蒐集、日誌累積、分頁聚合、資料管線的中繼緩衝和 LLM chunking 拼接。

常見操作

import { Chunk, Equal } from "effect"

const c1 = Chunk.make(1, 2, 3)
// 效能注意事項
// Chunk.fromIterable 會複製可疊代物件的元素建立新資料。
// 對大型資料或重複呼叫來說,這個拷貝成本可能影響效能。
const c2 = Chunk.fromIterable([4, 5])
const arr = Chunk.toReadonlyArray(c1) // readonly [1,2,3]

// 2) 接合 / 變換 / 篩選
const c3 = Chunk.appendAll(c1, c2) // [1,2,3,4,5]
const mapped = Chunk.map(c3, (n) => n * 2) // [2,4,6,8,10]
const filtered = Chunk.filter(mapped, (n) => n > 5) // [6,8,10]

// 3) 取捨 / 切片
const dropped = Chunk.drop(c3, 2) // [3,4,5]
const taken = Chunk.take(c3, 3) // [1,2,3]
const sliced = Chunk.take(Chunk.drop(c3, 1), 3) // [2,3,4]

// 3.5) 建立空集合 + 累積(適合重複接合場景)
const empty = Chunk.empty<number>() // []
const built = Chunk.appendAll(
  Chunk.append(Chunk.append(empty, 1), 2),
  Chunk.fromIterable([3, 4])
) // [1,2,3,4]

// 3.6) unsafeFromArray(避免複製,需小心)
// Chunk.unsafeFromArray 直接由陣列建立 Chunk,不會進行拷貝。
// 透過避免複製可提升效能,但需特別小心,
// 因為它繞過了不可變性(immutability)保證。
const direct = Chunk.unsafeFromArray([6, 7, 8]) // ⚠️ 請勿改動來源陣列

// 3.7) 比較 — 結構相等
// 比較兩個 Chunk 是否相等請使用 Equal.equals。
// 會以結構相等(逐一比對內容)的方式比較。
const isEqual = Equal.equals(c3, Chunk.make(1, 2, 3, 4, 5)) // true

// 4) 疊代(保持不可變)
let sum = 0
for (const n of c3) sum += n // 15
  • 說明
    • Chunk 提供不可變、結構共享的序列操作;常見 API 與陣列近似但不變更原值。
    • 常用於 Stream 結果聚集、批次處理與傳遞不可變列表。

實際案例:模擬文字串流,並使用 Chunk 進行縫合

import { Chunk, Effect, Ref, Console, Duration } from "effect"

// 將一段文字依指定大小切成多個片段
function splitTextBySize(text: string, chunkSize: number): ReadonlyArray<string> {
  if (chunkSize <= 0) return [text]
  const result: Array<string> = []
  for (let i = 0; i < text.length; i += chunkSize) {
    result.push(text.slice(i, i + chunkSize))
  }
  return result
}

// 直接以 Chunk 進行縫合(避免中間陣列配置)
function stitchChunk(parts: Chunk.Chunk<string>): string {
  return Chunk.reduce(parts, "", (acc, s) => acc + s)
}

// 以 Effect 模擬串流:逐一送出片段,片段間以 delayMs 間隔
function mockTextStreamEffect(
  text: string,
  chunkSize: number,
  delayMs: number,
  onChunk: (chunk: string, index: number) => Effect.Effect<void>
) {
  const pieces = splitTextBySize(text, Math.max(1, chunkSize))

  return Effect.forEach(
    pieces,
    (piece, idx) =>
      Effect.gen(function*() {
        yield* onChunk(piece, idx)
        if (idx < pieces.length - 1) {
          yield* Effect.sleep(Duration.millis(Math.max(0, delayMs)))
        }
      }),
    { concurrency: 1 }
  )
}

// 使用 Chunk 接收串流並即時縫合(純 Effect)
function consumeStreamWithChunkEffect() {
  return Effect.gen(function*() {
    // 更小的示例文字,方便閱讀與觀察每次拼接
    const sampleText = "你好,這是串流測試。讓我們逐塊接收並縫合。"

    // 以 Ref<Chunk<string>> 做為累積容器(適合重複 append 場景)
    const receivedRef = yield* Ref.make(Chunk.empty<string>())

    yield* mockTextStreamEffect(
      sampleText,
      6, // 每 6 個字元切一塊
      100, // 每 100ms 送出一次
      (chunk, idx) =>
        Effect.gen(function*() {
          // 每收到一個片段就接到 Chunk 後面
          yield* Ref.update(receivedRef, (acc) => Chunk.append(acc, chunk))

          // 只輸出片段與當前統計,避免每步驟字符串重建
          const snapshot = yield* Ref.get(receivedRef)
          yield* Console.log(`片段#${idx}:`, JSON.stringify(chunk))
          yield* Console.log("目前片段數:", Chunk.size(snapshot))
          // 每次縫合並顯示目前完整結果
          const current = stitchChunk(snapshot)
          yield* Console.log("目前拼接:", current)
        })
    )

    const final = yield* Ref.get(receivedRef)
    yield* Console.log("串流完成,片段數:", Chunk.size(final))
  })
}

// 執行
Effect.runFork(consumeStreamWithChunkEffect())

Equal / Hash:結構相等與雜湊

  • 用途
    • 用「看內容」判斷兩個值一不一樣(Equal.equals)。
    • 幫值算出固定的「指紋」(Hash.hash),讓集合能快又準地查找。
    • 有了這兩個能力,HashSet 能正確去重、HashMap 能用「內容一樣」當 Key。
  • 適用情境
    • 想把使用者、訂單這類「值物件」放進 HashMap/HashSet,用內容判斷是否相同。
    • 做快取、去重、彙整結果時,不想被「是不是同一個記憶體位址」影響。
    • 遇到希望「內容相同就覆蓋舊值」的對應表行為。

常見操作

import { Equal, Hash, HashMap, HashSet } from "effect"

// 1) 未實作 Equal → 比較是否為同一個物件(同一個記憶體位址)
console.log("1) 未實作 Equal:比較是否為同一個物件(同一記憶體位址)")
const a = { name: "Alice", age: 30 }
const b = { name: "Alice", age: 30 }
console.log("===:", a, "vs", b, "=>", a === b) // false(不是同一個物件 / 記憶體位址不同)
console.log("Equal.equals:", a, "vs", b, "=>", Equal.equals(a, b)) // false(依 === 判斷:不是同一個物件)

// 2) 使用 Equal + Hash 實作 Equal Interface,用以比較 class 物件是否結構相等
class Person implements Equal.Equal {
  constructor(
    readonly id: number,
    readonly name: string,
    readonly age: number
  ) {}

  [Equal.symbol](that: Equal.Equal): boolean {
    if (that instanceof Person) {
      return (
        Equal.equals(this.id, that.id) &&
        Equal.equals(this.name, that.name) &&
        Equal.equals(this.age, that.age)
      )
    }
    return false
  }

  [Hash.symbol](): number {
    // 以 id 產生雜湊值(快速不等判斷)
    return Hash.hash(this.id)
  }
}

console.log("2) 類別自訂值相等(Equal + Hash)")
const alice = new Person(1, "Alice", 30)
const aliceSame = new Person(1, "Alice", 30)
const bob = new Person(2, "Bob", 40)
console.log("Equal.equals:", alice, "vs", aliceSame, "=>", Equal.equals(alice, aliceSame)) // true
console.log("Equal.equals:", alice, "vs", bob, "=>", Equal.equals(alice, bob)) // false

// 3) HashSet:以值相等去重(需元素實作 Equal)
console.log("3) HashSet:值相等去重(元素需實作 Equal)")
let setWithEqual = HashSet.empty<Person>()
const p1 = new Person(1, "Alice", 30)
const p2 = new Person(1, "Alice", 30)
setWithEqual = HashSet.add(setWithEqual, p1)
setWithEqual = HashSet.add(setWithEqual, p2)
console.log("HashSet size (Equal):", HashSet.size(setWithEqual)) // 1

let setWithPlain = HashSet.empty<{ name: string; age: number }>()
const o1 = { name: "Alice", age: 30 }
const o2 = { name: "Alice", age: 30 }
setWithPlain = HashSet.add(setWithPlain, o1)
setWithPlain = HashSet.add(setWithPlain, o2)
console.log("HashSet size (plain):", HashSet.size(setWithPlain)) // 2

// 4) HashMap:Key 以值相等(需 Key 實作 Equal)
console.log("4) HashMap:Key 值相等(Key 需實作 Equal)")
let map = HashMap.empty<Person, number>()
const key1 = new Person(1, "Alice", 30)
const key2 = new Person(1, "Alice", 30)
map = HashMap.set(map, key1, 1)
map = HashMap.set(map, key2, 2)
console.log("HashMap size:", HashMap.size(map)) // 1(第二次覆蓋第一次)
console.log("HashMap get:", HashMap.get(map, key2))
/** 輸出:
1) 未實作 Equal:比較是否為同一個物件(同一記憶體位址)
===: { name: 'Alice', age: 30 } vs { name: 'Alice', age: 30 } => false
Equal.equals: { name: 'Alice', age: 30 } vs { name: 'Alice', age: 30 } => false

2) 類別自訂值相等(Equal + Hash)
Equal.equals: Person { id: 1, name: 'Alice', age: 30 } vs Person { id: 1, name: 'Alice', age: 30 } => true
Equal.equals: Person { id: 1, name: 'Alice', age: 30 } vs Person { id: 2, name: 'Bob', age: 40 } => false

3) HashSet:值相等去重(元素需實作 Equal)
HashSet size (Equal): 1
HashSet size (plain): 2

4) HashMap:Key 值相等(Key 需實作 Equal)
HashMap size: 1
HashMap get: { _id: 'Option', _tag: 'Some', value: 2 }
*/

程式碼逐段說明(對照上面 1–4 範例)

    1. 未實作 Equal Interface 的情況
    • 兩個內容相同的物件 a / b,用 ===Equal.equals(a, b) 都是 false,因為它們不是同一個實例(記憶體位址不同)。
    1. 實作 Equal Interface 的情況
    • Person 實作 [Equal.symbol](that):逐欄位比對 id/name/age,內容一樣才算相等;實作 [Hash.symbol]():用 id 產生雜湊值。
    • 因此 alicealiceSame 被判定為相等(true),alicebob 不相等(false)。
    1. HashSet:值相等去重
    • 放入兩個內容相同的 Person 時,因為元素有 Equal/Hash,集合能以「內容」去重,大小為 1
    • 若放的是一般物件(沒有 Equal),集合以參考相等判斷,兩個不同實例會同時存在,大小為 2
    1. HashMap:Key 用值相等
    • Person 當 Key,key1key2 內容相同,第二次 set 會覆蓋第一次的值,所以大小仍為 1,並可 get(key2) 取得 Some(2)
  • 說明

    • 原生集合以參考相等判斷;HashSet/HashMapEqual/Hash 的結構語義判斷,避免 duplicate。
    • 自訂型別可實作 Equal.symbolHash.symbol 以參與集合操作。

總結

本篇補齊 Effect 中幾個在實務上常見的 Data Type,這些 Data Type 只要用對,在語意、可讀性與效能上都能得到很好的提升。不過其實我們還差 Data 這個 Data Type 要講,但內容有點多,就留給讀者自己研究吧~這篇就先這樣唄。🙂

參考資料


上一篇
[學習 Effect Day25] Effect 中的 Data Types(一)
下一篇
[學習 Effect Day27] Effect 資源管理(一)
系列文
用 Effect 實現產品級軟體29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言