React Query的渲染优化

本文介绍在使用React Query时,如何进行优化,以减少重新渲染次数,提升渲染效率。

原文地址: React Query Render Optimizations

免责声明:渲染优化对任何应用程序来说都是高级概念。 React Query已经自带非常好的优化和开箱即用的默认设置,绝大多数情况下,我们不需要做进一步的优化。“不需要的重新渲染”是许多人重点关注的话题,这就是我决定涵盖它的原因。但我想再次指出,通常,对于大多数应用程序,渲染优化可能并不像你想象的那么重要。 重新渲染是一件好事。 它确保你的应用程序显示最新的数据。 我每天都会对“应该存在的缺失渲染”进行“不必要的重新渲染”。 有关此主题的更多信息,请阅读:

在这系列博文的第2部分:React Query的数据转换中,在介绍select选项时,我已经写了很多关于渲染优化的内容。然而,“为什么React Query在即使我的数据没有任何变化时,也让我的组件重渲染了两次”可能是我最需要回答的问题(除了可能:“我在哪里可以找到v2文档”😅)。 所以让我试着深入解释一下这个问题。

isFetching的状态过渡

在上一个例子中,当我说这个组件只会在todos的长度改变时重新渲染时,我并没有全都说清楚的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export const useTodosQuery = (select) =>
  useQuery(['todos'], fetchTodos, { select })

export const useTodosCount = () => useTodosQuery((data) => data.length)


function TodosCount() {
  const todosCount = useTodosCount()
  return <div>{todosCount.data}</div>
}

每次进行后台重新获取时,此组件将使用以下queryInfo重新渲染两次:

1
2
{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }

那是因为React Query每次查询都暴露了很多元信息,isFetching就是其中之一。 当请求正在进行中时,此标志将始终为真。如果你想显示后台加载进度条,这非常有用。 但如果你不这样做,这也有点不必要。

notifyOnChangeProps

对于这个例子,使用了React Query的 notifyOnChangeProps 选项。 该选项可以在每个观察者上进行设置以告诉React Query:仅通知此观察者所关注的属性的变化情况。 通过将此选项设置为 ['data'],我们就得到了我们所寻求的优化版本:

1
2
3
4
5
6
export const useTodosQuery = (select, notifyOnChangeProps) =>
  useQuery(['todos'], fetchTodos, { select, notifyOnChangeProps })

export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])

您可以在文档中的示例中看到这一实战技巧。

保持同步

虽然上面的代码运行良好,但它却很容易发生数据不同步的情况。如果我们也想对错误做出响应该怎么办?或者我们开始使用 isLoading 标志?我们必须使 notifyOnChangeProps 列表与我们在组件中实际使用的任何字段保持同步。 如果我们忘记这样做,我们只会观察到 data 属性,但是当我们收到一个需要我们显示的 error 时,我们的组件将不会重新渲染,因此数据和显示就变得不同步了。 如果我们在自定义钩子中对此进行硬编码,这会特别麻烦,因为钩子不知道组件实际需要使用哪些字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])

function TodosCount() {

  // ❌  we are using error, but we are not getting notified if error changes!
  const { error, data } = useTodosCount()
  return (
    <div>
      {error ? error : null}
      {data ? data : null}
    </div>)
}

正如我在开始的免责声明中所暗示的那样,我认为这比偶尔不必要的重新渲染要糟糕得多。 当然,我们可以将选项传递给自定义钩子,但这仍然需要手动且感觉非常重复。 有没有办法自动执行此操作? 是的,有的:

跟踪查询

我为这个功能感到非常自豪,因为这是我对库的第一个主要贡献。 如果你将 notifyOnChangeProps 设置为_‘tracked’_,React Query 将跟踪你在渲染期间使用的字段,并使用它来计算列表。 这将与手动指定列表完全相同的方式进行优化,只是你不必考虑它。你还可以为所有查询全局启用此功能:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      notifyOnChangeProps: 'tracked',
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>)
}

有了这个,你再也不用考虑重新渲染了。 当然,使用跟踪也有一些开销,因此请确保明智地使用它。 跟踪查询也有一些限制,这就是为什么这是一个可选的功能特性:

  • 如果你使用对象Rest解构,你将有效地观察所有字段。 正常的解构没问题,只是不要这样做:
1
2
3
4
5
// ❌ will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)
  • 跟踪查询仅在“渲染期间”有效。如果你仅在使用effects时访问字段,则不会跟踪它们。 这是种罕见的情况,因为我们一般都会将其放入依赖数组中:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const queryInfo = useQuery(...)
// ❌ will not corectly track data
React.useEffect(() => {
    console.log(queryInfo.data)
})

// ✅ fine because the dependency array is accessed during render
React.useEffect(() => {
    console.log(queryInfo.data)
}, [queryInfo.data])
  • 跟踪查询不会在每次渲染时重置,因此如果莫跟踪一个字段一次,你将在观察者的生命周期内跟踪它:
1
2
3
4
5
6
7
const queryInfo = useQuery(...)

if (someCondition()) {
    //we will track the data field if someCondition was true in any previous render cycle
    return <div>{queryInfo.data}</div>

}

共享数据结构

共享数据结构是一个和其它优化有所不同,但是同样重要的React Query开箱即用的渲染优化。此特性可确保我们在各个级别保留数据的参考标识。 例如,假设你有以下数据结构:

1
2
3
4
[
  { "id": 1, "name": "Learn React", "status": "active" },
  { "id": 2, "name": "Learn React Query", "status": "todo" }
]

现在假设我们将第一个todo转换为 done 状态,并进行后台重新获取。 我们将从后端获得一个全新的json:

1
2
3
4
5
[
-  { "id": 1, "name": "Learn React", "status": "active" },
+  { "id": 1, "name": "Learn React", "status": "done" },
   { "id": 2, "name": "Learn React Query", "status": "todo" }
]

现在React Query将尝试比较旧状态和新状态,并尽可能多地保留以前的状态。在我们的示例中,todos 数组将是新的,因为我们更新了一个todo。 id为1的对象也将是新的,但id为2的对象将与前一状态中的对象具有相同的引用 — React Query只会将其复制到新结果中,因为其中没有任何更改。

这在使用选择器进行部分订阅时非常方便:

1
2
3
4
// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing

const { data } = useTodo(2)

正如我之前所提到的,对于选择器,数据结构共享将进行两次:一次在从 queryFn 返回的结果上确定是否有任何更改,然后再一次在选择器函数的结果上。在某些情况下,尤其是在拥有非常大的数据集时,共享数据结构可能是一个瓶颈。它也仅适用于 json 数据。如果你不需要这个优化,你可以通过在任何查询上设置structureSharing: false来关闭它。

如果你想更深入了解它的原理,请查看replaceEqualDeep测试

译注

SPA的重渲染确实很影响程序的效率,但是不要过度的苛求。尤其是React已经使用了vdom了,如果我们能很好的使用useMemo和useCallback来进行优化,很多时候非必要的重渲染没那么可怕。关于重渲染,可以参考以下两篇文章: