Pinia Colada 非同步狀態管理

快取層真正要決定的只有一件事:哪些資料的正本不在瀏覽器裡。定錯了會得到兩份會漂的真相; key 設計不一致則會讓失效機制靜默失靈——改完資料畫面沒更新,重整一次又對了。

系列・Nuxt 4 全棧實戰筆記・第 10/12 篇

引入查詢快取之後,多數人的第一個問題是「什麼東西該放 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 的網址
資料能舊多久使用者看著過期的數字做決定

四個裡有三個的症狀都是「看起來只是畫面問題」,而它們全部都在資料層。這是快取這一層最需要留意的地方——它把資料的錯誤偽裝成顯示的錯誤。