基于 tRPC 的 SSG 静态生成实践:无 JavaScript 环境下预取并渲染 tRPC 查询数据

发布时间:2026/9/10 11:24:06
基于 tRPC 的 SSG 静态生成实践:无 JavaScript 环境下预取并渲染 tRPC 查询数据 基于 tRPC 的 SSG 静态生成实践无 JavaScript 环境下预取并渲染 tRPC 查询数据【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc本文以 tRPC 仓库中的 examples/.test/ssg 示例为研究对象。该示例演示了在 Next.js Pages Router 中配合 tRPC 做静态站点生成SSG在getStaticProps里同步执行 tRPC 查询并写入页面props最终产出的页面即使浏览器禁用 JavaScript 也能完整显示查询结果。示例同时配套了 Playwright 端到端测试与 Chrome DevTools 手动验证两条路径。示例定位一个“页面在无 JS 下也能工作”的 E2E 测试这个示例位于仓库的 examples/.test/ 目录下命名后缀为 E2E Test其目的不是做功能演示而是作为回归测试夹具验证 tRPC 在静态生成场景下的核心承诺预取prefetch的查询结果会被序列化进 HTML页面无需再发起任何网络请求即可渲染数据。原文examples/.test/ssg/README.md用一句话点明了整个示例的验收标准This example shows how you can prefetch queries. This page works without JavaScript enabled.对应的两个 Playwright 断言见 examples/.test/ssg/test/smoke.test.ts分别是test(query should be prefetched, async ({ page }) { await page.goto(/); // 页面静态生成、JavaScript 被禁用数据仍应立即可见 expect(await page.textContent(h1)).toBe(hello client); }); test(dates should be serialized, async ({ page }) { await page.goto(/); expect(await page.textContent(p)).toBe(Sat Jan 01 2022); });第二个用例的注释说得很直白“该测试本身并不真正测序列化但只要它通过就说明我们在 Date 上调用.toDateString()时没有报错”——即 superjson 正确还原了Date类型。项目结构一览整个示例的完整文件结构如下examples/.test/ssg/ ├── README.md ├── package.json # 脚本与依赖声明 ├── next.config.ts ├── playwright.config.ts # 浏览器默认禁用 JS ├── tsconfig.json # ~/* 路径别名指向 src/* ├── src/ │ ├── pages/ │ │ ├── _app.tsx # trpc.withTRPC 包裹根组件 │ │ ├── index.tsx # getStaticProps SSG 页面 │ │ └── api/trpc/[trpc].ts # Next.js API 路由处理器 │ ├── server/ │ │ ├── trpc.ts # initTRPC superjson 变压器 │ │ └── routers/_app.ts # appRouter 根路由定义 │ └── utils/ │ └── trpc.ts # createTRPCNext 客户端 └── test/ └── smoke.test.ts # 端到端冒烟测试页面渲染、预取逻辑、路由定义、API 处理器被拆分到四个典型文件中与 tRPC 官方推荐的项目组织方式一致server/放后端定义utils/放客户端封装pages/只负责数据获取与 UI 呈现。第一步定义一个返回 Date 的根路由后端入口 src/server/trpc.ts 使用initTRPC创建实例并启用superjson 数据转换器import { initTRPC } from trpc/server; import superjson from superjson; const t initTRPC.create({ transformer: superjson, // 使 Date 等非 JSON 类型可跨网络序列化 }); export const router t.router; export const publicProcedure t.procedure;路由定义src/server/routers/_app.ts包含一个带 zod 输入校验的greeting查询其返回值刻意包含一个Date实例import { z } from zod; import { publicProcedure, router } from ../trpc; export const appRouter router({ greeting: publicProcedure .input(z.object({ name: z.string() })) .query(({ input }) { return { text: hello ${input.name}, date: new Date(2022Z), }; }), }); export type AppRouter typeof appRouter;date: new Date(2022Z)即 2022-01-01T00:00:00Z是关键设计若使用 JSON 默认序列化Date会被转成字符串再还原成字符串前端调用.toDateString()会直接报错而 superjson 会把Date编码为带$date标记的结构从而在客户端还原为真正的Date对象。这正是 smoke 测试断言p标签文本为Sat Jan 01 2022的前提。第二步接入 API 处理器与 tRPC 客户端API 处理器src/pages/api/trpc/[trpc].ts是浏览器侧请求的入口这里通过createNextApiHandler将appRouter挂载到/api/trpcimport { createNextApiHandler } from trpc/server/adapters/next; import { appRouter } from ~/server/routers/_app; export default createNextApiHandler({ router: appRouter, createContext: () ({}), });注意tsconfig.json中~/*: [./src/*]的路径别名使~/server/routers/_app与../server/routers/_app指向同一文件。客户端封装src/utils/trpc.ts使用createTRPCNext链接为httpBatchLink支持请求批处理并显式声明ssr: falseimport { httpBatchLink } from trpc/client; import { createTRPCNext } from trpc/next; import type { AppRouter } from ~/server/routers/_app; import superjson from superjson; function getBaseUrl() { if (typeof window ! undefined) return ; if (process.env.VERCEL_URL) return https://${process.env.VERCEL_URL}; return http://localhost:${process.env.PORT ?? 3000}; } export const trpc createTRPCNextAppRouter({ config() { return { links: [ httpBatchLink({ url: getBaseUrl() /api/trpc, transformer: superjson, }), ], }; }, ssr: false, // 本示例采用 SSG 而非 SSR故关闭服务端渲染 transformer: superjson, });getBaseUrl区分浏览器空串表示同源相对路径与 Node 环境VERCEL_URL或 localhost 兜底这是 tRPC 官方示例的通用模式。ssr: false并非错误——本页的数据来自构建期静态生成而不是每次请求的服务端渲染因此无需开启 SSR 预取。根组件src/pages/_app.tsx用trpc.withTRPC包裹应用负责注入 QueryClientProvider 并消费页面props中携带的trpcStateimport type { AppType } from next/app; import { trpc } from ../utils/trpc; const MyApp: AppType (props) { return props.Component {...props.pageProps} /; }; export default trpc.withTRPC(MyApp);第三步核心——在 getStaticProps 中预取 tRPC 查询SSG 的关键代码位于 src/pages/index.tsximport { createServerSideHelpers } from trpc/react-query/server; import { appRouter } from ~/server/routers/_app; import { trpc } from ~/utils/trpc; import superjson from superjson; // 本页将被静态提供 export const getStaticProps async () { const ssg createServerSideHelpers({ router: appRouter, ctx: {}, transformer: superjson, }); await ssg.greeting.fetch({ name: client }); return { props: { trpcState: ssg.dehydrate(), }, revalidate: 1, }; }; export default function IndexPage() { const result trpc.greeting.useQuery({ name: client }); if (!result.data) { /* 不可达页面由静态文件提供服务 */ return div style{styles}h1Loading.../h1/div; } return ( div style{styles} h1{result.data.text}/h1 p{result.data.date.toDateString()}/p /div ); }这个流程可以拆成四个步骤理解createServerSideHelpers({ router, ctx, transformer })在服务端构造一套“辅助对象”它不需要网络直接调用路由内部的过程来执行查询ssg.greeting.fetch({ name: client })以完全类型安全的方式预取greeting查询并把结果写入其内部的 React Query 缓存等价于客户端查询键[greeting, { name: client }]ssg.dehydrate()将缓存脱水为可序列化状态作为trpcState写入propsNext.js 在构建/增量再验证时把它嵌入 HTMLrevalidate: 1开启 ISR增量静态再生成使得该静态页在最多 1 秒间隔后可根据最新数据重新生成。浏览器侧useQuery用相同的查询键读取客户端 hydrate水合时发现trpcState里已有缓存直接作为初始数据渲染——不再需要访问/api/trpc。因此注释 “unreachable, page is served statically”不可达页面由静态文件提供是成立的静态文件里已带完整数据客户端永远不该进入Loading...分支。从源码看 createServerSideHelpers 如何工作createServerSideHelpers的实现位于 packages/react-query/src/server/ssgProxy.tstrpc/react-query/server入口即从该文件导出packages/react-query/src/server/index.ts。从源码可以归纳出它的关键设计双模式解析createServerSideHelpers同时支持传入{ router, ctx }服务端直调或{ client }通过 tRPC 客户端远程调用。传入 router 时本示例即如此内部走callProcedure直接执行路由省去一次 HTTP 往返传入 client 时则退化为untypedClient.query(path, input)。递归 Proxyssg.greeting.fetch(...)这种“点路径式”调用由createRecursiveProxy实现运行时把greeting这样的路径与fetch/prefetch之类的工具方法拆开映射为对应查询键并调用queryClient的相应方法。默认脱水选项dehydrate()默认通过shouldDehydrateQuery过滤——pending尚未 settled的查询不进入脱水结果从而避免把未完成/出错状态误当成功数据传给客户端。序列化发生在最后脱水后的状态会再经过 transformer本示例为 superjson统一serialize同时剥离查询对象上的promise字段保证props可被 Next.js 安全序列化。这解释了为什么transformer: superjson必须在服务端 helpers、客户端 createTRPCNext、服务端 initTRPC三处保持一致查询数据要经历“写入缓存 → 脱水 serialize → JSON 进 HTML → 客户端 hydrate → 反序列化还原 Date”的完整往返任何一端 transformer 不匹配都会导致 Date 丢失或类型错乱。无 JS 场景验证手动流程与自动化等价物README 给出的手动验证步骤针对 Chrome DevTools按CMD SHIFT P打开命令面板搜索Disable JavaScript并回车刷新页面可以看到数据仍然被成功获取。启动服务的命令来自 examples/.test/ssg/package.jsonpnpm dev默认监听http://localhost:3000端口可通过PORT环境变量覆盖。页面渲染出h1hello client/h1与pSat Jan 01 2022/p——即便 DevTools 已禁用 JS两者依然存在。这一手动的“禁用 JavaScript”操作在示例中其实有自动化的等价物playwright.config.ts 里给所有测试统一设置了javaScriptEnabled: false并在 CI 下使用channel: chrome、重试 3 次use: { ...devices[Desktop Chrome], baseURL: baseUrl, javaScriptEnabled: false, // 浏览器默认禁用 JS channel: process.env.CI ? chrome : undefined, },也就是说smoke 测试test/smoke.test.ts始终运行在无 JS 的浏览器里从第一个像素起就在验证“数据已包含在静态 HTML 中”这一事实比手动的 DevTools 操作更加严格、可重复。运行 e2e 测试有两种方式package.json 的 scriptspnpm test-dev # start-server-and-test dev http://127.0.0.1:3000 test:e2e pnpm test-start # 构建后 start 生产服务器再跑测试两条脚本都用start-server-and-test先起服务、再执行playwright testtest-start额外验证了构建产物next build next start下的静态输出同样正确覆盖面更贴近真实部署。进阶注意客户端是否还要“重新拉取”静态页面虽然初始数据来自trpcState但 React Query 的默认行为是在客户端挂载与窗口重新聚焦时重新请求数据。如果你希望 SSG 页面彻底不做二次请求例如后端是限流的第三方 API就需要关闭这两个选项可全局配置于 utils/trpc.ts 的queryClientConfig.defaultOptions.queriesqueryClientConfig: { defaultOptions: { queries: { refetchOnMount: false, refetchOnWindowFocus: false, }, }, },也可在单个useQuery的第二个参数上局部覆盖如{ refetchOnMount: false, refetchOnWindowFocus: false }。但要注意如果应用里同时存在静态数据与强实时性动态数据全局限流会让动态查询也失去自动刷新能力需按场景权衡。仓库官方文档 www/docs/client/nextjs/pages-router/ssg.md 对上述要点有完整论述可作为扩展阅读其中动态路由页通常还需配合getStaticPaths预生成路径列表。小结通过这个 SSG E2E 示例可以确认 tRPC 静态生成的完整闭环构建期预取createServerSideHelpers在getStaticProps中类型安全地执行查询源码 packages/react-query/src/server/ssgProxy.ts 的 Proxy 直调callProcedure机制脱水注入dehydrate()序列化 React Query 缓存并随trpcState进入静态 HTML无 JS 可读浏览器禁用 JS 后页面仍渲染完整数据由 test/smoke.test.ts 在javaScriptEnabled: false的 Playwright 浏览器中自动断言hello client与日期文本Date 等非 JSON 类型依赖 superjson 在 server helpers、createTRPCNext、initTRPC三处的一致性配置。这套“静态生成 预取 脱水”的模式非常适合内容型页面、博客、文档站等对首屏速度与可用性要求高的场景——首屏 HTML 自带数据浏览器无需等待任何 RPC 请求甚至无需运行 JS 即可消费页面内容。【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考