在上面這篇文章中提到當 React Query 托管的資料過於複雜或者冗餘的話,後續在前端做資料的樂觀更新會變得非常複雜並且不可控。
Note
樂觀更新指,前端在進行資料操作時,通過 API 方式向伺服器提交資料,之後資料在 UI 上的更新由前端提供,不需要等待伺服器的響應。這樣的方式對用戶來說幾乎無感。
業務描述#
現在我們有這樣一個場景,資料結構類似這樣的。
接口是一個分頁的,結合 React Query 的 InfiniteQuery 我們會這樣使用它。
export const entries = {
entries: ({
level,
id,
view,
read,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
}) =>
defineQuery(
["entries", level, id, view, read],
async ({ pageParam }) =>
// 這裡為接口
entryActions.fetchEntries({
level,
id,
view,
read,
pageParam: pageParam as string,
}),
),
}
useInfiniteQuery(entries.entries({ level, id, view, read }), {
enabled: level !== undefined && id !== undefined,
getNextPageParam: (lastPage) => {
if (!lastPage.data?.length) {
return null
}
return lastPage.data.at(-1)!.entries.publishedAt
},
initialPageParam: undefined,
})
我們可以通過 React Query 去使用資料了。
現在我們需要更新這個資料的 read 欄位,這個時候我們需要樂觀更新,因為 markRead 操作是一個很頻繁的操作,不能一次操作之後重新拉後端資料去覆蓋當前資料,這樣不僅 UI 上的反饋很慢,而且造成瀏覽浪費。
下面是我們通過 React Query 自帶的 Cache Store 去更新資料,但是這樣不僅效率很低而且因為沒有足夠的類型推導,很容易出錯,後期基本無法繼續維護。再者,在 React Query 中,使用 useQuery 和 useInfiniteQuery 兩種方式對最後的資料結構是不同的,但是我們的資料可能是相同的。
Zustand + React Query#
現在我們放棄使用 React Query 托管資料,轉而使用 Zustand 的方式。
和後端的 Table 一樣,我們也在前端建立資料的映射表。如上圖的資料集,我們可以利用 Zustand 建立一個 entryId -> entry 的表。
export const useEntryStore = create<EntryState & EntryActions>((set, get) => ({
entries: {}, // entryId -> entry 的表
fetchEntries: async ({
level,
id,
view,
read,
pageParam,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
pageParam?: string
}) => {
// 資料獲取
const res = await apiClient.entries.$post({
json: {
publishedAfter: pageParam as string,
read,
...getEntriesParams({
level,
id,
view,
}),
},
})
const data = await res.json()
if (data.data) {
data.data.forEach((entry: EntryModel) => {
// 把資料更新到 store 的 data map 裡
get().upsert(entry.feeds.id, entry)
})
}
return data
},
// 定義一個資料更新方法
upsert(feedId: string, entry: EntryModel) {
set((state) =>
produce(state, (draft) => {
draft.entries[entry.entries.id] = entry
return draft
}),
)
},
}))
另外我們可以寫一個 hook 去根據 id 獲取資料。
export const useEntry = (entryId: string) => useEntryStore((state) => state.entries[entryId])
現在我們的資料由 Zustand 托管了,我們需要修改原先直接從 React Query 消費的資料的程式碼。
例如原先子元件直接對 React Query 的資料進行透傳的 props,我們可以修改成 id 作為 props,然後再從 store 中獲取資料。
樂觀更新#
現在我們不再需要使用非常繁瑣的 React Query 提供的 setData
去更新資料了。我們只需要根據當前的資料 Id 去更新相應的資料。
export const useEntryStore = create<EntryState & EntryActions>((set, get) => ({
entries: {},
// ...
optimisticUpdate(entryId: string, changed: Partial<EntryModel>) {
set((state) =>
produce(state, (draft) => {
const entry = draft.entries[entryId]
if (!entry) return
Object.assign(entry, changed)
return draft
}),
)
},
markRead: (feedId: string, entryId: string, read: boolean) => {
get().optimisticUpdate(entryId, {
read,
})
},
}))
例如這裡我們可以寫一個 markRead
方法專門去做這部分的邏輯。
整體資料同步#
當 store 中的 fetch 方法執行時,會自動把遠端伺服器的資料覆寫到 store 的 data map 中。因此,我們修改 useQuery 中的 queryFn,讓其指向 store.fetchEntries
。
export const entries = {
entries: ({
level,
id,
view,
read,
}: {
level?: string
id?: number | string
view?: number
read?: boolean
}) =>
defineQuery(
["entries", level, id, view, read],
async ({ pageParam }) =>
// 這裡為接口
entryActions.fetchEntries({
level,
id,
view,
read,
pageParam: pageParam as string,
}),
),
}
useInfiniteQuery(entries.entries({ level, id, view, read }), {
enabled: level !== undefined && id !== undefined,
getNextPageParam: (lastPage) => {
if (!lastPage.data?.length) {
return null
}
return lastPage.data.at(-1)!.entries.publishedAt
},
initialPageParam: undefined,
})
整體資料流#
https://cdn.jsdelivr.net/gh/innei/shiro-remote-components@main/excalidraw/2-data-flow-query-and-zustand.json
後記#
這個設計在 Follow 中,具體參考下面 pr 的修改。
https://github.com/RSSNext/follow/pull/28
Follow 是一款正在開發中的 RSS 信息流瀏覽器,敬請期待吧。
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/data-management-approach-for-zustand-and-react-query