在 TanStack Router 中集成 tRPC 与 React Query:全栈类型安全实战指南
2026/9/15 19:05:29 网站建设 项目流程

在 TanStack Router 中集成 tRPC 与 React Query:全栈类型安全实战指南

【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router

本文以仓库中的examples/react/with-trpc-react-query示例为蓝本,讲解如何将 tRPC 与 TanStack Query 无缝集成进 TanStack Router 的 React 应用,覆盖服务端 tRPC Router 定义、客户端 Options Proxy 封装、路由 loader 中的数据预取、URL 状态同步以及前后端双构建的生产部署流程。读完本文,你将掌握一套端到端类型安全、自带数据缓存与乐观更新能力的前后端一体式全栈架构方案。

示例概览与核心价值

tRPC 的核心卖点是端到端类型安全:API 的类型定义从服务端自动流动到客户端,不再需要手写 API 客户端、手动维护类型或运行时校验层。而 TanStack Query 负责服务端状态的管理——缓存、重试、失效与乐观更新。TanStack Router 则在前端提供类型安全的路由、路径参数与搜索参数解析。

三者结合后,路由的loader可以直接在服务端数据返回前预取并填充 Query 缓存,页面组件再通过useQuery消费同一份数据,避免重复请求与闪烁加载。示例 README.md 将其总结为五点能力:

  • tRPC 与 TanStack Router 的深度集成
  • 使用 TanStack Query 进行数据获取
  • 带缓存的类型安全 API 调用
  • 端到端类型安全
  • 乐观更新(Optimistic updates)能力支撑

示例的应用是一个带/dashboard/posts列表与/dashboard/posts/$postId详情页的小型博客后台,配套一个内存中的文章数据源,完整演示了从服务端过程(procedure)定义到前端消费的整条链路。

快速开始:基于示例创建新项目

示例提供了基于gitpick的脚手架命令,可以一键把整个示例目录复制为你的新项目:

npx gitpick TanStack/router/tree/main/examples/react/with-trpc-react-query with-trpc-react-query

说明:该命令从 TanStack Router 仓库中摘取本示例目录到当前文件夹,新目录名为with-trpc-react-query

进入项目后安装依赖并启动开发服务器:

pnpm install pnpm dev

示例的dev脚本在 package.json 中定义为pnpm tsx ./src/server/server.ts --watch,即通过tsx以 watch 模式直接运行 Express 服务器脚本,Vite 以 middleware 模式挂载在同一个 Express 实例上,因此开发时前端页面与/trpc接口同源,客户端无需显式配置完整 URL。

生产构建分两步(脚本见 package.json):

pnpm build # 等价于 pnpm run build:server && pnpm run build:client pnpm start # NODE_ENV=production node dist/server/server.js
  • build:servervite build --mode server,产出dist/server(SSR 构建);
  • build:clientvite build && tsc --noEmit,产出dist/client并执行 TypeScript 类型检查。

vite.config.ts通过configEnv.mode区分两种构建:客户端构建输出到dist/client并拷贝public资源;服务端构建以 src/server/server.ts 为入口做 SSR 打包(rolldownOptions.input指定入口)。两种模式共用同一套插件链:tailwindcss()tanstackRouter({ target: 'react', autoCodeSplitting: true })react(),其中autoCodeSplitting: true会按路由自动做代码分割。

服务端:用 Express 挂载 tRPC Router

tRPC 服务端定义集中在 src/server/trpc.ts,这是全栈类型安全的类型源头

import { initTRPC } from '@trpc/server' import { createExpressMiddleware } from '@trpc/server/adapters/express' import type { CreateExpressContextOptions } from '@trpc/server/adapters/express' const createTRPContext = ({ req, res }: CreateExpressContextOptions) => ({}) type TRPCContext = Awaited<ReturnType<typeof createTRPContext>> const t = initTRPC.context<TRPCContext>().create() const POSTS = [ /* 10 条模拟文章数据 */ ] export const appRouter = t.router({ hello: t.procedure.query(() => 'Hello world!'), posts: t.procedure.query(async (_) => { await new Promise((resolve) => setTimeout(resolve, 1000)) return POSTS }), post: t.procedure.input(String).query(async (req) => { await new Promise((resolve) => setTimeout(resolve, 500)) return POSTS.find((p) => p.id === req.input) }), }) export const trpcMiddleWare = createExpressMiddleware({ router: appRouter, createContext: createTRPContext, }) export type AppRouter = typeof appRouter

几个关键点:

  • initTRPC.context<TRPCContext>().create()定义了请求上下文类型。本示例上下文为空对象,实际项目可在其中注入req/res、数据库连接、鉴权用户等;
  • 三个 procedure 演示了 tRPC 的两种形态:无参查询(helloposts)与带输入校验的查询(postinput(String)声明入参为字符串)。postspost人为加入了 1 秒与 0.5 秒延迟,用于直观观察 loader 预取与 Spinner 的表现;
  • export type AppRouter = typeof appRouter将路由类型导出,供客户端以纯类型方式引用——这是端到端类型安全的枢纽;
  • createExpressMiddleware把 tRPC Router 暴露为标准 Express 中间件。

服务器装配在 src/server/server.ts 中:app.use('/trpc', trpcMiddleWare)把 tRPC 端点挂在/trpc路径下,随后根据环境分支:

  • 开发模式:通过vite.createServer({ middlewareMode: true, appType: 'custom', hmr: { port: HMR_PORT } })创建 Vite 中间件挂载到 Express,并用app.get('/{*splat}')兜底返回经transformIndexHtml处理的index.html,从而让前端路由回退到 SPA;
  • 生产模式express.static托管dist/client静态资源,同样的/{*splat}兜底返回index.html支持前端路由;
  • 端口默认3000process.env.PORT可覆盖),HMR 端口默认3001

客户端:TRPC Options Proxy 与 Router 上下文

客户端集成的枢纽是 src/router.tsx。它利用@trpc/tanstack-react-query提供的createTRPCOptionsProxy,把 tRPC 过程直接转化为 React Query 的 options 对象:

import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createTRPCClient, httpBatchLink } from '@trpc/client' import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query' import { routeTree } from './routeTree.gen' import type { AppRouter } from './server/trpc' export const queryClient = new QueryClient() export const trpc = createTRPCOptionsProxy<AppRouter>({ client: createTRPCClient({ links: [ httpBatchLink({ url: '/trpc', }), ], }), queryClient, }) export function createRouter() { const router = createTanStackRouter({ routeTree, scrollRestoration: true, defaultPreload: 'intent', context: { trpc, queryClient, }, defaultPendingComponent: () => ( <div className={`p-2 text-2xl`}> <Spinner /> </div> ), Wrap: function WrapComponent({ children }) { return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }, }) return router } declare module '@tanstack/react-router' { interface Register { router: ReturnType<typeof createRouter> } }

要点拆解:

  • createTRPCClient通过httpBatchLink指向/trpc。由于开发模式 Vite 与 Express 同源,直接写相对路径/trpc即可,无需拼主机名;
  • createTRPCOptionsProxy<AppRouter>接收客户端与queryClient,之后trpc.posts.queryOptions()trpc.post.queryOptions(postId)返回的即是可直接传给useQuery/ensureQueryData的标准 React Query options,同时保留完整的入参与出参类型推导;
  • context中注入trpcqueryClient,使路由的loader能通过context访问它们,实现“进组件前先填缓存”;
  • WrapQueryClientProvider包裹整个应用,让所有路由组件共享同一个 QueryClient;
  • 底部declare module注册 Router 实例类型,配合routeTree.gen.ts实现LinkuseNavigateuseParams等 API 的全面类型推导。

入口 src/main.tsx 通过createRouter()创建 Router 实例,并以RouterProvider挂载到#root(仅在 root 为空时渲染,避免 HMR 重复挂载)。

路由 loader 中的数据预取:进入页面前先填缓存

示例最精彩的部分是路由 loader 与 tRPC 查询 options 的配合——数据在页面渲染前就已进入 Query 缓存,页面组件直接命中缓存,几乎无加载闪烁。

文章列表路由 src/routes/dashboard.posts.tsx:

export const Route = createFileRoute('/dashboard/posts')({ errorComponent: () => 'Oh crap!', loader: async ({ context: { trpc, queryClient } }) => { await queryClient.ensureQueryData(trpc.posts.queryOptions()) return }, pendingComponent: Spinner, component: DashboardPostsComponent, }) function DashboardPostsComponent() { const postsQuery = useQuery(trpc.posts.queryOptions()) const posts = postsQuery.data || [] return ( // 遍历 posts,用 <Link to="/dashboard/posts/$postId" params={{ postId }} preload="intent"> // 渲染文章列表,右侧 <Outlet /> 渲染子路由 ) }
  • loaderqueryClient.ensureQueryData(trpc.posts.queryOptions()):若缓存未命中则发起请求并填充缓存,loader 完成后组件挂载时useQuery(trpc.posts.queryOptions())直接读到已缓存数据;
  • pendingComponent: Spinner在 loader 执行期间显示加载态;
  • errorComponent: () => 'Oh crap!'提供最小的错误兜底;
  • 列表中每个Link使用preload="intent"(配合 Router 的defaultPreload: 'intent'),用户鼠标悬停/聚焦时即触发预取,进一步提升跳转响应速度;MatchRoute结合pending属性在目标路由进入 pending 状态时展示 Spinner。

文章详情路由 src/routes/dashboard.posts.$postId.tsx 演示了带参数的预取:

export const Route = createFileRoute('/dashboard/posts/$postId')({ validateSearch: z.object({ showNotes: z.boolean().optional(), notes: z.string().optional(), }), loader: async ({ context: { trpc, queryClient }, params: { postId } }) => { await queryClient.ensureQueryData(trpc.post.queryOptions(postId)) }, pendingComponent: Spinner, component: DashboardPostsPostIdComponent, }) function DashboardPostsPostIdComponent() { const postId = Route.useParams({ select: (d) => d.postId }) const postQuery = useQuery(trpc.post.queryOptions(postId)) const post = postQuery.data // ... }
  • loader 从params拿到postId,调用trpc.post.queryOptions(postId)预取单篇文章,入参类型由 tRPC 服务端input(String)约束,天然类型安全;
  • Route.useParams({ select: ... })精确订阅postId参数,避免不必要的重渲染;
  • useQuery(trpc.post.queryOptions(postId))消费缓存;服务端返回undefined(找不到文章)时组件渲染 “Post not found”。

URL 即状态:搜索参数与笔记同步

详情页还演示了 TanStack Router 的搜索参数(search params)能力,配合 tRPC 数据展示“Notes 存进 URL”的交互模式:

const search = Route.useSearch() const navigate = Route.useNavigate() const [notes, setNotes] = React.useState(search.notes ?? ``) React.useEffect(() => { navigate({ search: (old) => ({ ...old, notes: notes ? notes : undefined }), replace: true, params: true, }) }, [notes])
  • validateSearch用 zod 声明搜索参数结构:showNotes?: booleannotes?: string,非法输入会被过滤,保证useSearch返回值类型安全;
  • 用户输入笔记时,navigate将内容写入 URL 的 search 部分(replace: true不污染历史记录),因此复制 URL 到新标签页即可恢复笔记内容——这正是示例中 “Notes are stored in the URL” 的交互逻辑;
  • Linksearch函数式更新用于切换showNotes显隐,params: true保留当前路径参数。

生产运行与构建细节

生产模式下pnpm startNODE_ENV=production启动 dist/server/server.js,该构建产物由vite build --mode server生成。服务器此时不再加载 Vite 中间件,而是:

  1. express.static(path.resolve(__dirname, '../client'))托管静态资源;
  2. /trpc中间件继续提供 API;
  3. app.get('/{*splat}')对未知路径回退到index.html,保证前端路由(如直接访问/dashboard/posts/3)在刷新后仍能正确渲染。

整体请求流可概括为:浏览器请求页面 → Express 命中 SPA 兜底返回 HTML → 路由加载执行 loader →ensureQueryData通过/trpc批量请求数据填充 Query 缓存 → 组件渲染并复用缓存。得益于httpBatchLink,同一时间片的多个查询会自动合并为一次 HTTP 请求,减少网络往返。

扩展阅读

  • 本示例配套的with-trpc示例(无 React Query 版本)位于 examples/react/with-trpc,可对照理解 Options Proxy 模式带来的缓存与预取收益;
  • 若你的应用需要把服务端状态在 SSR 阶段同步给客户端,可参考 packages/react-router-ssr-query 与 packages/router-ssr-query-core 的源码实现;
  • 路由文件路由的自动生成与代码分割由 packages/router-plugin 与vite.config.ts中的tanstackRouter({ autoCodeSplitting: true })驱动;
  • 想了解 Router 上下文注入与 loader 执行时机,可阅读 packages/router-core/src 下路由匹配与 loader 相关实现(如route.tsrouter.ts)。

掌握这套tRPC + React Query + TanStack Router组合后,你将获得一条从数据库到 UI 全程类型安全的开发链路:服务端定义appRouter,客户端用createTRPCOptionsProxy零成本获得类型化查询函数,路由 loader 负责预取填缓存,组件只管消费缓存并保持 URL 状态同步。

【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询