banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

一種適用於 Zustand 和 React Query 的前端數據管理方式

在上面這篇文章中提到當 React Query 托管的資料過於複雜或者冗餘的話,後續在前端做資料的樂觀更新會變得非常複雜並且不可控。

Note

樂觀更新指,前端在進行資料操作時,通過 API 方式向伺服器提交資料,之後資料在 UI 上的更新由前端提供,不需要等待伺服器的響應。這樣的方式對用戶來說幾乎無感。

業務描述#

現在我們有這樣一個場景,資料結構類似這樣的。

image

接口是一個分頁的,結合 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 兩種方式對最後的資料結構是不同的,但是我們的資料可能是相同的。

https://github.com/RSSNext/follow/blob/9920c46fd677cb8c9e88e62a3ce75c4a3a73fa03/src/renderer/src/hooks/useUpdateEntry.tsx

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 中獲取資料。

image

樂觀更新#

現在我們不再需要使用非常繁瑣的 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

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。