[翻译] Part 2. React-Query 数据转换

1,888 阅读5分钟

原文 tkdodo.eu/blog/react-…

欢迎来到 “关于 react-query 我不得不说的事情” 的第 2 部分。随着我越来越多地参与代码库及其周围的社区,我观察到人们经常询问的更多模式。最初,我想将它们全部写在一篇大文章中,但后来决定将它们分解为更易于管理的部分。第一个是关于一个非常常见和重要的任务:数据转换。

数据转换

让我们面对现实吧——我们大多数人都没有使用 GraphQL 。如果你这样做了,那么你会非常高兴,因为你可以奢侈地以你想要的格式请求数据。

但是,如果你正在使用 REST,则会受到后端返回内容的限制。那么在使用 react-query 时,如何以及在哪里最好地转换数据?在软件开发中唯一值得一试的答案也适用于此:

这取始终决于每个开发人员,

以下是 3+1 方法,可以根据各自的优缺点来转换数据:

0. 在后端

这是我最喜欢的方法,如果你能负担得起的话。如果后端以我们想要的结构返回数据,我们就不需要做任何事情。虽然这在许多情况下听起来可能不切实际,例如在使用公共 REST API 时,也很有可能在企业应用程序中实现。如果你控制后端并且有一个端点可以为你的确切用例返回数据,则更偏向以你期望的方式交付数据。

🟢 前端没有工作量
🔴 但并不总是可能的

1.在queryFn中

queryFn 是你传递给 useQuery 的函数。它希望你返回一个 Promise ,结果数据会在查询缓存中结束。但这并不意味着你必须在后端提供的结构中返回数据,你可以在执行此操作之前对其进行转换:

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  const data: Todos = response.data

  return data.map((todo) => todo.name.toUpperCase())
}

export const useTodosQuery = () => useQuery(['todos'], fetchTodos)

在前端,你可以使用这些数据“就像它来自后端一样”。在你的代码中,你实际上不会使用非大写的待办事项名称。你也将无法访问原始结构。如果你查看 react-query-devtools ,你将看到转换后的结构。如果你查看网络跟踪,你将看到原始结构。这可能会令人困惑,所以请记住这一点。

此外,这里没有 react-query 可以为你做的优化。每次执行提取时,你的转换都会运行。如果转换是高昂的,请考虑其他选择之一。一些公司还有一个共享的 api 层来抽象数据获取,因此你可能无法访问该层来进行转换。

🟢 在协同定位方面非常“接近后端”
🟡 转换后的结构在缓存中结束,因此你无法访问原始结构
🔴 在每次获取时运行
🔴 如果你有一个不能自由修改的共享api层是不可行的

2.在 render 函数中

如第 1 部分所述,如果你创建自定义钩子,你可以轻松地在那里进行转换:

const fetchTodos = async (): Promise<Todos> => {
  const response = await axios.get('todos')
  return response.data
}

export const useTodosQuery = () => {
  const queryInfo = useQuery(['todos'], fetchTodos)

  return {
    ...queryInfo,
    data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
  }
}

就目前而言,这不仅会在每次 fetch 函数运行时运行,而且实际上会在每次渲染时运行(即使是那些不涉及数据提取的渲染)。这可能根本不是问题,但如果是,你可以使用 useMemo 进行优化。小心地定义尽可能窄的依赖项。除非确实发生了变化(在这种情况下你想要重新计算转换),否则 queryInfo 中的数据将是引用稳定的,但 queryInfo 本身不会。如果你添加 queryInfo 作为你的依赖项,转换将在每次渲染时再次运行:

export const useTodosQuery = () => {
  const queryInfo = useQuery(['todos'], fetchTodos)

  return {
    ...queryInfo,
    // 🚨 don't do this - the useMemo does nothing at all here!
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo]
    ),

    // ✅ correctly memoizes by queryInfo.data
    data: React.useMemo(
      () => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
      [queryInfo.data]
    ),
  }
}

这是一个不错的选择,特别是如果你的自定义钩子中有额外的逻辑与依赖你的数据转换。请注意,数据可能是未定义的,因此在使用它时使用可选链 (optional chaining)。

🟢 可通过 useMemo 优化
🟡 无法在 devtools 中检查确切的结构
🔴 有点复杂的语法
🔴 数据可能是未定义的

3.使用选择选项

v3 引入了内置选择器,也可用于转换数据:

export const useTodosQuery = () =>
  useQuery(['todos'], fetchTodos, {
    select: (data) => data.map((todo) => todo.name.toUpperCase()),
  })

选择器只会在数据存在时被调用,所以你不必关心这里的 undefined 。像上面这样的选择器也将在每次渲染时运行,因为功能标识发生了变化(它是一个内联函数)。如果你的转换很昂贵,你可以使用 useCallback 或通过将其提取到稳定的函数引用来记住它:

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())

export const useTodosQuery = () =>
  useQuery(['todos'], fetchTodos, {
    // ✅ uses a stable function reference
    select: transformTodoNames,
  })

export const useTodosQuery = () =>
  useQuery(['todos'], fetchTodos, {
    // ✅ memoizes with useCallback
    select: React.useCallback(
      (data: Todos) => data.map((todo) => todo.name.toUpperCase()),
      []
    ),
  })

此外,选择选项还可用于仅订阅部分数据。这就是使这种方法真正独一无二的原因。考虑以下示例:

export const useTodosQuery = (select) =>
  useQuery(['todos'], fetchTodos, { select })

// 二次封装Hook
export const useTodosCount = () => useTodosQuery((data) => data.length)
export const useTodo = (id) =>
  useTodosQuery((data) => data.find((todo) => todo.id === id))

在这里,我们通过将自定义选择器传递给我们的 useTodosQuery 创建了一个类似 useSelector 的 API。自定义钩子仍然像以前一样工作,因为如果不传递 select 将是未定义的,因此将返回整个状态。

但是如果你传递一个选择器,你现在只订阅了选择器函数的结果。这非常强大,因为这意味着即使我们更新了一个 todo 的名称,我们仅通过 useTodosCount 订阅计数的组件也不会重新渲染。计数没有改变,所以 react-query 可以选择不通知这个观察者更新🥳(请注意,这里有点简化,技术上不完全正确 - 我将在第 3 部分中更详细地讨论渲染优化)。

🟢 最佳优化
🟢 允许部分订阅
🟡 结构对于每个观察者来说都可能不同
🟡 结构共享执行两次(我也会在第 3 部分中更详细地讨论这个)