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


読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。