高级服务端渲染
欢迎来到高级服务端渲染指南,你将在此全面了解如何在流式传输(streaming)、服务端组件(Server Components)以及 Next.js 应用路由器(app router)中使用 React Query。
在阅读本指南前,你可能希望先阅读 服务端渲染与注水指南,它讲解了在服务端渲染(SSR)中使用 React Query 的基础知识;此外,性能与请求瀑布 和 预取与路由集成 也包含有价值的背景知识。
开始之前,请注意:SSR 指南中所述的 initialData 方法在服务端组件中同样适用,但本指南将聚焦于注水(hydration)相关 API。
服务端组件与 Next.js 应用路由器
我们不会在此深入探讨服务端组件,但简而言之,它们是保证仅在服务端运行的组件——不仅在初始页面加载时如此,在页面跳转时亦然。这类似于 Next.js getServerSideProps/getStaticProps 和 Remix loader 的工作方式:它们也始终在服务端运行,但仅能返回数据;而服务端组件能力更广。不过,对 React Query 而言,数据部分仍是核心,因此我们聚焦于此。
我们如何将服务端渲染指南中所学的 在框架加载器(loader)中预取数据并传入应用 这一方法,迁移到服务端组件和 Next.js 应用路由器中?思考这个问题的最佳起点,是把服务端组件视作“仅仅”另一种框架加载器。
关于术语的一点说明
到目前为止,这些指南中我们一直在讨论 服务端 和 客户端。需要特别注意的是,这种划分并不严格对应于 服务端组件 和 客户端组件。服务端组件确实保证只在服务端运行,但客户端组件实际上可在两个地方运行:因为它们也可能在初始 服务端渲染 阶段进行渲染。
一种理解方式是:尽管服务端组件也会 渲染,但其发生在“加载器阶段”(始终在服务端),而客户端组件则运行于“应用阶段”。该应用既可在服务端(SSR 期间)运行,也可在例如浏览器中运行。该应用具体运行位置、是否参与 SSR,可能因框架而异。
初始设置
任何 React Query 设置的第一步,始终是创建一个 queryClient 并用 QueryClientProvider 包裹你的应用。在服务端组件中,此步骤在各框架间大体相同,主要差异在于文件命名约定:
1// In Next.js, this file would be called: app/providers.tsx2'use client'3 4// Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top5import {6 environmentManager,7 QueryClient,8 QueryClientProvider,9} from '@tanstack/react-query'10 11function makeQueryClient() {12 return new QueryClient({13 defaultOptions: {14 queries: {15 // With SSR, we usually want to set some default staleTime16 // above 0 to avoid refetching immediately on the client17 staleTime: 60 * 1000,18 },19 },20 })21}22 23let browserQueryClient: QueryClient | undefined = undefined24 25function getQueryClient() {26 if (environmentManager.isServer()) {27 // Server: always make a new query client28 return makeQueryClient()29 } else {30 // Browser: make a new query client if we don't already have one31 // This is very important, so we don't re-make a new client if React32 // suspends during the initial render. This may not be needed if we33 // have a suspense boundary BELOW the creation of the query client34 if (!browserQueryClient) browserQueryClient = makeQueryClient()35 return browserQueryClient36 }37}38 39export default function Providers({ children }: { children: React.ReactNode }) {40 // NOTE: Avoid useState when initializing the query client if you don't41 // have a suspense boundary between this and the code that may42 // suspend because React will throw away the client on the initial43 // render if it suspends and there is no boundary44 const queryClient = getQueryClient()45 46 return (47 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>48 )49}1// In Next.js, this file would be called: app/layout.tsx2import Providers from './providers'3 4export default function RootLayout({5 children,6}: {7 children: React.ReactNode8}) {9 return (10 <html lang="en">11 <head />12 <body>13 <Providers>{children}</Providers>14 </body>15 </html>16 )17}这部分与 SSR 指南中的做法非常相似,我们只需将内容拆分为两个不同文件即可。
预取与脱水/注水数据
接下来,我们来看如何实际预取数据,然后脱水(dehydrate)和注水(hydrate)它。使用 Next.js Pages Router 时,代码如下所示:
1// pages/posts.tsx2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6 useQuery,7} from '@tanstack/react-query'8 9// This could also be getServerSideProps10export async function getStaticProps() {11 const queryClient = new QueryClient()12 13 await queryClient14 .query({15 queryKey: ['posts'],16 queryFn: getPosts,17 })18 .catch(noop)19 20 return {21 props: {22 dehydratedState: dehydrate(queryClient),23 },24 }25}26 27function Posts() {28 // This useQuery could just as well happen in some deeper child to29 // the <PostsRoute>, data will be available immediately either way30 //31 // Note that we are using useQuery here instead of useSuspenseQuery.32 // Because this data has already been prefetched, there is no need to33 // ever suspend in the component itself. If we forget or remove the34 // prefetch, this will instead fetch the data on the client, while35 // using useSuspenseQuery would have had worse side effects.36 const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })37 38 // This query was not prefetched on the server and will not start39 // fetching until on the client, both patterns are fine to mix40 const { data: commentsData } = useQuery({41 queryKey: ['posts-comments'],42 queryFn: getComments,43 })44 45 // ...46}47 48export default function PostsRoute({ dehydratedState }) {49 return (50 <HydrationBoundary state={dehydratedState}>51 <Posts />52 </HydrationBoundary>53 )54}转换为应用路由器后,代码结构其实非常相似,只需稍作调整。首先,我们创建一个服务端组件来完成预取部分:
1// app/posts/page.tsx2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6} from '@tanstack/react-query'7import Posts from './posts'8 9export default async function PostsPage() {10 const queryClient = new QueryClient()11 12 await queryClient13 .query({14 queryKey: ['posts'],15 queryFn: getPosts,16 })17 .catch(noop)18 19 return (20 // Neat! Serialization is now as easy as passing props.21 // HydrationBoundary is a Client Component, so hydration will happen there.22 <HydrationBoundary state={dehydrate(queryClient)}>23 <Posts />24 </HydrationBoundary>25 )26}接着,我们来看客户端组件部分的写法:
1// app/posts/posts.tsx2'use client'3 4export default function Posts() {5 // This useQuery could just as well happen in some deeper6 // child to <Posts>, data will be available immediately either way7 const { data } = useQuery({8 queryKey: ['posts'],9 queryFn: () => getPosts(),10 })11 12 // This query was not prefetched on the server and will not start13 // fetching until on the client, both patterns are fine to mix.14 const { data: commentsData } = useQuery({15 queryKey: ['posts-comments'],16 queryFn: getComments,17 })18 19 // ...20}上述示例的一个巧妙之处在于:其中唯一 Next.js 特有的部分仅为文件名,其余所有内容在任何支持服务端组件的其他框架中均保持一致。
在 SSR 指南中,我们提到你可以通过避免在每个路由中重复书写 <HydrationBoundary> 来消除样板代码。但在服务端组件中,这是不可能的。
注意:若你在 TypeScript 版本低于
5.1.3且@types/react版本低于18.2.8的环境下使用异步服务端组件时遇到类型错误,建议升级至这两个工具的最新版本。或者,你可采用临时变通方案:在另一组件内调用该组件时添加{/* @ts-expect-error Server Component */}。更多信息请参阅 Next.js TypeScript 文档中的 异步服务端组件 TypeScript 错误。
警告:我们不推荐在
queryFn中使用 Next.js 服务端操作(Server Actions)来 拉取 数据。当从客户端调用时,服务端操作 按顺序执行,而非并行,这与 React Query 拉取和重新拉取查询的方式相冲突。这可能导致查询卡在等待中(pending)状态,或导致操作根本无法执行(参见 #7934)。向queryFn传递服务端操作引用也可能因Only plain objects, and a few built-ins, can be passed to Server Actions...失败,因为你必须 调用 该操作,而非仅传递其引用(参见 #6264)。如需在客户端拉取数据,请改用 API 路由中的fetch,或使用 tRPC 等 RPC 层。服务端操作仍非常适合用于 修改(useMutation)。
嵌套服务端组件
服务端组件的一大优势是可嵌套,并能在 React 树中多层存在,从而允许你在数据实际使用处附近预取数据,而非仅限于应用顶层(正如 Remix 加载器那样)。这可以简单到一个服务端组件渲染另一个服务端组件(为简洁起见,本例中省略客户端组件):
1// app/posts/page.tsx2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6} from '@tanstack/react-query'7import Posts from './posts'8import CommentsServerComponent from './comments-server'9 10export default async function PostsPage() {11 const queryClient = new QueryClient()12 13 await queryClient14 .query({15 queryKey: ['posts'],16 queryFn: getPosts,17 })18 .catch(noop)19 20 return (21 <HydrationBoundary state={dehydrate(queryClient)}>22 <Posts />23 <CommentsServerComponent />24 </HydrationBoundary>25 )26}27 28// app/posts/comments-server.tsx29import {30 dehydrate,31 HydrationBoundary,32 QueryClient,33} from '@tanstack/react-query'34import Comments from './comments'35 36export default async function CommentsServerComponent() {37 const queryClient = new QueryClient()38 39 await queryClient40 .query({41 queryKey: ['posts-comments'],42 queryFn: getComments,43 })44 .catch(noop)45 46 return (47 <HydrationBoundary state={dehydrate(queryClient)}>48 <Comments />49 </HydrationBoundary>50 )51}如你所见,在多个位置使用 <HydrationBoundary> 完全可行,你也可以为预取创建并脱水多个 queryClient。
请注意:由于我们在渲染 CommentsServerComponent 前 await getPosts,这将导致服务端请求瀑布:
11. |> getPosts()22. |> getComments()如果服务端访问数据的延迟较低,这或许问题不大,但仍值得指出。
在 Next.js 中,除在 page.tsx 中预取数据外,你还可以在 layout.tsx 及 并行路由 中进行。由于这些都属于路由系统的一部分,Next.js 知道如何并行获取全部数据。因此,若上述 CommentsServerComponent 改为以并行路由形式表达,则请求瀑布将自动展平。
随着更多框架开始支持服务端组件,它们可能采用其他路由约定。请查阅你所用框架的文档以获取详情。
替代方案:使用单个 queryClient 进行预取
在上述示例中,我们为每个拉取数据的服务端组件创建了一个新的 queryClient。这是推荐做法;但如果你愿意,也可选择创建一个供所有服务端组件复用的单一 queryClient:
1// app/getQueryClient.tsx2import { QueryClient } from '@tanstack/react-query'3import { cache } from 'react'4 5// cache() is scoped per request, so we don't leak data between requests6const getQueryClient = cache(() => new QueryClient())7export default getQueryClient这样做的好处是,你可在任何由服务端组件调用的位置(包括工具函数)中通过 getQueryClient() 获取该客户端实例。缺点是每次调用 dehydrate(getQueryClient()) 时,你都会序列化 整个 queryClient,包括那些此前已序列化过、且与当前服务端组件无关的查询,造成不必要的开销。
Next.js 已对使用 fetch() 的请求进行了去重(dedupe),但若你在 queryFn 中使用其他机制,或所用框架未自动去重此类请求,则采用上述单个 queryClient 方案可能更合理,尽管存在重复序列化问题。
作为未来改进方向,我们可能会开发一个
dehydrateNew()函数(名称待定),该函数仅脱水自上次调用dehydrateNew()以来 新增 的查询。如果你对此感兴趣并愿意参与贡献,请随时联系我们!
数据所有权与重新验证
使用服务端组件时,思考数据所有权与重新验证(revalidation)至关重要。为解释原因,我们来看一个经修改的前述示例:
1// app/posts/page.tsx2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6} from '@tanstack/react-query'7import Posts from './posts'8 9export default async function PostsPage() {10 const queryClient = new QueryClient()11 12 // Note we are getting the result from query13 const posts = await queryClient.query({14 queryKey: ['posts'],15 queryFn: getPosts,16 })17 18 return (19 <HydrationBoundary state={dehydrate(queryClient)}>20 {/* This is the new part */}21 <div>Nr of posts: {posts.length}</div>22 <Posts />23 </HydrationBoundary>24 )25}我们现在同时在服务端组件和客户端组件中渲染 getPosts 查询的数据。初始页面渲染时一切正常,但当 staleTime 已传递、而查询因某种原因在客户端重新验证时,会发生什么?
React Query 完全不知道如何 重新验证服务端组件。因此,若它在客户端重新拉取数据,导致 React 重新渲染帖子列表,则 Nr of posts: {posts.length} 将最终与之不同步。
若你设置了 staleTime: Infinity,使 React Query 永不重新验证,则此情况尚可接受;但若你最初就选用 React Query,这很可能并非你所需。
将 React Query 与服务端组件结合使用最有意义的场景包括:
- 你有一个已使用 React Query 的应用,希望迁移到服务端组件,而无需重写全部数据拉取逻辑;
- 你希望沿用熟悉的编程范式,同时在最合适的场景中融入服务端组件的优势;
- 你有某些 React Query 能覆盖、但所选框架本身无法覆盖的用例。
何时将 React Query 与服务端组件配对使用才合理,很难给出普适性建议。如果你正从零开始构建一个全新的服务端组件应用,我们建议你优先使用框架自身提供的数据拉取工具,并避免过早引入 React Query,直到你真正需要它为止。 这种情况可能永远不会发生,这完全没问题——请选用最适合任务的工具!
若你确实使用它,一条实用经验法则是:避免在服务端渲染 queryClient.query 的结果,或将其传递给其他组件(即使是客户端组件)。
从 React Query 角度看,应将服务端组件仅视为预取数据的场所,别无他用。
当然,服务端组件拥有部分数据、客户端组件拥有其他数据完全可行,只需确保这两套现实不致脱节即可。
使用服务端组件进行流式传输
Next.js 应用路由器会自动将应用中任何已准备就绪的部分尽快流式传输(stream)至浏览器,从而使已完成内容可立即显示,无需等待仍在处理中的内容。此过程沿 <Suspense> 边界进行。请注意:若你创建一个 loading.tsx 文件,系统将在后台自动创建一个 <Suspense> 边界。
借助上述预取模式,React Query 完全兼容此类流式传输。每当每个 Suspense 边界的数据解析完成,Next.js 即可渲染并将完成内容流式传输至浏览器。即使你如上所述使用 useQuery,此机制依然有效,因为挂起(suspending)实际发生在你 await 预取时。
自 React Query v5.40.0 起,要实现此功能,你不再需要 await 所有预取操作,因为 pending 查询也可被脱水并发送至客户端。这让你能尽早启动预取,同时避免阻塞整个 Suspense 边界,并在查询完成时将 数据 流式传输至客户端。例如,当你想预取仅在用户交互后才可见的内容,或想 await 并渲染无限查询的第一页、同时在不阻塞渲染的前提下开始预取第二页时,此功能便十分有用。
为实现此功能,我们需要指示 queryClient 同时 dehydrate 等待中(pending)的查询。我们可通过全局配置,或直接将该选项传给 dehydrate 实现。
我们还需将 getQueryClient() 函数移出 app/providers.tsx 文件,因为我们希望在服务端组件及客户端提供器(client provider)中均能使用它。
1// app/get-query-client.ts2import {3 environmentManager,4 QueryClient,5 defaultShouldDehydrateQuery,6} from '@tanstack/react-query'7 8function makeQueryClient() {9 return new QueryClient({10 defaultOptions: {11 queries: {12 staleTime: 60 * 1000,13 },14 dehydrate: {15 // include pending queries in dehydration16 shouldDehydrateQuery: (query) =>17 defaultShouldDehydrateQuery(query) ||18 query.state.status === 'pending',19 shouldRedactErrors: (error) => {20 // We should not catch Next.js server errors21 // as that's how Next.js detects dynamic pages22 // so we cannot redact them.23 // Next.js also automatically redacts errors for us24 // with better digests.25 return false26 },27 },28 },29 })30}31 32let browserQueryClient: QueryClient | undefined = undefined33 34export function getQueryClient() {35 if (environmentManager.isServer()) {36 // Server: always make a new query client37 return makeQueryClient()38 } else {39 // Browser: make a new query client if we don't already have one40 // This is very important, so we don't re-make a new client if React41 // suspends during the initial render. This may not be needed if we42 // have a suspense boundary BELOW the creation of the query client43 if (!browserQueryClient) browserQueryClient = makeQueryClient()44 return browserQueryClient45 }46}注意:此功能在 Next.js 和服务端组件中可用,是因为 React 在将 Promise 传递给客户端组件时,可跨网络序列化它们。
随后,我们只需提供一个 HydrationBoundary,而无需再 await 预取操作:
1// app/posts/page.tsx2import { dehydrate, HydrationBoundary } from '@tanstack/react-query'3import { getQueryClient } from './get-query-client'4import Posts from './posts'5 6// the function doesn't need to be `async` because we don't `await` anything7export default function PostsPage() {8 const queryClient = getQueryClient()9 10 // look ma, no await11 void queryClient12 .query({13 queryKey: ['posts'],14 queryFn: getPosts,15 })16 .catch(noop)17 18 return (19 <HydrationBoundary state={dehydrate(queryClient)}>20 <Posts />21 </HydrationBoundary>22 )23}在客户端,Promise 将被自动置入 QueryCache。这意味着我们现在可在 Posts 组件内调用 useSuspenseQuery,以“使用”该 Promise(其在服务端创建):
1// app/posts/posts.tsx2'use client'3 4export default function Posts() {5 const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })6 7 // ...8}注意:你也可使用
useQuery替代useSuspenseQuery,Promise 仍将被正确捕获。但此时 Next.js 不会挂起(suspend),组件将以pending状态渲染,这也意味着内容将退出服务端渲染。
若你使用非 JSON 数据类型并在服务端序列化查询结果,可指定 dehydrate.serializeData 和 hydrate.deserializeData 选项,以在边界两侧分别序列化和反序列化数据,确保缓存中的数据在服务端和客户端格式一致:
1// app/get-query-client.ts2import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'3import { deserialize, serialize } from './transformer'4 5function makeQueryClient() {6 return new QueryClient({7 defaultOptions: {8 // ...9 hydrate: {10 deserializeData: deserialize,11 },12 dehydrate: {13 serializeData: serialize,14 },15 },16 })17}18 19// ...1// app/posts/page.tsx2import {3 dehydrate,4 HydrationBoundary,5 QueryClient,6} from '@tanstack/react-query'7import { getQueryClient } from './get-query-client'8import { serialize } from './transformer'9import Posts from './posts'10 11export default function PostsPage() {12 const queryClient = getQueryClient()13 14 // look ma, no await15 void queryClient16 .query({17 queryKey: ['posts'],18 queryFn: () => getPosts().then(serialize), // <-- serialize the data on the server19 })20 .catch(noop)21 22 return (23 <HydrationBoundary state={dehydrate(queryClient)}>24 <Posts />25 </HydrationBoundary>26 )27}1// app/posts/posts.tsx2'use client'3 4export default function Posts() {5 const { data } = useSuspenseQuery({ queryKey: ['posts'], queryFn: getPosts })6 7 // ...8}现在,你的 getPosts 函数可返回例如 Temporal datetime 对象,数据将在客户端被序列化和反序列化(前提是你的转换器支持这些数据类型的序列化与反序列化)。
更多信息,请参阅 带预取功能的 Next.js 应用示例。
在流式传输中使用持久化适配器(Persist Adapter)
若你将持久化适配器与 服务端组件流式传输 功能一同使用,需谨慎避免将 Promise 保存至存储。由于等待中查询可被脱水并流式传输至客户端,你应将持久化器(persister)配置为仅持久化成功查询:
1<PersistQueryClientProvider2 client={queryClient}3 persistOptions={{4 persister,5 // We don't want to save promises into the storage, so we only persist successful queries6 dehydrateOptions: { shouldDehydrateQuery: defaultShouldDehydrateQuery },7 }}8>9 {children}10</PersistQueryClientProvider>此举确保仅有成功解析的查询被持久化至存储,防止因等待中 Promise 引发的序列化问题。
Next.js 中实验性无预取流式传输
虽然我们推荐上述预取方案,因其可在初始页面加载 及 后续任意页面导航中均消除请求瀑布,但也存在一种实验性方式:完全跳过预取,仍实现流式 SSR —— @tanstack/react-query-next-experimental
该包允许你在客户端组件中直接调用 useSuspenseQuery,即可在服务端拉取数据。随后,结果将随 SuspenseBoundary 解析而从服务端流式传输至客户端。若你调用 useSuspenseQuery 时未将其包裹在 <Suspense> 边界内,则 HTML 响应将一直等到拉取完成才开始发送。根据具体情况,这可能是你想要的效果,但请注意这将损害首字节时间(TTFB)。
要实现此功能,请用 ReactQueryStreamedHydration 组件包裹你的应用:
1// app/providers.tsx2'use client'3 4import {5 environmentManager,6 QueryClient,7 QueryClientProvider,8} from '@tanstack/react-query'9import * as React from 'react'10import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'11 12function makeQueryClient() {13 return new QueryClient({14 defaultOptions: {15 queries: {16 // With SSR, we usually want to set some default staleTime17 // above 0 to avoid refetching immediately on the client18 staleTime: 60 * 1000,19 },20 },21 })22}23 24let browserQueryClient: QueryClient | undefined = undefined25 26function getQueryClient() {27 if (environmentManager.isServer()) {28 // Server: always make a new query client29 return makeQueryClient()30 } else {31 // Browser: make a new query client if we don't already have one32 // This is very important, so we don't re-make a new client if React33 // suspends during the initial render. This may not be needed if we34 // have a suspense boundary BELOW the creation of the query client35 if (!browserQueryClient) browserQueryClient = makeQueryClient()36 return browserQueryClient37 }38}39 40export function Providers(props: { children: React.ReactNode }) {41 // NOTE: Avoid useState when initializing the query client if you don't42 // have a suspense boundary between this and the code that may43 // suspend because React will throw away the client on the initial44 // render if it suspends and there is no boundary45 const queryClient = getQueryClient()46 47 return (48 <QueryClientProvider client={queryClient}>49 <ReactQueryStreamedHydration>50 {props.children}51 </ReactQueryStreamedHydration>52 </QueryClientProvider>53 )54}更多信息,请参阅 Next.js Suspense 流式传输示例。
其最大优势在于:你不再需要手动预取查询即可让 SSR 正常工作,且结果仍可流式传输!这带来极佳的开发者体验(DX)与更低的代码复杂度。
其劣势最易通过回顾 性能与请求瀑布指南中的复杂请求瀑布示例 来理解。使用预取的服务端组件,可有效消除初始页面加载 及 后续导航中的请求瀑布。而这种无预取方案,仅能在初始页面加载时展平瀑布,但在页面导航时却退化为与原始示例相同的深层瀑布:
11. |> JS for <Feed>22. |> getFeed()33. |> JS for <GraphFeedItem>44. |> getGraphDataById()这甚至比 getServerSideProps/getStaticProps 更差,因为后者至少还能并行拉取数据与代码。
若你更看重开发者体验/迭代速度/上线速度与低代码复杂度,而非极致性能;或你的查询嵌套不深;又或你已通过 useSuspenseQueries 等工具掌握并行拉取以管控请求瀑布,则此权衡可能颇为合适。
或许可将两种方案结合使用,但我们尚未尝试。若你尝试了,请反馈结果,甚至可更新本文档并分享技巧!
最后的话
服务端组件与流式传输仍是相对较新的概念,我们仍在探索 React Query 如何融入其中,以及如何改进 API。我们欢迎建议、反馈与缺陷报告!
同样,要在一份指南中首次即完整涵盖这一新范式的全部细节,实属不可能。若你发现此处缺少某些信息,或对如何改进内容有建议,也请联系我们;更欢迎点击下方“在 GitHub 上编辑”按钮,助我们完善文档。
延伸阅读
要判断你的应用在使用服务端组件的同时能否从 React Query 中获益,请参阅文章 你或许并不需要 React Query。