框架
版本

性能与请求瀑布

应用程序性能是一个广泛而复杂的领域,虽然 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,这些查询将并行发生。

幸运的是,这很容易解决,当组件中有多个 Suspense 查询时,始终使用 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 两种情况下。

如果子组件根据父组件中的数据有条件地渲染,或者如果子组件依赖于从父组件作为 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> 从父组件获取了一个属性 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 中,但会产生请求瀑布流

效果不佳,也是服务器组件的动机之一。通过服务器组件,可以同时避免这两种情况,有关这如何应用于 React Query 的更多信息,请参阅高级服务器渲染指南

总结和要点

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

  • 在子组件中添加查询,却没有意识到父组件已经有一个查询
  • 在父组件中添加查询,却没有意识到子组件已经有一个查询
  • 将带有子孙组件(其中有查询)的组件移动到一个新的带有祖先组件(其中有查询)的父组件中
  • 等等...

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

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