基于 tRPC 的 SSG 静态生成实践:无 JavaScript 环境下预取并渲染 tRPC 查询数据
2026/9/10 11:23:56 网站建设 项目流程

基于 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)是浏览器侧请求的入口,这里通过createNextApiHandlerappRouter挂载到/api/trpc

import { 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: false

import { 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 = createTRPCNext<AppRouter>({ 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中携带的trpcState

import 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.tsx:

import { 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}><h1>Loading...</h1></div>; } return ( <div style={styles}> <h1>{result.data.text}</h1> <p>{result.data.date.toDateString()}</p> </div> ); }

这个流程可以拆成四个步骤理解:

  1. createServerSideHelpers({ router, ctx, transformer })在服务端构造一套“辅助对象”,它不需要网络,直接调用路由内部的过程来执行查询;
  2. ssg.greeting.fetch({ name: 'client' })以完全类型安全的方式预取greeting查询,并把结果写入其内部的 React Query 缓存(等价于客户端查询键['greeting', { name: 'client' }]);
  3. ssg.dehydrate()将缓存脱水为可序列化状态,作为trpcState写入props;Next.js 在构建/增量再验证时把它嵌入 HTML;
  4. revalidate: 1开启 ISR(增量静态再生成),使得该静态页在最多 1 秒间隔后可根据最新数据重新生成。

浏览器侧,useQuery用相同的查询键读取,客户端 hydrate(水合)时发现trpcState里已有缓存,直接作为初始数据渲染——不再需要访问/api/trpc。因此注释 “unreachable, page is served statically”(不可达,页面由静态文件提供)是成立的:静态文件里已带完整数据,客户端永远不该进入Loading...分支。

从源码看 createServerSideHelpers 如何工作

createServerSideHelpers的实现位于 packages/react-query/src/server/ssgProxy.ts,@trpc/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:

  1. CMD + SHIFT + P打开命令面板;
  2. 搜索Disable JavaScript并回车;
  3. 刷新页面,可以看到数据仍然被成功获取。

启动服务的命令(来自 examples/.test/ssg/package.json):

pnpm dev

默认监听http://localhost:3000(端口可通过PORT环境变量覆盖)。页面渲染出<h1>hello client</h1><p>Sat 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 的 scripts):

pnpm 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.queries

queryClientConfig: { defaultOptions: { queries: { refetchOnMount: false, refetchOnWindowFocus: false, }, }, },

也可在单个useQuery的第二个参数上局部覆盖(如{ refetchOnMount: false, refetchOnWindowFocus: false })。但要注意:如果应用里同时存在静态数据与强实时性动态数据,全局限流会让动态查询也失去自动刷新能力,需按场景权衡。仓库官方文档 www/docs/client/nextjs/pages-router/ssg.md 对上述要点有完整论述,可作为扩展阅读;其中动态路由页通常还需配合getStaticPaths预生成路径列表。

小结

通过这个 SSG E2E 示例可以确认 tRPC 静态生成的完整闭环:

  • 构建期预取createServerSideHelpersgetStaticProps中类型安全地执行查询(源码 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、createTRPCNextinitTRPC三处的一致性配置。

这套“静态生成 + 预取 + 脱水”的模式非常适合内容型页面、博客、文档站等对首屏速度与可用性要求高的场景——首屏 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),仅供参考

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

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

立即咨询