React Query 渲染优化

2,016 阅读5分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

本文翻译自 TkDodoReact Query Render Optimizations

免责声明:渲染优化对于任何应用来说都是高级概念,React Query 已经有了很棒的开箱即用的优化,并且在大部分情况下,不需要进一步优化,不需要 re-render 是很多人关注的主题,这也是我想要谈论它的原因,但是我想再次指出,大多数情况下,对于大多数应用,渲染优化可能并不像你觉得那么重要,re-render 它确保了你的应用是最新的,我每天都会选择一个不必要的渲染,而不是丢失一个需要的渲染,更多请参考:

select 选项一章,对于渲染优化,我已经写了不少内容,但是"为什么 React Query 重新渲染了两次,即使我的数据没有变化"可能是我最需要回答的问题,因此,让我来试着深入解释一下

isFetching

上一个例子中,我说这个组件只会在 length 变化时重新渲染,这其实不完全正确

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

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

这是因为 React Query 对每个 query 都暴露出去了很多元数据,isFetching 正是其中一个,它会在每次请求的过程中变为 true,这在你想展示一个 loading 的时候非常有用,但是如果你不需要,那么它也就没必要了

notifyOnChangeProps

对于这种情况,React Query 有一个 notifyOnChangeProps 选项,它可以在每个观察者层面设置,用来告诉 React Query:请只有在这些数据发生变化时,才通知当前观察者,通过设置 ['data'],我们将找到我们所寻求的优化版本

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

你可以在文档的 optimistic-updates-typescript 找到这个功能

保持同步

虽然上边的代码可以工作,但是它也很容易不同步,如果我们想处理错误呢,或者我们开始使用 isLoading,我们必须将我们在组件中使用的字段添加到 notifyOnChangeProps 选项中,如果我们忘记了,只监听了 data,那么当错误发生时,我们的组件就不会重新渲染,并且展示过时的数据,或者我们可以在自定义钩子中硬编码,这就比较麻烦,因为钩子不知道组件使用什么

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

正如一开始说的,我认为这比偶尔的不需要的重新渲染更加糟糕,当然,我们可以把这个选项从组件中传递给自定义钩子,但是这仍然让人觉得很费劲,有没有一种办法可以自动做到这一点,事实上,是有的

Tracked Queries

我为这个功能相当自豪,因为这是我对它的第一个重大贡献,如果你把 notifyOnChangeProps 设置为 trackedReact Query 会追踪你所使用的值,并将计算为一个 list,这与你手动传递没有区别,只是你不必再考虑这个问题了,当然你也可以全局打开这个选项

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

这样,你就不用再担心重新渲染的问题了,当然追踪是有一定的开销的,所以确保你明智的使用它,此外还存在一些限制,这也是为什么这是一个可选的功能

// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)

// ✅ this is totally fine
const { isLoading, data } = useQuery(...)

追踪只发生在渲染期间,所以如果你只在副作用内访问字段,那么它不会被追踪

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])

追踪的字段不会在每次渲染的时候重置,所以,如果你追踪了一个字段一次,那么在观察者的整个生命周期内,你都会追踪它

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

更新:从 v4 开始,追踪查询是默认值,你可以使用notifyOnChangeProps: 'all' 关闭它

结构共享

结构共享是 React Query 另一个非常重要的,开箱即用的优化,这个特性确保我们的数据在每一层级上的引用都是稳定的,假设我们有以下的数据结构

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

现在我们把第一条的数据从 active 状态过度到 todo,并且在后台进行了 re-fetch,我们将从后端获得一个全新的 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 会把旧的数据和新的数据进行比较,以保证保留更多的数据,在我们的例子中,整个数组会是新的,因为我们更新了一条数据,id 为 1 的对象会是新的,但是 id 为 2 的对象会保持和旧数据相同的引用,React Query 会将它赋值到新的数据中,因为它没有任何变化

当我们使用选择器进行部分订阅时,这就非常方便了

// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing
const { data } = useTodo(2)

正如我之前暗示的那样,对于 selector 来说,结构共享进行两次,一次是在 queryFn 返回结果时,用来确定是否有任何东西改变 ,一次是应用于 selector 的返回值,在一些情况下,特别是有巨大的数据时,结构共享可能是一个瓶颈,同时它也仅适用于可序列化的数据,如果你不需要这个优化,你可以通过 structuralSharing: false 来关闭

如果你想要了解更多引擎盖下发生了什么,可以参考这些 replaceEqualDeep tests

本文正在参加「金石计划 . 瓜分6万现金大奖」