為什麼 FP 是管理時間線的好方法?
本文為簡約的軟體開發思維:用 Functional Programming 重構程式 以 Javascript 為例這本書中的共享資源、協調時間線這兩個章節的筆記:
為什麼 FP 是管理時間線的好方法?
在軟體開發中,時間線(Timelines) 是不可避免的:
- 使用者點擊按鈕 → 發送 API → 更新畫面
- 後台定時同步 → 同時影響多個模組
- 多個請求同時刷新 Token
當系統還只有一條時間線時,我們只需要追蹤程式的直線流程;但一旦出現多條時間線:
- 它們可能同時修改共享資源
- 或需要依賴對方的完成順序
這些都讓程式變得難以推理,進而導致:
- 競賽條件(Race Conditions):行為依賴執行先後,導致結果不一致
- 非預期副作用:相同動作可能被執行多次
- 難以 Debug:問題偶爾才發生,難以重現
Functional Programming(FP)提供了一個關鍵思路:
不是消滅時間線,而是讓它們顯性化、可組合、可推理。
透過:
- Immutable Data:減少共享
- Declarative Control:明確描述依賴
- Concurrency Primitives:用簡單構件(Queue、Cut、JustOnce)建立可預測的時間線,達到安全資源共享的目的。
第16章 多條時間線共享資源
多條時間線共享資源:用 Queue 讓混亂變得可控
在單條時間線中,程式的行為是可預測的。但當多條時間線同時操作同一資源(例如:購物車、Token、動畫狀態)時,就會出現競賽條件(Race Condition)。
什麼是競賽條件?
當系統的輸出取決於多個非同步事件的執行順序時,就可能導致不一致的結果:
購物車正在計算總價的同時,有另一個事件新增商品 → 最終總價錯誤。
Refresh Token 還沒更新完畢,另一個 API 已經開始使用過期 Token → 認證失敗。
核心挑戰:共享狀態在多條時間線中難以控制
FP 的簡約原則
為了降低這種複雜度,書中提出五個方向:
- 減少時間線數量
- 減少每條時間線的步驟
- 減少共享資源
- 協調必須共享的時間線
- 改善時間線本身
在這篇,我們聚焦在用 Queue 讓多條時間線「單一化」。
用 Queue 控制共享資源
基本版 Queue
Queue 的概念是先進先出:
1function Queue(worker) {2 var queue_items = [];3 var working = false;45 function runNext() {6 if (working) return;7 if (queue_items.length === 0) return;89 working = true;10 var item = queue_items.shift();11 worker(item.data, function (val) {12 working = false;13 setTimeout(item.callback, 0, val);14 runNext();15 });16 }1718 return function (data, callback) {19 queue_items.push({ data, callback: callback || function () {} });20 setTimeout(runNext, 0);21 };22}
假設我們要更新購物車的總價:
1function calc_cart_worker(cart, done) {2 calc_cart_total(cart, function (total) {3 update_total_dom(total);4 done(total);5 });6}78var update_total_queue = Queue(calc_cart_worker);
不管事件觸發多少次,總價的更新會按順序執行,避免競賽條件。
DroppingQueue:丟棄不必要的更新
有些情況下,我們不需要處理所有事件,只需要保留最新的。例如:
- UI 動畫更新(只顯示最新狀態即可)
- 直播送禮動畫:先送禮的人要顯示,但快速連續送的「中間狀態」可以丟棄
1function DroppingQueue(max, worker) {2 var queue_items = [];3 var working = false;45 function runNext() {6 if (working) return;7 if (queue_items.length === 0) return;89 working = true;10 var item = queue_items.shift();11 worker(item.data, function (val) {12 working = false;13 setTimeout(item.callback, 0, val);14 runNext();15 });16 }1718 return function (data, callback) {19 queue_items.push({ data, callback: callback || function () {} });20 while (queue_items.length > max) queue_items.shift();21 setTimeout(runNext, 0);22 };23}2425var update_total_queue = DroppingQueue(1, calc_cart_worker);
實務案例:Refresh Token 問題
在許多應用中,API 請求可能同時觸發 Refresh Token:
- 請求 A 發現 Token 過期 → 發送 Refresh Token
- 請求 B 也發現 Token 過期 → 重複發送 Refresh Token
結果:
- Token 被刷新兩次
- 部分請求仍使用舊 Token,導致認證失敗
解法:用 Queue 確保 Refresh Token 流程一次只執行一個:
1const refreshQueue = Queue((_, done) => {2 refreshAccessToken().then((newToken) => {3 saveToken(newToken);4 done(newToken);5 });6});78// 所有需要刷新 Token 的請求都排進同一個 Queue9function requestWithAutoRefresh(apiCall) {10 return apiCall().catch((err) => {11 if (err.status === 401) {12 return new Promise((resolve) => {13 refreshQueue(null, (newToken) => {14 resolve(apiCall(newToken));15 });16 });17 }18 throw err;19 });20}
這樣,不論多少請求同時觸發刷新,系統都能按順序處理,避免競賽條件。
Queue vs Debounce vs Throttle
在前端事件控制中:
- Queue:確保所有任務依序執行(適合共享資源)
- Debounce:只保留最後一次(搜尋框輸入)
- Throttle:固定頻率(scroll、mousemove)
Queue 側重「正確性」,Debounce/Throttle 側重「效能」。
第17章 協調時間線
為什麼需要協調?
即使控制了共享資源,時間線之間仍可能有依賴:
- 多個 API 必須全部完成後更新 UI
- 多種支付方式的回呼可能同時觸發,但「結帳完成」訊息只能顯示一次
這時,我們需要協調(Coordination)。
繪製時間線圖:先看清問題
- 辨識 Actions(會造成副作用的程式)
- 將 Actions 畫在時間線上
- 尋找可以合併或簡化的時間線
Concurrency Primitives
併發,確保資源分享的安全性
Cut:等所有時間線完成再繼續
問題:必須等多條時間線的結果都回來,才能進行下一步(例如多 API 合併更新) 解法:設一個「同步點」
原本:
T1: |A-----------done|
T2: |B---done|
T3: |C------done|
無法保證何時進行下一步
Cut 之後:
T1: |A-----------X|
T2: |B---X|
T3: |C------X|
↓
所有完成後
↓
下一步
直觀:多條時間線「在終點對齊」→ 保證下一步只在「全部完成」後執行
1function Cut(num, callback) {2 var num_finished = 0;3 return function () {4 num_finished += 1;5 if (num_finished === num) callback();6 };7}89const done = Cut(3, () => console.log("全部完成"));10fetchA().then(done);11fetchB().then(done);12fetchC().then(done);
JustOnce:副作用只執行一次
問題:多條時間線可能同時觸發同一動作(例如結帳完成通知) 解法:只讓它真正執行一次
原本:
T1: |Action--->
T2: |Action--->
T3: |Action--->
結果:Action 執行三次
JustOnce 之後:
T1: |Action--->
T2: |X
T3: |X
結果:Action 只執行一次
直觀:把「重複的副作用」收斂成「唯一一次」
1function JustOnce(action) {2 var alreadyCalled = false;3 return function (a, b, c) {4 if (alreadyCalled) return;5 alreadyCalled = true;6 return action(a, b, c);7 };8}910const notifyCheckout = JustOnce(() => console.log("結帳完成"));1112payByCard().then(notifyCheckout);13payByCash().then(notifyCheckout);
為什麼 console.log("完成") 是 Action?
因為它與外部世界互動(輸出到 console),因為它產生了副作用 side effect,影響外部世界(終端機出現輸出),是不可重複的副作用。
改善多時間線運作的方法
- 減少時間線的數量
- 減少時間線Action的數量
- 減少共享資源
- 利用 concurrency primitive 來共享資源
- 利用 concurrency primitive 來協調時間線