框架
版本

性能 & 请求瀑布流

应用程序性能是一个广泛而复杂的领域,虽然 React Query 不能使你的 API 更快,但在你使用 React Query 时,仍然需要注意一些事项,以确保最佳性能。

当使用 React Query,或者任何允许你在组件内部获取数据的库时,最大的性能陷阱是请求瀑布流。本页的剩余部分将解释它们是什么,你如何发现它们,以及你如何重构你的应用程序或 API 以避免它们。

《预取 & 路由集成指南》以此为基础,教你如何在无法或不可行重构你的应用程序或 API 时,提前预取数据。

《服务端渲染 & 水合指南》教你如何在服务器上预取数据,并将数据传递到客户端,这样你就不必再次获取它。

《高级服务端渲染指南》进一步教你如何将这些模式应用于服务端组件和流式服务端渲染。

什么是请求瀑布流?

请求瀑布流是指当对资源的请求(代码、CSS、图像、数据)在另一个资源的请求完成后才开始的情况。

以网页为例。在你可以加载 CSS、JS 等内容之前,浏览器首先需要加载标记。这就是请求瀑布流。

1. |-> Markup
2.   |-> CSS
2.   |-> JS
2.   |-> Image
1. |-> Markup
2.   |-> CSS
2.   |-> JS
2.   |-> Image

如果你在 JS 文件中获取你的 CSS,你现在就有了双重瀑布流

1. |-> Markup
2.   |-> JS
3.     |-> CSS
1. |-> Markup
2.   |-> JS
3.     |-> CSS

如果该 CSS 使用背景图像,那就是三重瀑布流

1. |-> Markup
2.   |-> JS
3.     |-> CSS
4.       |-> Image
1. |-> Markup
2.   |-> JS
3.     |-> CSS
4.       |-> Image

发现和分析你的请求瀑布流的最佳方法通常是打开你的浏览器开发者工具的 "网络" 选项卡。

除非资源在本地缓存,否则每个瀑布流都代表至少一次往返服务器(实际上,由于浏览器需要建立连接,这需要一些来回交互,因此其中一些瀑布流可能代表多次往返,但我们在这里忽略这一点)。因此,请求瀑布流的负面影响高度依赖于用户的延迟。考虑三重瀑布流的例子,它实际上代表 4 次服务器往返。在 250 毫秒的延迟下,这在 3g 网络或恶劣的网络条件下并不罕见,我们最终得到的总时间为 4*250=1000 毫秒,这仅仅是计算延迟。如果我们能够将其扁平化为第一个只有 2 次往返的例子,我们得到的是 500 毫秒,可能在一半的时间内加载该背景图像!

请求瀑布流 & React Query

现在让我们考虑 React Query。我们将首先关注没有服务端渲染的情况。在我们甚至可以开始进行查询之前,我们需要加载 JS,所以在我们可以在屏幕上显示数据之前,我们有一个双重瀑布流

1. |-> Markup
2.   |-> JS
3.     |-> Query
1. |-> Markup
2.   |-> JS
3.     |-> Query

以此为基础,让我们看看一些可能导致 React Query 中出现请求瀑布流的不同模式,以及如何避免它们。

  • 单组件瀑布流 / 串行查询
  • 嵌套组件瀑布流
  • 代码分割

单组件瀑布流 / 串行查询

当单个组件首先获取一个查询,然后再获取另一个查询时,这就是请求瀑布流。当第二个查询是依赖查询时,可能会发生这种情况,也就是说,它在获取时依赖于来自第一个查询的数据

tsx
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})
// Get the user
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})

虽然并非总是可行,但为了获得最佳性能,最好重构你的 API,以便你可以在单个查询中获取这两个。在上面的例子中,与其先获取 getUserByEmail 以便能够 getProjectsByUser,不如引入一个新的 getProjectsByUserEmail 查询,这将扁平化瀑布流。

另一种在不重构 API 的情况下缓解依赖查询的方法是将瀑布流移动到延迟较低的服务器上。这是服务端组件背后的思想,服务端组件在《高级服务端渲染指南》中介绍。

串行查询的另一个例子是当你将 React Query 与 Suspense 一起使用时

tsx
function App () {
  // The following queries will execute in serial, causing separate roundtrips to the server:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // Note that since the queries above suspend rendering, no data
  // gets rendered until all of the queries finished
  ...
}
function App () {
  // The following queries will execute in serial, causing separate roundtrips to the server:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // Note that since the queries above suspend rendering, no data
  // gets rendered until all of the queries finished
  ...
}

请注意,使用普通的 useQuery,这些查询会并行发生。

幸运的是,这很容易修复,方法是在组件中有多个 suspenseful 查询时,始终使用 useSuspenseQueries 钩子。

tsx
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['teams'], queryFn: fetchTeams },
    { queryKey: ['projects'], queryFn: fetchProjects },
  ],
})
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['teams'], queryFn: fetchTeams },
    { queryKey: ['projects'], queryFn: fetchProjects },
  ],
})

嵌套组件瀑布流

嵌套组件瀑布流是指当父组件和子组件都包含查询,并且父组件在子组件的查询完成之前不渲染子组件时发生的情况。这可能发生在 useQuery 和 useSuspenseQuery 中。

如果子组件根据父组件中的数据有条件地渲染,或者如果子组件依赖于从父组件作为 prop 传递下来的部分结果来执行其查询,那么我们就有了依赖的嵌套组件瀑布流。

让我们首先看一个子组件不依赖于父组件的例子。

tsx
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )

}

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}
function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )

}

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}

请注意,虽然 <Comments> 从父组件获取一个 prop id,但当 <Article> 渲染时,该 id 已经可用,因此我们没有理由不能在与文章相同的时间获取评论。在实际应用中,子组件可能嵌套在父组件的下方很远,这类瀑布流通常更难发现和修复,但对于我们的例子,扁平化瀑布流的一种方法是将评论查询提升到父组件。

tsx
function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  if (articlePending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        'Loading comments...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}
function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  if (articlePending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        'Loading comments...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}

这两个查询现在将并行获取。请注意,如果你正在使用 suspense,你可能需要将这两个查询合并为一个 useSuspenseQueries。

另一种扁平化这个瀑布流的方法是在 <Article> 组件中预取评论,或者在页面加载或页面导航时在路由器级别预取这两个查询,在《预取 & 路由集成指南》中阅读更多关于此内容的信息。

接下来,让我们看看一个依赖的嵌套组件瀑布流。

tsx
function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}
function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}

第二个查询 getGraphDataById 在两个不同的方面依赖于其父组件。首先,除非 feedItem 是一个图表,否则它永远不会发生,其次,它需要来自父组件的 id。

1. |> getFeed()
2.   |> getGraphDataById()
1. |> getFeed()
2.   |> getGraphDataById()

在这个例子中,我们不能简单地通过将查询提升到父组件,甚至添加预取来扁平化瀑布流。就像本指南开头的依赖查询示例一样,一种选择是重构我们的 API,将图表数据包含在 getFeed 查询中。另一个更高级的解决方案是利用服务端组件将瀑布流移动到延迟较低的服务器上(在《高级服务端渲染指南》中阅读更多关于此内容的信息),但请注意,这可能是一个非常大的架构更改。

即使这里那里有一些查询瀑布流,你也可以获得良好的性能,只需知道它们是一个常见的性能问题,并注意它们。一个特别隐蔽的版本是当代码分割参与进来时,让我们接下来看看这个。

代码分割

将应用程序的 JS 代码分割成更小的块,并且只加载必要的部分,通常是实现良好性能的关键步骤。然而,它确实有一个缺点,那就是它经常引入请求瀑布流。当代码分割的代码内部也有一个查询时,这个问题会进一步恶化。

将此视为 Feed 示例的稍微修改版本。

tsx
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}
// This lazy loads the GraphFeedItem component, meaning
// it wont start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
  const { data, isPending } = useQuery({
    queryKey: ['feed'],
    queryFn: getFeed,
  })

  if (isPending) {
    return 'Loading feed...'
  }

  return (
    <>
      {data.map((feedItem) => {
        if (feedItem.type === 'GRAPH') {
          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
        }

        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
      })}
    </>
  )
}

// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })

  ...
}

这个例子有一个双重瀑布流,看起来像这样

1. |> getFeed()
2.   |> JS for <GraphFeedItem>
3.     |> getGraphDataById()
1. |> getFeed()
2.   |> JS for <GraphFeedItem>
3.     |> getGraphDataById()

但这只是从示例代码来看,如果我们考虑这个页面的首次加载是什么样子,我们实际上必须完成 5 次往返服务器才能渲染图表!

1. |> Markup
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()
1. |> Markup
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()

请注意,当服务端渲染时,这看起来有点不同,我们将在《服务端渲染 & 水合指南》中进一步探讨。还要注意,包含 <Feed> 的路由也进行代码分割的情况并不少见,这可能会增加另一个跳跃。

在代码分割的情况下,将 getGraphDataById 查询提升到 <Feed> 组件并使其成为有条件的,或者添加有条件预取,实际上可能会有所帮助。然后,该查询可以与代码并行获取,将示例部分变成这样

1. |> getFeed()
2.   |> getGraphDataById()
2.   |> JS for <GraphFeedItem>
1. |> getFeed()
2.   |> getGraphDataById()
2.   |> JS for <GraphFeedItem>

然而,这在很大程度上是一种权衡。你现在将 getGraphDataById 的数据获取代码包含在与 <Feed> 相同的 bundle 中,因此评估哪种最适合你的情况。在《预取 & 路由集成指南》中阅读更多关于如何执行此操作的信息。

之间的权衡

  • 将所有数据获取代码包含在主 bundle 中,即使我们很少使用它
  • 将数据获取代码放在代码分割的 bundle 中,但会产生请求瀑布流

并不理想,并且一直是服务端组件的动机之一。使用服务端组件,可以避免两者,在《高级服务端渲染指南》中阅读更多关于这如何应用于 React Query 的信息。

总结与要点

请求瀑布流是一个非常常见且复杂的性能问题,涉及许多权衡。有很多方法可以意外地将它们引入你的应用程序中

  • 向子组件添加查询,而没有意识到父组件已经有一个查询
  • 向父组件添加查询,而没有意识到子组件已经有一个查询
  • 将具有查询的后代组件移动到具有查询祖先的新父组件
  • 等等...

由于这种意外的复杂性,注意瀑布流并定期检查你的应用程序以寻找它们是值得的(一个好方法是时不时地检查网络选项卡!)。你不必为了获得良好的性能而扁平化所有瀑布流,但要留意那些影响较大的瀑布流。

在下一份指南中,我们将通过利用预取 & 路由集成,来研究更多扁平化瀑布流的方法。