為什麼 FP 是管理時間線的好方法?

2025年7月23日8 分鐘閱讀

本文為簡約的軟體開發思維:用 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;
4
5 function runNext() {
6 if (working) return;
7 if (queue_items.length === 0) return;
8
9 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 }
17
18 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}
7
8var update_total_queue = Queue(calc_cart_worker);

不管事件觸發多少次,總價的更新會按順序執行,避免競賽條件。


DroppingQueue:丟棄不必要的更新

有些情況下,我們不需要處理所有事件,只需要保留最新的。例如:

  • UI 動畫更新(只顯示最新狀態即可)
  • 直播送禮動畫:先送禮的人要顯示,但快速連續送的「中間狀態」可以丟棄
1function DroppingQueue(max, worker) {
2 var queue_items = [];
3 var working = false;
4
5 function runNext() {
6 if (working) return;
7 if (queue_items.length === 0) return;
8
9 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 }
17
18 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}
24
25var update_total_queue = DroppingQueue(1, calc_cart_worker);

實務案例:Refresh Token 問題

在許多應用中,API 請求可能同時觸發 Refresh Token:

  1. 請求 A 發現 Token 過期 → 發送 Refresh Token
  2. 請求 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});
7
8// 所有需要刷新 Token 的請求都排進同一個 Queue
9function 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)


繪製時間線圖:先看清問題

  1. 辨識 Actions(會造成副作用的程式)
  2. 將 Actions 畫在時間線上
  3. 尋找可以合併或簡化的時間線

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}
8
9const 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}
9
10const notifyCheckout = JustOnce(() => console.log("結帳完成"));
11
12payByCard().then(notifyCheckout);
13payByCash().then(notifyCheckout);

為什麼 console.log("完成") 是 Action?

因為它與外部世界互動(輸出到 console),因為它產生了副作用 side effect,影響外部世界(終端機出現輸出),是不可重複的副作用。


改善多時間線運作的方法

  1. 減少時間線的數量
  2. 減少時間線Action的數量
  3. 減少共享資源
  4. 利用 concurrency primitive 來共享資源
  5. 利用 concurrency primitive 來協調時間線

標籤:

JavaScript