框架
版本

性能和请求瀑布流

应用程序性能是一个广泛而复杂的领域,虽然 React Query 无法使您的 API 更快,但在如何使用 React Query 以确保最佳性能方面仍有一些需要注意的事项。

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

预取和路由器集成指南以此为基础,并教您如何在无法或不可行地重构应用程序或 API 时提前预取数据。

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

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

什么是请求瀑布流?

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

考虑一个网页。在您可以加载 CSS、JS 等内容之前,浏览器首先需要加载标记。这是一个请求瀑布流。

1. |-> 标记
2.   |-> CSS
2.   |-> JS
2.   |-> 图像
1. |-> 标记
2.   |-> CSS
2.   |-> JS
2.   |-> 图像

如果您在 JS 文件中获取 CSS,那么现在您有一个双重瀑布流:

1. |-> 标记
2.   |-> JS
3.     |-> CSS
1. |-> 标记
2.   |-> JS
3.     |-> CSS

如果该 CSS 使用背景图片,则为三重瀑布流:

1. |-> 标记
2.   |-> JS
3.     |-> CSS
4.       |-> 图像
1. |-> 标记
2.   |-> JS
3.     |-> CSS
4.       |-> 图像

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

每个瀑布流至少代表一次到服务器的往返,除非资源已本地缓存(实际上,其中一些瀑布流可能代表不止一次往返,因为浏览器需要建立连接,这需要一些来回通信,但我们在此忽略这一点)。因此,请求瀑布流的负面影响在很大程度上取决于用户的延迟。考虑三重瀑布流的示例,它实际上代表 4 次服务器往返。在 250 毫秒的延迟下(这在 3G 网络或网络状况不佳的情况下并不少见),我们最终的总时间仅计算延迟就达到了 4*250=1000 毫秒。如果我们能够将其展平为第一个示例,只有 2 次往返,那么我们将得到 500 毫秒,从而可能将加载该背景图片的时间缩短一半!

请求瀑布流和 React Query

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

1. |-> 标记
2.   |-> JS
3.     |-> 查询
1. |-> 标记
2.   |-> JS
3.     |-> 查询

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

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

单组件瀑布流/串行查询

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

tsx
// 获取用户
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// 然后获取用户的项目
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // 直到 userId 存在,查询才会执行
  enabled: !!userId,
})
// 获取用户
const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// 然后获取用户的项目
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // 直到 userId 存在,查询才会执行
  enabled: !!userId,
})

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

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

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

tsx
function App () {
  // 以下查询将串行执行,导致到服务器的单独往返:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // 请注意,由于上面的查询会暂停渲染,因此在所有查询完成之前不会渲染任何数据
  ...
}
function App () {
  // 以下查询将串行执行,导致到服务器的单独往返:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // 请注意,由于上面的查询会暂停渲染,因此在所有查询完成之前不会渲染任何数据
  ...
}

请注意,使用常规 useQuery 时,这些操作会并行发生。

幸运的是,这很容易修复,只需在组件中有多个悬念查询时始终使用 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 },
  ],
})

嵌套组件瀑布流

嵌套组件瀑布流是指父组件和子组件都包含查询,并且父组件在查询完成之前不渲染子组件。这在使用 useQueryuseSuspenseQuery 时都可能发生。

如果子组件根据父组件中的数据有条件地渲染,或者如果子组件依赖于从父组件传递下来的结果的某个部分来进行查询,则我们有一个_依赖的_嵌套组件瀑布流。

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

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

  if (isPending) {
    return '正在加载文章...'
  }

  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 '正在加载文章...'
  }

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

}

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

  ...
}

请注意,虽然 <Comments> 从父组件获取了一个 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 '正在加载文章...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        '正在加载评论...'
      ) : (
        <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 '正在加载文章...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        '正在加载评论...'
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  )
}

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

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

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

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

  if (isPending) {
    return '正在加载动态...'
  }

  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 '正在加载动态...'
  }

  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
// 这会延迟加载 GraphFeedItem 组件,这意味着
// 它在某些内容渲染它之前不会开始加载
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

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

  if (isPending) {
    return '正在加载动态...'
  }

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

  ...
}
// 这会延迟加载 GraphFeedItem 组件,这意味着
// 它在某些内容渲染它之前不会开始加载
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

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

  if (isPending) {
    return '正在加载动态...'
  }

  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. |> 标记
2.   |-> JS for <Feed>
3.     |-> getFeed()
4.       |-> JS for <GraphFeedItem>
5.         |-> getGraphDataById()
1. |> 标记
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> 相同的捆绑包中,因此您需要根据具体情况评估最佳方案。有关如何执行此操作的更多信息,请阅读预取和路由器集成指南

权衡:

  • 将所有数据获取代码包含在主捆绑包中,即使我们很少使用它
  • 将数据获取代码放在代码拆分捆绑包中,但存在请求瀑布流

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

总结和要点

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

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

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

在下一篇指南中,我们将介绍更多展平瀑布流的方法,方法是利用预取和路由器集成