Eric TechBlog
Software Development2025 IT 鐵人 30 天挑戰

Effect 中的 Data Types(二)

昨天我們介紹了 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
secondsDuration.seconds(n)"2 seconds"
分鐘minutesDuration.minutes(n)"5 minutes"
小時hoursDuration.hours(n)"7 hours"
daysDuration.days(n)"3 days"
weeksDuration.weeks(n)"3 weeks"
無窮infinityDuration.infinityInfinity

實務案例

案例 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 要講,但內容有點多,就留給讀者自己研究吧~這篇就先這樣唄。🙂

參考資料

Last updated on

On this page