译零 TransZero · TanStack Query 中文文档
主站

性能与请求瀑布

更新于 2026-11-02 Preact 查看英文原文 ↗

应用性能是一个宽泛而复杂的领域。虽然 Preact Query 无法让你的 API 变得更快,但在使用 Preact Query 时,你仍需留意若干事项,以确保获得最佳性能。

使用 Preact Query(或任何允许你在组件内部拉取数据的数据拉取库)时,最大的性能隐患是请求瀑布(request waterfalls)。本页其余内容将解释什么是请求瀑布、如何识别它们,以及如何重构你的应用或 API 来避免它们。

预取与路由集成指南 在此基础上进一步展开,教你如何在无法或不便于重构应用或 API 的情况下提前预取数据。

服务端渲染与注水指南 教你如何在服务端预取数据,并将该数据传递给客户端,从而避免在客户端重复拉取。

高级服务端渲染指南 进一步讲解如何将这些模式应用于服务端组件(Server Components)和流式服务端渲染(Streaming Server Rendering)。

什么是请求瀑布?

请求瀑布是指:某个资源(代码、CSS、图片、数据等)的请求,必须等到另一个资源的请求完成之后才能发起。

考虑一个网页:在加载 CSS、JS 等资源之前,浏览器首先需要加载 HTML 标记(markup)。这就构成了一个请求瀑布。

Code
11. |-> Markup22.   |-> CSS32.   |-> JS42.   |-> Image

如果你在 JS 文件中拉取 CSS,那么现在就出现了双重瀑布:

Code
11. |-> Markup22.   |-> JS33.     |-> CSS

如果该 CSS 使用了背景图,那就成了三重瀑布:

Code
11. |-> Markup22.   |-> JS33.     |-> CSS44.       |-> Image

识别和分析请求瀑布最常用的方法,是打开浏览器开发者工具(devtools)中的“网络”(Network)选项卡。

每个瀑布至少代表一次往返服务端的请求,除非该资源已被本地缓存(实际上,某些瀑布可能代表多次往返,因为浏览器需要建立连接,而这本身就需要若干次来回通信;不过此处我们暂且忽略这一细节)。正因如此,请求瀑布的负面影响高度依赖于用户的网络延迟。以三重瀑布为例,它实际代表了 4 次服务端往返。若延迟为 250ms(这在 3G 网络或网络状况较差时并不少见),仅计算延迟部分,总耗时就达到 4×250 = 1000ms。如果我们能将其扁平化为第一种情形(仅 2 次往返),则总延迟仅为 500ms,背景图的加载时间可能因此缩短一半!

请求瀑布与 Preact Query

现在让我们聚焦于 Preact Query。我们先以不启用服务端渲染的情形为起点进行分析。在我们甚至开始发起查询之前,必须先加载 JS;因此,在屏幕上显示该数据之前,我们已经遭遇了双重瀑布:

Code
11. |-> Markup22.   |-> JS33.     |-> Query

以此为基础,我们来看几种可能导致 Preact Query 中出现请求瀑布的常见模式,以及如何规避它们:

  • 单组件瀑布 / 串行查询
  • 嵌套组件瀑布
  • 代码分割(Code Splitting)

单组件瀑布 / 串行查询

当单个组件先发起一个查询,再发起另一个查询时,就构成了请求瀑布。这种情况常出现在第二个查询是依赖查询时——即其拉取过程依赖于第一个查询返回的数据:

TypeScript
1// Get the user2const { data: user } = useQuery({3  queryKey: ['user', email],4  queryFn: getUserByEmail,5})6 7const userId = user?.id8 9// Then get the user's projects10const {11  status,12  fetchStatus,13  data: projects,14} = useQuery({15  queryKey: ['projects', userId],16  queryFn: getProjectsByUser,17  // The query will not execute until the userId exists18  enabled: !!userId,19})

尽管并非总是可行,但为了实现最优性能,最好重构你的 API,使这两个查询能合并为一次请求。以上例而言,与其先拉取 getUserByEmail 再基于结果拉取 getProjectsByUser,不如新增一个 getProjectsByUserEmail 查询,从而消除瀑布。

若不重构 API,另一种缓解依赖查询的方式是将瀑布移至服务端执行,因为服务端延迟更低。这正是服务端组件(Server Components)的设计理念,详见高级服务端渲染指南。

串行查询的另一典型场景是将 Preact Query 与 Suspense 配合使用:

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

注意:在常规 useQuery 下,这些查询本可并行执行。

幸运的是,这个问题很容易解决:只要组件内存在多个 Suspense 查询,始终使用 useSuspenseQueries Hook 即可。

TypeScript
1const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({2  queries: [3    { queryKey: ['users'], queryFn: fetchUsers },4    { queryKey: ['teams'], queryFn: fetchTeams },5    { queryKey: ['projects'], queryFn: fetchProjects },6  ],7})

嵌套组件瀑布

嵌套组件瀑布指父组件与子组件各自包含查询,且父组件在自身查询完成前不渲染子组件。这种情况既可能发生在 useQuery 中,也可能发生在 useSuspenseQuery 中。

如果子组件的渲染依赖于父组件的数据,或子组件需通过 props 接收父组件查询结果的某一部分来构造自身的查询,则构成一种依赖型嵌套组件瀑布。

我们先看一个子组件不依赖父组件的示例:

TypeScript
1function Article({ id }) {2  const { data: articleData, isPending } = useQuery({3    queryKey: ['article', id],4    queryFn: getArticleById,5  })6 7  if (isPending) {8    return 'Loading article...'9  }10 11  return (12    <>13      <ArticleHeader articleData={articleData} />14      <ArticleBody articleData={articleData} />15      <Comments id={id} />16    </>17  )18 19}20 21function Comments({ id }) {22  const { data, isPending } = useQuery({23    queryKey: ['article-comments', id],24    queryFn: getArticleCommentsById,25  })26 27  ...28}

注意:尽管 <Comments> 接收了来自父组件的 prop id,但该 id 在 <Article> 渲染时已可用,因此完全可以在获取文章的同时并行拉取评论。在真实应用中,子组件可能深嵌于父组件之下,这类瀑布往往更难识别和修复;但就本例而言,一种扁平化瀑布的方式是将评论查询提升至父组件:

TypeScript
1function Article({ id }) {2  const { data: articleData, isPending: articlePending } = useQuery({3    queryKey: ['article', id],4    queryFn: getArticleById,5  })6 7  const { data: commentsData, isPending: commentsPending } = useQuery({8    queryKey: ['article-comments', id],9    queryFn: getArticleCommentsById,10  })11 12  if (articlePending) {13    return 'Loading article...'14  }15 16  return (17    <>18      <ArticleHeader articleData={articleData} />19      <ArticleBody articleData={articleData} />20      {commentsPending ? (21        'Loading comments...'22      ) : (23        <Comments commentsData={commentsData} />24      )}25    </>26  )27}

此时两个查询将并行拉取。注意:若你正在使用 Suspense,则应改用单个 useSuspenseQueries 将二者合并。

另一种扁平化该瀑布的方式是在 <Article> 组件中预取评论,或在路由层级于页面加载或导航时预取这两个查询。更多细节请参阅预取与路由集成指南。

接下来,我们考察一种依赖型嵌套组件瀑布:

TypeScript
1function Feed() {2  const { data, isPending } = useQuery({3    queryKey: ['feed'],4    queryFn: getFeed,5  })6 7  if (isPending) {8    return 'Loading feed...'9  }10 11  return (12    <>13      {data.map((feedItem) => {14        if (feedItem.type === 'GRAPH') {15          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />16        }1718        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />19      })}20    </>21  )22}23 24function GraphFeedItem({ feedItem }) {25  const { data, isPending } = useQuery({26    queryKey: ['graph', feedItem.id],27    queryFn: getGraphDataById,28  })29 30  ...31}

第二个查询 getGraphDataById 在两个层面依赖其父组件:首先,它仅在 feedItem 是图结构(graph)时才会触发;其次,它需要从父组件获取一个 id。

Code
11. |> getFeed()22.   |> getGraphDataById()

在此例中,仅靠将查询提升至父组件,或单纯添加预取,都无法轻易消除瀑布。正如本指南开头所述的依赖查询示例,一种方案是重构 API,使 getFeed 查询直接包含图结构数据。另一种更高级的方案是借助服务端组件,将瀑布迁移至服务端执行(详见高级服务端渲染指南),但请注意,这可能带来巨大的架构变更。

即使偶尔出现少量查询瀑布,你的应用仍可保持良好性能;你只需意识到这是常见的性能隐患,并对其保持警惕即可。尤其值得警惕的是当代码分割介入时的情形,下面我们继续探讨这一点。

代码分割

将应用的 JS 代码拆分为更小的代码块,并仅加载所需部分,通常是实现高性能的关键步骤。但它也存在弊端:常常会引入请求瀑布。当被分割的代码块内部还包含查询时,问题将进一步加剧。

以下是对 Feed 示例稍作修改的版本:

TypeScript
1import { lazy } from 'preact/iso'2// This lazy loads the GraphFeedItem component, meaning3// it wont start loading until something renders it4const GraphFeedItem = lazy(() => import('./GraphFeedItem'))5 6function Feed() {7  const { data, isPending } = useQuery({8    queryKey: ['feed'],9    queryFn: getFeed,10  })11 12  if (isPending) {13    return 'Loading feed...'14  }15 16  return (17    <>18      {data.map((feedItem) => {19        if (feedItem.type === 'GRAPH') {20          return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />21        }2223        return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />24      })}25    </>26  )27}28 29// GraphFeedItem.tsx30function GraphFeedItem({ feedItem }) {31  const { data, isPending } = useQuery({32    queryKey: ['graph', feedItem.id],33    queryFn: getGraphDataById,34  })35 36  ...37}

此例呈现双重瀑布,如下所示:

Code
11. |> getFeed()22.   |> JS for <GraphFeedItem>33.     |> getGraphDataById()

但这仅是从示例代码角度观察的结果。若考虑该页面的首次加载过程,我们实际上需要完成 5 次服务端往返,才能渲染出图结构!

Code
11. |> Markup22.   |> JS for <Feed>33.     |> getFeed()44.       |> JS for <GraphFeedItem>55.         |> getGraphDataById()

注意:服务端渲染下的表现略有不同,我们将在服务端渲染与注水指南中深入探讨。此外,包含 <Feed> 的路由本身也很可能被代码分割,这又会额外增加一次网络跳转。

在代码分割场景下,将 getGraphDataById 查询提升至 <Feed> 组件并设为条件性执行,或添加条件性预取,反而可能有所帮助。这样,该查询便可与代码加载并行执行,使示例部分变为:

Code
11. |> getFeed()22.   |> getGraphDataById()32.   |> JS for <GraphFeedItem>

但这本质上是一种权衡。你现在将 getGraphDataById 的数据拉取逻辑打包进了 <Feed> 所在的 bundle,因此你需要评估哪种方式更符合你的实际需求。更多实现细节请参阅预取与路由集成指南。

以下两种策略之间的权衡:

  • 将所有数据拉取逻辑放入主 bundle,即使其中大部分 seldom 被使用
  • 将数据拉取逻辑放入代码分割后的 bundle,但伴随请求瀑布

并不理想,而这正是推动服务端组件(Server Components)发展的重要动因之一。借助服务端组件,我们有望同时规避上述两种缺陷。有关其在 Preact Query 中的具体应用,请参阅高级服务端渲染指南。

总结与要点

请求瀑布是一种非常常见且复杂的性能问题,涉及诸多权衡取舍。你可能会在无意中将请求瀑布引入应用,例如:

  • 在子组件中添加一个查询,却未意识到父组件已存在该查询
  • 在父组件中添加一个查询,却未意识到子组件已存在该查询
  • 将一个包含后代组件(且自身含有查询)的组件移动到新父组件下,而该新父组件的某个祖先组件也已含有查询
  • 等等

由于这种意外引入的复杂性,你应当对请求瀑布保持警惕,并定期检查应用中是否存在此类问题(一种有效方式是偶尔查看浏览器的 Network 标签页!)。你并不一定需要消除所有请求瀑布才能获得良好的性能,但需重点关注那些影响较大的请求瀑布。

在下一指南中,我们将探讨更多借助 Prefetching & Router Integration 来扁平化请求瀑布的方法。

本页为 TanStack Query 文档(Preact 版)的非官方中文翻译,原文由上游团队维护,遵循 MIT 许可。 译文如有偏差,以英文原文为准。

译自上游 59d11de