測試回報了兩個問題,乍看毫無關係。
第一個:上傳一個檔案之後,檔案列表有更新,但頁首的「已用空間」沒變。 要重新整理頁面才會對。
第二個:從儀表板切到設定頁,再切回來,整個列表又轉了一次圈圈。 明明三秒前才看過,資料根本沒變。
我打開 Network 面板,又發現了第三件事:頁首跟儀表板都在顯示空間用量,/api/quota 一進頁面就被打了兩次,一模一樣的請求。
三個問題,我這週寫的 hook 一個都沒處理到。
昨天剛學會 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(),還是會打兩次——要加一個「正在載入就不要再打」的判斷。寫到第五點,我停下來。這已經不是「共用狀態」了。
我在寫的,是一個快取。
這時候我才搞懂一個之前一直混在一起的東西。
昨天 Zustand 管的「選了哪些檔案」,跟今天的「空間用量」,看起來都是狀態,本質卻完全不同:
| 選了哪些檔案 | 空間用量 | |
|---|---|---|
| 誰擁有它 | 我(前端) | 後端 |
| 我手上的是什麼 | 本尊 | 一份副本 |
| 會不會過期 | 不會,我不改它就不會變 | 會,別人上傳檔案它就變了 |
| 取得方式 | 同步,直接讀 | 非同步,要發請求 |
| 需要處理 | 存取、訂閱 | 載入中、錯誤、重試、去重、快取、過期、重新整理 |
前者叫客戶端狀態(client state),後者叫伺服器狀態(server state)。
伺服器狀態的麻煩在於:我手上的永遠只是某一刻的快照,而本尊隨時可能在別處被改掉。 所以它需要的不是一個「存放的地方」,而是一整套「什麼時候該相信手上這份、什麼時候該重新問一次」的規則。
回頭看這一週:Day 9 的競態、Day 10 的平行請求、Day 11 的 debounce,其實全都是伺服器狀態的問題。我一直以為自己在手刻 RxJS 運算子,實際上是在一塊一塊地手刻一個快取。
這個問題困擾了我一陣子。我寫 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(以前叫 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,是這週唯一會留下來的。
兩者有重疊,但分工不太一樣。loader 適合「進入頁面時就要有的資料」,跟著導航一起載入;TanStack Query 適合「頁面上跟著使用者操作變動的資料」,像搜尋、分頁、篩選。實務上兩個常常一起用,這個之後談路由時再細講。
這一週我一直以為,自己在把 RxJS 的運算子一個一個搬到 React。
今天才看清楚:我真正在搬的,是一個替別人的資料做的快取。
伺服器狀態跟客戶端狀態是兩種不同的東西。客戶端狀態是我的,存起來、讀出來就好;伺服器狀態是別人的,我手上只有一份會過期的副本,所以需要去重、快取、過期、重抓、失效——一整套規則。
在 Angular,單例 service 加上 shareReplay,讓我可以用十幾行拼出一個簡易版,久到我以為這就是「寫 service 的正常方式」。React 兩塊積木都沒有,所以這整件事,變成了一個叫 TanStack Query 的函式庫。