[翻译] Part 3. React-Query 渲染优化

2,480 阅读6分钟

原文 tkdodo.eu/blog/react-…

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

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

isFetching 过渡

在上一个例子中,当我说这个组件只会在 todos 的长度改变时重新渲染时,我并不是完全诚实的:

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>
}

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

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

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

notifyOnChangeProps

对于这个用例,React QuerynotifyOnChangeProps 选项。可以在每个观察者级别设置它以告诉 React Query:如果这些道具之一发生变化,请仅通知此观察者有关更改的信息。通过将此选项设置为 ['data'],我们将得到我们寻求的优化版本:

export const useTodosQuery = (select, notifyOnChangeProps) =>
  useQuery(['todos'], fetchTodos, { select, notifyOnChangeProps })
export const useTodosCount = () =>
  useTodosQuery((data) => data.length, ['data'])

您可以在文档中的乐观更新打字稿示例中看到这一点。

保持同步

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

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

function TodosCount() {
  // 🚨 我们使用了 error,但当 error 改变时我们没有得到响应
  const { error, data } = useTodosCount()

  return (
    <div>
      {error ? error : null}
      {data ? data : null}
    </div>
  )
}

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

跟踪查询

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      notifyOnChangeProps: 'tracked',
    },
  },
})
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

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

如果您使用对象静止解构,您将有效地观察所有字段。正常的解构没问题,只是不要这样做: 有问题的休息破坏

// 🚨 将跟踪使用属性
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ 这就没问题
const { isLoading, data } = useQuery(...)

跟踪查询仅在“渲染期间”有效。如果您仅在效果期间访问字段,则不会跟踪它们。由于依赖数组,这是非常极端的情况:

const queryInfo = useQuery(...)

// 🚨 这将不会被跟踪
React.useEffect(() => {
    console.log(queryInfo.data)
})

// ✅ 这是正确的做法,将依赖的字段访问一遍并置入依赖数组
React.useEffect(() => {
    console.log(queryInfo.data)
}, [queryInfo.data])

跟踪查询不会在每次渲染时重置,因此如果您跟踪一个字段一次,您将在观察者的生命周期内跟踪它:

const queryInfo = useQuery(...)

if (someCondition()) {
    // 如果 someCondition() 是 true,那么将一直追踪 queryInfo.data 的变化
    return <div>{queryInfo.data}</div>
}

结构共享( structural sharing )

React Query 开箱即用的另一个同样重要的渲染优化是结构共享。此功能可确保我们在各个级别保留数据的引用。例如,假设您有以下数据结构:

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

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

[
 // { "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 数组将是新的,因为我们更新了一个 todoid 为 1 的对象也将是新的,但 id 为 2 的对象将与前一状态中的对象具有相同的引用,React Query 只会将其复制到新结果中,因为其中没有任何更改。

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

// ✅ 只有当 id 为 2 的 todo 发生变化才能引起重渲染 
// 多亏了 structural sharing
const { data } = useTodo(2)

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

如果您想了解更多有关幕后发生的事情,请查看 replaceEqualDeep 测试