性能与请求瀑布
应用性能是一个宽泛而复杂的领域。虽然 React Query 无法让您的 API 变得更快,但在使用 React Query 时,您仍需注意若干事项,以确保获得最佳性能。
使用 React Query(或任何允许在组件内部拉取数据的数据获取库)时,最大的性能隐患是请求瀑布(request waterfalls)。本页其余内容将解释什么是请求瀑布、如何识别它们,以及如何重构您的应用或 API 来避免它们。
预取与路由集成指南 在此基础上进一步展开,教您在无法或不便于重构应用或 API 的情况下,如何提前预取数据。
服务端渲染与注水指南 教您如何在服务端预取数据,并将该数据传递给客户端,从而避免重复拉取。
高级服务端渲染指南 进一步讲解如何将这些模式应用于服务端组件(Server Components)和流式服务端渲染(Streaming Server Rendering)。
什么是请求瀑布?
请求瀑布是指:对某个资源(代码、CSS、图片、数据等)的请求,必须等到另一个资源的请求完成之后才开始。
考虑一个网页:在加载 CSS、JS 等资源之前,浏览器首先需要加载 HTML 标记(markup)。这就是一个请求瀑布。
11. |-> Markup22. |-> CSS32. |-> JS42. |-> Image如果您在 JS 文件中拉取 CSS,则会形成双重瀑布:
11. |-> Markup22. |-> JS33. |-> CSS如果该 CSS 使用了背景图片,则会形成三重瀑布:
11. |-> Markup22. |-> JS33. |-> CSS44. |-> Image识别和分析请求瀑布的最佳方式,通常是打开浏览器开发者工具(devtools)的“网络”(Network)选项卡。
每个瀑布至少代表一次往返服务端的请求(除非该资源已本地缓存)。实际上,某些瀑布可能代表多次往返,因为浏览器建立连接本身就需要若干次来回通信;但此处我们暂且忽略这一细节。正因如此,请求瀑布的负面影响高度依赖于用户的网络延迟。以三重瀑布为例,它实际代表了 4 次服务端往返。若延迟为 250ms(3G 网络或弱网环境下并不罕见),仅计算延迟部分,总耗时即达 4×250 = 1000ms。而如果我们能将其扁平化为第一个例子中的仅 2 次往返,则总延迟仅为 500ms——背景图片的加载时间可能因此缩短一半!
请求瀑布与 React Query
现在我们来看 React Query。我们先聚焦于无服务端渲染(Server Rendering)的情形。在发起任何查询之前,我们必须先加载 JS;因此,在屏幕上显示数据之前,已经形成了双重瀑布:
11. |-> Markup22. |-> JS33. |-> Query以此为基础,我们来考察几种可能导致 React Query 中出现请求瀑布的模式,以及如何规避它们:
- 单组件瀑布 / 串行查询
- 嵌套组件瀑布
- 代码分割(Code Splitting)
单组件瀑布 / 串行查询
当单个组件先拉取一个查询,再拉取另一个查询时,就构成了请求瀑布。这种情况常见于依赖型查询,即第二个查询在拉取时依赖第一个查询返回的数据:
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)的设计理念,详见高级服务端渲染指南。
串行查询的另一典型场景是结合 Suspense 使用 React Query:
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 下,这些查询本可并行执行。
幸运的是,此问题很容易解决:只要组件内存在多个 suspenseful 查询,始终使用 useSuspenseQueries Hook 即可。
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 接收父组件结果的某一部分才能发起其查询,则构成一种依赖型嵌套组件瀑布。
我们先看一个子组件不依赖父组件的例子:
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> 渲染时即已可用,因此完全可以在拉取文章的同时并行拉取评论。在真实应用中,子组件可能深嵌于父组件之下,此类瀑布往往更难识别与修复;但就本例而言,一种扁平化方案是将评论查询提升至父组件:
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> 组件中预取评论,或在路由层级(页面加载或导航时)预取这两个查询。更多细节请参阅预取与路由集成指南。
接下来,我们考察一种依赖型嵌套组件瀑布:
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。
11. |> getFeed()22. |> getGraphDataById()本例中,仅靠将查询提升至父组件,或简单添加预取,都无法轻易消除瀑布。正如本指南开头所述的依赖型查询示例,一种选择是重构 API,将图数据直接包含在 getFeed 查询中。另一种更高级的方案是利用服务端组件,将瀑布迁移至服务端(延迟更低)——详见高级服务端渲染指南;但请注意,这可能带来巨大的架构变更。
即使存在少量请求瀑布,您的应用仍可保持良好性能;只需意识到这是常见的性能隐患,并对其保持警惕即可。尤其隐蔽的一种情况是涉及代码分割时,我们接下来便探讨这一点。
代码分割
将应用的 JS 代码拆分为更小的代码块,并仅加载必要部分,通常是实现高性能的关键步骤。但它也存在弊端:常常引入请求瀑布。当被分割的代码块内部还包含查询时,该问题将进一步加剧。
考虑这个稍作修改的 Feed 示例:
1// This lazy loads the GraphFeedItem component, meaning2// it wont start loading until something renders it3const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))4 5function Feed() {6 const { data, isPending } = useQuery({7 queryKey: ['feed'],8 queryFn: getFeed,9 })10 11 if (isPending) {12 return 'Loading feed...'13 }14 15 return (16 <>17 {data.map((feedItem) => {18 if (feedItem.type === 'GRAPH') {19 return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />20 }2122 return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />23 })}24 </>25 )26}27 28// GraphFeedItem.tsx29function GraphFeedItem({ feedItem }) {30 const { data, isPending } = useQuery({31 queryKey: ['graph', feedItem.id],32 queryFn: getGraphDataById,33 })34 35 ...36}本例呈现双重瀑布,如下所示:
11. |> getFeed()22. |> JS for <GraphFeedItem>33. |> getGraphDataById()但这仅基于示例代码本身。若考虑该页面的首次加载过程,我们实际上需要完成 5 次服务端往返,才能渲染出图结构!
11. |> Markup22. |> JS for <Feed>33. |> getFeed()44. |> JS for <GraphFeedItem>55. |> getGraphDataById()注意:服务端渲染下的表现略有不同,我们将在服务端渲染与注水指南中深入探讨。另请注意,包含 <Feed> 的路由本身也常被代码分割,这可能再增加一次网络跳转。
在代码分割场景下,将 getGraphDataById 查询提升至 <Feed> 组件并设为条件执行,或添加条件预取,反而可能有益。这样,该查询便可与代码加载并行执行,使示例部分变为:
11. |> getFeed()22. |> getGraphDataById()32. |> JS for <GraphFeedItem>但这本质上是一种权衡。您现在将 getGraphDataById 的数据拉取逻辑与 <Feed> 打包在同一 bundle 中,因此需评估哪种方案更符合您的实际需求。更多实践方法请参阅预取与路由集成指南。
以下两种方案之间的权衡:
- 将所有数据拉取逻辑包含在主 bundle 中,即使其中大部分 seldom 被使用
- 将数据拉取逻辑放入代码分割的 bundle 中,但伴随请求瀑布
并不理想,而这正是推动服务端组件(Server Components)发展的动因之一。借助服务端组件,可同时规避上述两种缺陷。有关其在 React Query 中的具体应用,请参阅高级服务端渲染指南。
总结与要点
请求瀑布是一种非常普遍且复杂的性能问题,涉及诸多权衡。您可能在不经意间向应用中引入它们,例如:
- 向子组件添加查询,却未意识到父组件已存在查询
- 向父组件添加查询,却未意识到子组件已存在查询
- 将带有后代查询的组件迁移至新父组件,而该新父组件的祖先组件本身已有查询
- 等等……
由于这种无意引入的复杂性,主动关注瀑布并定期检查应用(例如不定期查看 Network 选项卡)十分值得。您未必需要消除所有瀑布才能获得良好性能,但务必重点关注那些高影响的瀑布。
在下一指南中,我们将借助预取与路由集成探索更多消除瀑布的方法。