iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 13 篇

Day 13|一直在手刻的,其實是一個快取

  • 分享至 

  • xImage
  •  

上傳完檔案,空間用量卻沒變

測試回報了兩個問題,乍看毫無關係。

第一個:上傳一個檔案之後,檔案列表有更新,但頁首的「已用空間」沒變。 要重新整理頁面才會對。

第二個:從儀表板切到設定頁,再切回來,整個列表又轉了一次圈圈。 明明三秒前才看過,資料根本沒變。

我打開 Network 面板,又發現了第三件事:頁首跟儀表板都在顯示空間用量,/api/quota 一進頁面就被打了兩次,一模一樣的請求。

三個問題,我這週寫的 hook 一個都沒處理到。


一、我先試著用 Zustand 補

昨天剛學會 Zustand,第一個念頭很自然:把 API 的資料也放進 store,大家共用一份,不就解決了?

export const useQuotaStore = create<QuotaState>()(set => ({
  quota: null,
  loading: false,
  error: null,
  fetchQuota: async () => {
    set({ loading: true });
    try {
      set({ quota: await api.getQuota(), loading: false });
    } catch (error) {
      set({ error, loading: false });
    }
  },
}));

頁首跟儀表板都讀這個 store,重複請求解決了。上傳成功後呼叫一次 fetchQuota(),空間用量也會更新。

然後我開始往下寫,清單越列越長:

  • 兩個元件同時掛載,都呼叫 fetchQuota(),還是會打兩次——要加一個「正在載入就不要再打」的判斷。
  • 檔案列表是帶參數的:第 1 頁、第 2 頁、關鍵字 "report",每一組都要各存一份。
  • 存多久?使用者離開半小時再回來,那份資料還能直接用嗎?
  • 用不到的資料什麼時候清掉?不清的話,store 會越來越大。
  • 使用者切回瀏覽器分頁時,要不要順便重抓一次?

寫到第五點,我停下來。這已經不是「共用狀態」了。

我在寫的,是一個快取。


二、有兩種完全不同的狀態

這時候我才搞懂一個之前一直混在一起的東西。

昨天 Zustand 管的「選了哪些檔案」,跟今天的「空間用量」,看起來都是狀態,本質卻完全不同:

選了哪些檔案 空間用量
誰擁有它 我(前端) 後端
我手上的是什麼 本尊 一份副本
會不會過期 不會,我不改它就不會變 會,別人上傳檔案它就變了
取得方式 同步,直接讀 非同步,要發請求
需要處理 存取、訂閱 載入中、錯誤、重試、去重、快取、過期、重新整理

前者叫客戶端狀態(client state),後者叫伺服器狀態(server state)。

伺服器狀態的麻煩在於:我手上的永遠只是某一刻的快照,而本尊隨時可能在別處被改掉。 所以它需要的不是一個「存放的地方」,而是一整套「什麼時候該相信手上這份、什麼時候該重新問一次」的規則。

回頭看這一週:Day 9 的競態、Day 10 的平行請求、Day 11 的 debounce,其實全都是伺服器狀態的問題。我一直以為自己在手刻 RxJS 運算子,實際上是在一塊一塊地手刻一個快取。


三、那 Angular 為什麼好像不需要?

這個問題困擾了我一陣子。我寫 Angular 三年,從來沒裝過什麼「資料請求套件」。

仔細想,是因為 Angular 給了我兩塊剛好夠用的積木。

@Injectable({ providedIn: 'root' })
export class QuotaService {
  private http = inject(HttpClient);
  private refresh$ = new BehaviorSubject<void>(undefined);

  readonly quota$ = this.refresh$.pipe(
    switchMap(() => this.http.get<Quota>('/api/quota')),
    shareReplay(1),
  );

  refresh() {
    this.refresh$.next();
  }
}

第一塊是單例 service。 providedIn: 'root' 讓它全 app 只有一份,資料自然有了一個共用的家(Day 2)。

第二塊是 RxJS 運算子。 shareReplay(1) 讓十個元件訂閱也只打一次請求,而且後來的人直接拿到上次的結果;switchMap 處理競態;需要重新整理,就從 refresh$ 推一下。要重試,再接一個 retry(3)。

頁首跟儀表板都訂閱 quota$,只打一次。上傳成功後呼叫 refresh(),所有訂閱者一起更新。

一個簡易版的伺服器狀態快取,就這樣用十幾行拼出來了。 我以前根本沒意識到自己在做這件事,只覺得這是「寫 service 的正常方式」。

React 兩塊積木都沒有。沒有單例 service 當家,沒有運算子當工具。所以同樣的東西,在 React 就沒辦法隨手拼,整塊變成了一個需要專門函式庫的問題。

公平起見要補一句:Angular 那個簡易版其實也不完整——沒有自動過期、沒有清掉用不到的資料、切回分頁也不會自動重抓。很多 Angular 團隊最後也是自己越補越多,TanStack Query 甚至也有 Angular 版本;Angular 近幾版推出的 resource 系列 API,方向也跟它越來越像。

所以與其說 Angular 不需要,不如說:Angular 的積木讓你比較晚才發現自己需要。


四、TanStack Query 把這件事當成主角

TanStack Query(以前叫 React Query)就是專門處理伺服器狀態的函式庫。把 Day 9 的搜尋改寫,大概長這樣:

const { data, isPending, error } = useQuery({
  queryKey: ['files', 'search', debouncedKeyword],
  queryFn: ({ signal }) => api.search(debouncedKeyword, { signal }),
  enabled: debouncedKeyword.trim() !== '',
});

就這樣。但這幾行底下藏了很多東西。

queryKey 是快取的鑰匙。 同一個 key 的資料只存一份。頁首跟儀表板都用 ['quota'],就只會打一次請求——這就是 shareReplay(1) 在做的事。

signal 是它幫你準備好的 AbortController。 Day 9 那一套取消邏輯,它已經幫你接好了,你只要把 signal 往下傳。

競態是用「分開存」解決的,不是用「取消」。 這是我覺得最漂亮的地方。"rep" 的結果存在 ['files', 'search', 'rep'] 底下,"repo" 的結果存在 ['files', 'search', 'repo'] 底下。畫面永遠只顯示目前 key 對應的那一份——舊的請求就算晚回來,也只是寫進舊的格子,根本蓋不到新的。

切回頁面不會再轉圈圈。 資料還在快取裡,先直接顯示,同時在背景重抓一次確認有沒有變。這種策略叫 stale-while-revalidate:先給你舊的,再悄悄換成新的。

預設就會重試,失敗時自動再試幾次;切回瀏覽器分頁時自動重抓;一段時間沒人用的資料會被清掉。這些是我第一節那張清單上,還沒來得及寫的東西。

上傳之後的更新,則是用「讓快取失效」來做:

const queryClient = useQueryClient();

const upload = useMutation({
  mutationFn: api.uploadFile,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['files'] });
    queryClient.invalidateQueries({ queryKey: ['quota'] });
  },
});

invalidateQueries 的意思是:「這些資料已經不可信了。」正在畫面上使用它們的元件,會自動重新抓一次。不用知道誰在用、在哪裡用。

這就是 Angular 那個 refresh$.next() 的完整版——只是它不用你一個 service 一個 service 去接。

把這週對照起來

我這週做的 RxJS 的做法 TanStack Query
AbortController(Day 9) switchMap signal + 依 key 分開存
手刻 useSearch(Day 9) service + ` async`
共用同一份資料 shareReplay(1) 同 queryKey 自動去重
上傳後手動重抓 refresh$.next() invalidateQueries
沒做 retry(3) 預設重試
useDebouncedValue(Day 11) debounceTime 沒有,還是要自己來

最後一列值得注意:TanStack Query 不管 debounce。它管的是「資料怎麼存、什麼時候重抓」,不管「使用者打字多快」。所以 Day 11 那個十行的 hook,是這週唯一會留下來的。

那 Day 10 的 loader 呢?

兩者有重疊,但分工不太一樣。loader 適合「進入頁面時就要有的資料」,跟著導航一起載入;TanStack Query 適合「頁面上跟著使用者操作變動的資料」,像搜尋、分頁、篩選。實務上兩個常常一起用,這個之後談路由時再細講。


今天的結論

這一週我一直以為,自己在把 RxJS 的運算子一個一個搬到 React。

今天才看清楚:我真正在搬的,是一個替別人的資料做的快取。

伺服器狀態跟客戶端狀態是兩種不同的東西。客戶端狀態是我的,存起來、讀出來就好;伺服器狀態是別人的,我手上只有一份會過期的副本,所以需要去重、快取、過期、重抓、失效——一整套規則。

在 Angular,單例 service 加上 shareReplay,讓我可以用十幾行拼出一個簡易版,久到我以為這就是「寫 service 的正常方式」。React 兩塊積木都沒有,所以這整件事,變成了一個叫 TanStack Query 的函式庫。


上一篇
Day 12| React 裡,找到了我的 BehaviorSubject
下一篇
Day 14|以為手刻的東西,一個套件五分鐘解決
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言