欢迎来到 “关于 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 部分中更详细地讨论这个)