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


加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。