引入查詢快取之後,多數人的第一個問題是「什麼東西該放 store、什麼該放 query」。
這個問題有一個乾淨的判準,而且只有一個:這份資料的正本在哪裡。
正本在伺服器(列表、單筆資料、下拉選項)的東西,瀏覽器裡的都只是副本,該用查詢快取管——它處理載入中、錯誤、重複請求、失效重取這些副本才有的問題。正本就在瀏覽器(側邊欄收合、目前選的分頁、還沒送出的表單草稿)的東西,用一般的 store 管就好,它們沒有「過期」這個概念。
把伺服器資料抄進 store,就是把一份副本升格成第二個正本——之後所有「資料改了但畫面沒更新」的問題,根源都是這裡。
你可能會想:那全部自己用 store 管不就好了,少一個套件。可以,但你會把載入中、錯誤、重複請求、失效重取這幾件事重寫一遍,而且是散在各個 store 裡各寫一遍。這不是套件推銷——重點是這幾件事你逃不掉,差別只在於它們是集中在一個地方被處理,還是每個 store 各自處理到七成。
key 是快取的身分證
每一筆快取用 key 識別,而失效機制靠 key 比對。兩邊拼錯一個字母,失效就會安靜地沒有發生:
useQuery({ key: ['user', userId] }) // 存進去用 'user'
queryClient.invalidateQueries({ key: ['users', userId] }) // 清除時用 'users'
症狀是:送出修改、API 成功、畫面沒變,手動重整之後又對了。很容易被當成後端沒寫入而查錯方向——實際上資料早就寫進去了,只是畫面還在讀舊副本。
所以 key 的形狀要先定好,並且集中在同一個檔案裡產生,不要在各個呼叫點手寫:
單筆 ['users', id]
列表 ['users', 'list', params]
選項 ['users', 'options']
有階層的 key 還有一個好處:失效時可以只給前綴,一次清掉這個資源底下的所有快取。手寫字串做不到這件事。
參數還沒到位時不要查
需要參數的查詢,在參數還是 undefined 的那一瞬間也會執行:
// 會打出 /api/v1/users/undefined
useQuery({
key: () => ['users', toValue(userId)],
query: () => $fetch(`/api/v1/users/${toValue(userId)}`),
})
伺服器端通常回 404,看起來無害。真正的風險是那個字串有機會是合法的識別碼——例如用數字 id 而參數是 NaN、或路由參數還沒解析完就先拿到空字串。加上條件就沒有這個問題:
enabled: () => !!toValue(userId),
樂觀更新的回滾不是加分項
樂觀更新是先改畫面、再送請求。做了這件事就等於承諾請求失敗時要把畫面改回去——沒有回滾的樂觀更新比沒有樂觀更新更糟,因為使用者會看到一個成功的假象,然後在下次重整時發現他做的事沒發生。
所以三個回呼是一組,缺一不可:修改前記下舊值、失敗時還原、無論成敗都重新取一次伺服器的版本。最後這一步是防止「回滾也回滾錯」的保險——伺服器說什麼就是什麼。
staleTime 是一個取捨,不是效能設定
staleTime 決定一筆快取在多久內被視為新鮮、不需重新取。設定它時真正在回答的問題是:這份資料舊個幾秒鐘,會不會讓使用者做出錯的決定。
下拉選單的選項清單可以放很久,庫存數量或審核狀態就不行。這件事沒有通用的預設值,只能一筆一筆問。統一設一個大數字會讓請求數變好看,代價是某些畫面會拿舊資料騙人;統一設零則是把快取層的意義丟掉一半。
收束
這一層要做對的判斷其實只有四個:
| 判斷 | 做錯的症狀 |
|---|---|
| 正本在哪 | 兩份真相互相漂移 |
| key 一不一致 | 改完畫面不更新,重整就好 |
| 參數到位了沒 | 打出帶著 undefined 的網址 |
| 資料能舊多久 | 使用者看著過期的數字做決定 |
四個裡有三個的症狀都是「看起來只是畫面問題」,而它們全部都在資料層。這是快取這一層最需要留意的地方——它把資料的錯誤偽裝成顯示的錯誤。