在本指南中,您将学习如何结合服务端渲染使用 React Query。
关于背景知识,请参阅预取与路由集成指南。在此之前,您可能还需要查看性能与请求瀑布流指南。
如需了解高级服务端渲染模式(如流式传输、服务器组件和新的 Next.js app router),请参阅高级服务端渲染指南。
如果您只想查看代码示例,可以直接跳转到下方的完整 Next.js pages router 示例或完整 Remix 示例。
那么什么是服务端渲染?本指南的其余部分将假设您已熟悉这个概念,但让我们花些时间看看它与 React Query 的关系。服务端渲染是在服务器上生成初始 HTML 的行为,这样用户在页面加载时就能立即看到一些内容。这可以在页面被请求时按需发生(SSR),也可以因为之前的请求被缓存或在构建时(SSG)提前发生。
如果您阅读过请求瀑布流指南,可能会记得这个流程:
1. |-> 标记(无内容)
2. |-> JavaScript
3. |-> 查询
1. |-> 标记(无内容)
2. |-> JavaScript
3. |-> 查询
在客户端渲染的应用程序中,这是用户在屏幕上看到任何内容之前至少需要进行的 3 次服务器往返。服务端渲染的一种理解方式是将其转变为:
1. |-> 标记(包含内容 AND 初始数据)
2. |-> JavaScript
1. |-> 标记(包含内容 AND 初始数据)
2. |-> JavaScript
一旦 1. 完成,用户就可以看到内容,当 2. 完成时,页面变得可交互和可点击。因为标记中还包含我们需要的初始数据,所以步骤 3. 根本不需要在客户端运行,至少在您因某些原因想要重新验证数据之前不需要。
这都是从客户端的角度来看的。在服务器端,我们需要在生成/渲染标记之前预取数据,需要将该数据脱水为可序列化的格式以便嵌入到标记中,而在客户端,我们需要将该数据注水到 React Query 缓存中,以避免在客户端进行新的获取。
继续阅读以了解如何用 React Query 实现这三个步骤。
本指南使用常规的 useQuery API。虽然我们不一定会推荐,但可以用 useSuspenseQuery 替代它,只要您总是预取所有查询。这样做的好处是您可以在客户端使用 <Suspense> 来处理加载状态。
如果您在使用 useSuspenseQuery 时忘记预取查询,后果将取决于您使用的框架。在某些情况下,数据会在服务器端 Suspense 并获取,但永远不会被注水到客户端,客户端会再次获取。在这些情况下,您会遇到标记注水不匹配的问题,因为服务器和客户端尝试渲染不同的内容。
使用 React Query 的第一步始终是创建一个 queryClient 并将应用程序包裹在 <QueryClientProvider> 中。在进行服务端渲染时,重要的是在您的应用程序内部、React 状态中创建 queryClient 实例(实例引用也可以)。这确保不同用户和请求之间的数据不会共享,同时仍然只在组件生命周期中创建一次 queryClient。
Next.js pages router 示例:
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// 永远不要这样做:
// const queryClient = new QueryClient()
//
// 在文件根级别创建 queryClient 会使缓存
// 在所有请求之间共享,意味着 _所有_ 数据都会传递给 _所有_ 用户。
// 除了对性能不利外,这还会泄露任何敏感数据。
export default function MyApp({ Component, pageProps }) {
// 应该这样做,确保每个请求都有自己的缓存:
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// 永远不要这样做:
// const queryClient = new QueryClient()
//
// 在文件根级别创建 queryClient 会使缓存
// 在所有请求之间共享,意味着 _所有_ 数据都会传递给 _所有_ 用户。
// 除了对性能不利外,这还会泄露任何敏感数据。
export default function MyApp({ Component, pageProps }) {
// 应该这样做,确保每个请求都有自己的缓存:
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
Remix 示例:
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
最快的方法是根本不涉及 React Query 的预取功能,也不使用 dehydrate/hydrate API。相反,您可以将原始数据作为 initialData 选项传递给 useQuery。让我们看一个使用 Next.js pages router 和 getServerSideProps 的示例。
export async function getServerSideProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts,
})
// ...
}
export async function getServerSideProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: props.posts,
})
// ...
}
这也适用于 getStaticProps 甚至较旧的 getInitialProps,相同的模式可以应用于任何具有等效功能的其他框架。这是在 Remix 中的相同示例:
export async function loader() {
const posts = await getPosts()
return json({ posts })
}
function Posts() {
const { posts } = useLoaderData<typeof loader>()
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: posts,
})
// ...
}
export async function loader() {
const posts = await getPosts()
return json({ posts })
}
function Posts() {
const { posts } = useLoaderData<typeof loader>()
const { data } = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
initialData: posts,
})
// ...
}
设置非常简单,这可以快速解决某些情况,但与完整方法相比,有一些权衡需要考虑:
设置完整的注水解决方案很简单,并且没有这些缺点,这将是本文档其余部分的重点。
只需稍多的设置,您就可以在预加载阶段使用 queryClient 预取查询,将该 queryClient 的序列化版本传递给应用程序的渲染部分并在那里重用。这避免了上述缺点。随意跳转到完整的 Next.js pages router 和 Remix 示例,但在一般情况下,这些是额外的步骤:
一个有趣的细节是实际上涉及三个 queryClient。框架的 loader 是一种"预加载"阶段,发生在渲染之前,这个阶段有自己的 queryClient 进行预取。这个阶段的脱水结果被传递给服务器渲染过程和客户端渲染过程,它们各自有自己的 queryClient。这确保它们从相同的数据开始,因此可以返回相同的标记。
服务器组件是另一种"预加载"阶段,也可以"预加载"(预渲染)React 组件树的部分。在高级服务端渲染指南中了解更多。
有关 app router 的文档,请参阅高级服务端渲染指南。
初始设置:
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
在每个路由中:
// pages/posts.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
// 也可以是 getServerSideProps
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// 这个 useQuery 也可以发生在 <PostsRoute> 的更深层子组件中,
// 数据将立即可用
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 这个查询没有在服务器上预取,将在客户端才开始
// 获取,两种模式可以混合使用
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
// pages/posts.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
// 也可以是 getServerSideProps
export async function getStaticProps() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
}
function Posts() {
// 这个 useQuery 也可以发生在 <PostsRoute> 的更深层子组件中,
// 数据将立即可用
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 这个查询没有在服务器上预取,将在客户端才开始
// 获取,两种模式可以混合使用
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
初始设置:
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
// app/root.tsx
import { Outlet } from '@remix-run/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function MyApp() {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 使用 SSR 时,我们通常希望设置默认的 staleTime
// 大于 0,以避免在客户端立即重新获取
staleTime: 60 * 1000,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
)
}
在每个路由中,注意也可以在嵌套路由中这样做:
// app/routes/posts.tsx
import { json } from '@remix-run/node'
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
export async function loader() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return json({ dehydratedState: dehydrate(queryClient) })
}
function Posts() {
// 这个 useQuery 也可以发生在 <PostsRoute> 的更深层子组件中,
// 数据将立即可用
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 这个查询没有在服务器上预取,将在客户端才开始
// 获取,两种模式可以混合使用
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
// app/routes/posts.tsx
import { json } from '@remix-run/node'
import {
dehydrate,
HydrationBoundary,
QueryClient,
useQuery,
} from '@tanstack/react-query'
export async function loader() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return json({ dehydratedState: dehydrate(queryClient) })
}
function Posts() {
// 这个 useQuery 也可以发生在 <PostsRoute> 的更深层子组件中,
// 数据将立即可用
const { data } = useQuery({ queryKey: ['posts'], queryFn: getPosts })
// 这个查询没有在服务器上预取,将在客户端才开始
// 获取,两种模式可以混合使用
const { data: commentsData } = useQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
// ...
}
export default function PostsRoute() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
在每个路由中都有这部分可能看起来有很多样板代码:
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
export default function PostsRoute({ dehydratedState }) {
return (
<HydrationBoundary state={dehydratedState}>
<Posts />
</HydrationBoundary>
)
}
虽然这种方法没有问题,但如果您想摆脱这种样板代码,可以这样修改 Next.js 的设置:
// _app.tsx
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
// pages/posts.tsx
// 移除带有 HydrationBoundary 的 PostsRoute,直接导出 Posts:
export default function Posts() { ... }
// _app.tsx
import {
HydrationBoundary,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
)
}
// pages/posts.tsx
// 移除带有 HydrationBoundary 的 PostsRoute,直接导出 Posts:
export default function Posts() { ... }
对于 Remix,这稍微复杂一些,我们建议查看 use-dehydrated-state 包。
在预取指南中,我们学习了如何预取依赖查询,但我们如何在框架的 loader 中做到这一点?考虑以下代码,取自依赖查询指南:
// 获取用户
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,
})
我们如何预取这个以便它可以进行服务端渲染?这里有一个示例:
// 对于 Remix,将此重命名为 loader
export async function getServerSideProps() {
const queryClient = new QueryClient()
const user = await queryClient.fetchQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
if (user?.userId) {
await queryClient.prefetchQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
})
}
// 对于 Remix:
// return json({ dehydratedState: dehydrate(queryClient) })
return { props: { dehydratedState: dehydrate(queryClient) } }
}
// 对于 Remix,将此重命名为 loader
export async function getServerSideProps() {
const queryClient = new QueryClient()
const user = await queryClient.fetchQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
if (user?.userId) {
await queryClient.prefetchQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
})
}
// 对于 Remix:
// return json({ dehydratedState: dehydrate(queryClient) })
return { props: { dehydratedState: dehydrate(queryClient) } }
}
当然,这可能会变得更复杂,但由于这些 loader 函数只是 JavaScript,您可以使用该语言的全部功能来构建您的逻辑。确保预取所有您希望进行服务端渲染的查询。
React Query 默认为优雅降级策略。这意味着:
这将导致任何失败的查询在客户端重试,并且服务端渲染的输出将包括加载状态而不是完整内容。
虽然这是一个很好的默认设置,但有时这并不是您想要的。当关键内容缺失时,您可能希望根据情况返回 404 或 500 状态码。对于这些情况,请改用 queryClient.fetchQuery(...),它会在失败时抛出错误,让您以适当的方式处理事情。
let result
try {
result = await queryClient.fetchQuery(...)
} catch (error) {
// 处理错误,参考您的框架文档
}
// 您可能还想检查并处理任何无效的 `result`
let result
try {
result = await queryClient.fetchQuery(...)
} catch (error) {
// 处理错误,参考您的框架文档
}
// 您可能还想检查并处理任何无效的 `result`
如果出于某种原因,您希望在脱水状态中包含失败的查询以避免重试,可以使用 shouldDehydrateQuery 选项覆盖默认函数并实现自己的逻辑:
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
// 这将包括所有查询,包括失败的查询,
// 但您也可以通过检查 `query` 实现自己的逻辑
return true
},
})
dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
// 这将包括所有查询,包括失败的查询,
// 但您也可以通过检查 `query` 实现自己的逻辑
return true
},
})
当在 Next.js 中执行 return { props: { dehydratedState: dehydrate(queryClient) } } 或在 Remix 中执行 return json({ dehydratedState: dehydrate(queryClient) }) 时,发生的是 queryClient 的 dehydratedState 表示由框架序列化,以便可以嵌入到标记中并传输到客户端。
默认情况下,这些框架仅支持返回可安全序列化/解析的内容,因此不支持 undefined、Error、Date、Map、Set、BigInt、Infinity、NaN、-0、正则表达式等。这也意味着您不能从查询中返回任何这些内容。如果返回这些值是您想要的,请查看 superjson 或类似的包。
如果您使用自定义 SSR 设置,您需要自己处理这一步。您的第一反应可能是使用 JSON.stringify(dehydratedState),但由于默认情况下这不会转义像 <script>alert('Oh no..')</script> 这样的内容,这很容易导致