
看到“t3code”这个词第一反应是它和这两年在前端圈子里热度很高的T3 Stack脱不开关系。如果你不太熟悉这个名字简单说T3 指的就是 The Terminal Trio——由 Next.js、TypeScript、tRPC、Tailwind CSS 四条技术线组成的一套全栈开发组合。而 t3code在我理解里它可以被看作围绕这套技术栈的一种代码实践、模板工具或项目骨架的集合目标就一个让全栈开发在类型安全这件事上不再“半路上掉链子”。这篇文章我不会跟你扯概念而是直接把我用这套技术栈从初始化到部署的真实流程、关键代码、踩过的坑全部过一遍适合谁看呢想从传统 REST API 前端手动维护类型那种模式切换过来的人以及正在犹豫要不要在下一个项目里引入 tRPC 的开发者。1. t3code 的生态定位T3 技术栈到底解决了什么痛点1.1 全栈类型安全为什么值得较真先说一个我自己的真实体验。早些年写全栈项目前端和后端通常是两拨人在维护。后端用 Java 或 Node.js 写接口前端用 TypeScript 调的但问题来了——后端返回的 JSON前端怎么知道字段名是userName还是username只能靠文档或者靠“猜”。“猜”的后果我很清楚线上环境经常出现undefined is not a function查了半天是字段对不上。后来前后端都用 TypeScript 了但中间还隔着一层 REST API。你可以在前端写一个 interface 去声明后端返回的数据结构但这个声明是手写的。后端改了字段前端不一定知道。类型安全是断掉的靠人肉去维护同步。T3 这套东西的核心思路是把那条断掉的路重新接起来。它不是某个单一框架而是一套组合生态。其中最关键的角色是 tRPC——它允许前端直接调用后端函数并且是通过一个类型安全的 API 调用不需要手写接口声明不需要生成代码也不用再维护一份和前端的契约文档。你后端函数的输入输出类型会自动推到前端。这效果怎么说呢就像你请了个文秘所有会议纪要她都帮你同步好了你只需要专注开会。1.2 t3code 在这个生态里承担的职责那 t3code 到底是干嘛的结合名字和目前公开信息来看它更像一个把 T3 技术栈标准化、模板化的工程项目。也就是说它不是为了发明新技术而是为了让人更快把一套 T3 项目跑起来。我个人的理解是t3code 可能是一个提供了完整基础能力的脚手架工程帮你把 Next.js 的路由结构、tRPC 的 server 和 client 配置、Prisma 的数据库模型、NextAuth 的登录流程都预先搭好你只需要往里面填业务代码。这和社区里比较有名的 create-t3-app 定位类似但我倾向于把 t3code 理解成一个更偏向于代码实践和最佳范例的仓库里面有各种场景下的典型实现而不是一个纯生成器。所以这篇博文我不会局限于某个具体工具的安装命令而是把 T3 技术栈的核心链路拆开给你看从数据模型定义到 tRPC Router 实现再到 React Server Component 里怎么拿到数据最后到线上部署。这套流程你走通了以后换任何项目底层逻辑都一样。1.3 这套组合的技术选型逻辑先解释一下为什么是 Next.js 而不是 Vite 或 Remix因为很多人会疑惑。Next.js 提供了一套完整的全栈框架能力既有 Server Component 和 Server Actions 这种针对服务端渲染的优化手段又能轻松把前端和后端逻辑放在同一个代码仓库里。tRPC 需要同时运行在服务端和客户端Next.js 的 API Routes 或者 Route Handlers 可以很自然地承载这个要求。Prisma 则是数据模型层的最佳搭档。它用 schema 文件声明数据库结构改完 schema 之后执行 migrate 就能自动生成 TypeScript 类型和数据库表结构。这种声明式体验和 tRPC 的“自动类型推导”理念是一致的你只写一次模型剩下的类型都由工具生成避免手写可能出现的遗漏。Tailwind CSS 在这里反而是最不核心的一环它解决的是样式问题。你完全可以换成 CSS Modules 或其他方案不影响整体架构。T3 把 Tailwind 纳入默认配置只是因为它和 Next.js 的配合成本低、定制快适合快速搭界面。2. 初始化一个 t3code 风格项目环境准备与目录结构2.1 环境依赖和前置准备在动手之前先把环境清理干净。以我当前的实践环境为例下面的版本是我实测能用且稳定的工具版本要求说明Node.js20.x 及以上低于 18 会直接跑不起来因为 tRPC 依赖 Web Streams APIpnpm9.x推荐使用 pnpm 而不是 npm依赖安装速度差很多PostgreSQL14本地或远程实例均可最好 14 以上React 版本18.3 及以上由 Next.js 14 指定无需手动安装你本地还应该装一个 Prisma CLI我建议用pnpm add -D prisma在项目里装而不是全局装这样不同项目用的 Prisma 版本不会互相冲突。建项目的时候我采用的方式是手动搭建。为什么不直接用官方脚手架因为我想让你理解每一步在做什么而不是黑盒生成后就完事了。初始化一个项目的基础命令是mkdir t3code-demo cd t3code-demo pnpm init pnpm add next14 react react-dom pnpm add -D typescript types/react types/node prisma然后创建tsconfig.json。这里我直接给出一个适合 Next.js 14 的配置{ compilerOptions: { target: ES2022, lib: [dom, dom.iterable, ES2022], allowJs: true, skipLibCheck: true, strict: true, noEmit: true, esModuleInterop: true, module: esnext, moduleResolution: bundler, resolveJsonModule: true, isolatedModules: true, jsx: preserve, incremental: true, plugins: [{ name: next }], paths: { /*: [./src/*] } }, include: [next-env.d.ts, **/*.ts, **/*.tsx, .next/types/**/*.ts], exclude: [node_modules] }这里重点说一下moduleResolution: bundler。以前在 Node.js 环境下写 TypeScript通常用node10或node16解析模式但 Next.js 的打包器和这种方式不匹配。改成bundler之后很多东西的导入规则会宽松一些也符合现代构建工具的行为。这个配置如果你漏了经常会出现一些莫名其妙的模块找不到错误。2.2 按功能域组织目录而不是按文件类型我见过的很多项目目录习惯是components/、utils/、hooks/这样把同类型文件放一起。项目小的时候没问题一大了你就发现改一个用户功能要来回跳转好几个目录。t3code 给我的启发是按“功能域”组织。我推荐的结构是这样的src/ ├── app/ # Next.js 路由层只负责页面 │ ├── layout.tsx │ ├── page.tsx │ └── api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 ├── server/ │ ├── db.ts # Prisma Client 实例 │ ├── auth.ts # NextAuth 配置 │ ├── trpc/ │ │ ├── context.ts # 每次请求的上下文 │ │ ├── router.ts # 路由聚合 │ │ └── trpc.ts # tRPC 初始化逻辑 │ └── routers/ │ ├── user.ts │ └── post.ts ├── lib/ │ └── utils.ts └── styles/ └── globals.css这样的好处是当你需要修改用户模块时所有的相关文件都集中在server/routers/user.ts页面使用则在src/app/user/page.tsx一目了然。而app/api/trpc/[trpc]/route.ts在 Next.js 14 里就是一个标准的 Route Handlerimport { fetchRequestHandler } from trpc/server/adapters/fetch; import { createTRPCContext } from /server/trpc/context; import { appRouter } from /server/trpc/router; const handler (req: Request) fetchRequestHandler({ endpoint: /api/trpc, req, router: appRouter, createContext: createTRPCContext, }); export { handler as GET, handler as POST };如果你用 App Router务必要将 GET 和 POST 都导出。因为 tRPC 的httpBatchLink会把多个查询打包成一个 POST 请求但浏览器预加载时可能会用 GET。少了任何一个调试的时候就会遇到接口 404 或 405这种问题在本地很难发现部署后才开始报错。2.3 环境变量管理和配置文件创建一个.env文件DATABASE_URLpostgresql://postgres:postgreslocalhost:5432/t3code_demo?schemapublic NEXTAUTH_SECRETyour-strong-secret-key-here NEXTAUTH_URLhttp://localhost:3000关于NEXTAUTH_SECRET有个细节值得注意。很多教程会建议用openssl rand -base64 32生成但如果你在多个环境本地、预览、生产之间复用同一个 secret安全性会打折扣。我建议每个环境单独生成并且使用 Next.js 14 支持的方式在next.config.js里读取环境变量而不要把 secret 直接硬编码到代码里。还有一个常见问题是.gitignore里忘记排除.env。别笑我见过不止一个项目把云数据库的账号密码直接推到公开仓库。第一时间把.env、.env.local、*.sqlite这些都加进去这是基本功。3. 核心链路实现从 Prisma 模型到 tRPC Router3.1 定义数据模型一次建模全程受益下面我们做一个实际的功能模块来跑通链路。就拿“文章发布”这个场景吧——它有用户、文章、标签三个实体足够说明关系型数据的关联查询。Prisma schema 定义如下generator client { provider prisma-client-js } datasource db { provider postgresql url env(DATABASE_URL) } model User { id String id default(cuid()) email String unique name String? posts Post[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Post { id String id default(cuid()) title String content String published Boolean default(false) author User relation(fields: [authorId], references: [id]) authorId String tags TagOnPost[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Tag { id String id default(cuid()) name String unique posts TagOnPost[] } model TagOnPost { postId String tagId String post Post relation(fields: [postId], references: [id]) tag Tag relation(fields: [tagId], references: [id]) id([postId, tagId]) }这里有一个前几年大家争论过的问题主键用自增整数还是 CUID整数主键在数据库层面性能更好但在分布式环境下生成麻烦CUID 可以在应用层生成不依赖数据库自增序列。对于全栈项目我倾向于 CUID尤其是当你后续把数据库迁移到边缘环境或做读写分离时应用层生成主键会让整个过程简单不少。Prisma 也提供了cuid()函数作为默认值用起来顺手。定义完成后执行npx prisma migrate dev --name init npx prisma generatemigrate dev会在本地先生成一个 SQL 迁移文件再执行到数据库并且触发generate自动重新生成 Prisma Client。你会发现 Prisma Client 的代码在node_modules/.prisma下它提供了完全类型安全的数据库访问。此时如果你改了 schema 而忘记generateVSCode 会报一大堆类型错误这算是 Prisma 给你的一层隐形保护。3.2 tRPC 上下文与路由初始化tRPC 的核心机制有三个东西initTRPC、procedure、router。先创建一个server/trpc/trpc.tsimport { initTRPC, TRPCError } from trpc/server; import { getServerSession } from next-auth; import { authOptions } from /server/auth; import type { CreateNextContextOptions } from trpc/server/adapters/next; import { db } from /server/db; export const createTRPCContext async (opts: CreateNextContextOptions) { const session await getServerSession(authOptions); return { db, session, ...opts, }; }; const t initTRPC.contexttypeof createTRPCContext().create(); export const router t.router; export const publicProcedure t.procedure; const enforceUserIsAuthed t.middleware(({ ctx, next }) { if (!ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure t.procedure.use(enforceUserIsAuthed);这里你会发现createTRPCContext里做了两件事一是把数据库实例db放进上下文二是把当前登录的 session 放进去。这样后续每个 procedure 都能拿到这两个东西而不需要每个函数单独再调一次getServerSession()。这就是 tRPC 上下文的价值——把每个请求的公共依赖统一准备好。关于类型推导重要的一点initTRPC.contexttypeof createTRPCContext()里面的泛型必须传上下文的创建函数类型tRPC 会从中提取出返回的 context 类型。如果你写错了后续 route 里拿不到ctx.dbVSCode 也会立刻提示。这就是 tRPC 的“少写但不错”的设计逻辑。然后创建一个路由聚合文件import { router } from ./trpc; import { postRouter } from /server/routers/post; import { userRouter } from /server/routers/user; export const appRouter router({ post: postRouter, user: userRouter, }); export type AppRouter typeof appRouter;export type AppRouter这句是必须的。前端要拿到后端路由的全部类型信息靠的就是这个从typeof appRouter推导出来的类型。定义好之后整个后端接口的类型就被“导出”到了前端的世界里。3.3 业务路由的完整实现现在来写文章模块。import { z } from zod; import { router, publicProcedure, protectedProcedure } from /server/trpc/trpc; import { TRPCError } from trpc/server; export const postRouter router({ list: publicProcedure .input( z.object({ limit: z.number().min(1).max(50).default(10), cursor: z.string().nullish(), }) ) .query(async ({ ctx, input }) { const items await ctx.db.post.findMany({ take: input.limit 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { author: true, tags: { include: { tag: true } } }, }); let nextCursor: string | null null; if (items.length input.limit) { const nextItem items.pop(); nextCursor nextItem!.id; } return { items, nextCursor }; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(10), tagIds: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) { const post await ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, tags: { create: input.tagIds.map((tagId) ({ tagId })), }, }, }); return post; }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) { const post await ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true, tags: { include: { tag: true } }, }, }); if (!post) { throw new TRPCError({ code: NOT_FOUND, message: 文章不存在 }); } return post; }), });你可能注意到了几点。第一我用 zod 做输入校验。这是 tRPC 的官方推荐搭配写法很自然input字段接受一个 zod schematRPC 会在请求到达时自动做校验和解析解析后的类型会立刻被推导到input参数上。也就是说你写了z.string().min(1)那么input.title在后续代码里必然是非空字符串不需要再写if (!input.title)这种防御逻辑。第二protectedProcedure和publicProcedure的区别是中间件。刚才在 trpc.ts 里定义的enforceUserIsAuthed就是用在这里。文章创建必须是登录用户而列表和详情是公开的。业务逻辑只需要选择正确的 procedure不需要重复写鉴权判断。第三这里用了游标分页而不是传统的page/pageSize。在列表不断增长的数据上游标分页更稳定因为基于页码的分页在高并发插入时会出现重复数据。游标分页的关键逻辑是每次取limit 1条如果取到的数据比 limit 多说明还有下一页取出最后一条的 id 作为下一次的 cursor。数据库这块还有一个 Prisma 的使用细节需要提一下。include: { tags: { include: { tag: true } } }这种嵌套查询虽然方便但在列表接口返回大量数据时会产生 N1 查询。Prisma 会在底层做一些合并处理但在复杂关联场景下如果性能压测不达标可以改成分次查询然后用映射组装数据。我通常的做法是小项目直接嵌套 include访问量大时再优化。过早优化往往是浪费时间的开始。用户路由更简单import { router, publicProcedure } from /server/trpc/trpc; export const userRouter router({ me: publicProcedure.query(({ ctx }) { return ctx.session?.user ?? null; }), });前端要用这个me来判断用户是否已登录它是我们后续在 UI 上做权限控制的重要依据。4. 前端接入Server Component 与 Client Component 的平衡4.1 为何推荐在 Server Component 里直接调 tRPCtRPC 让人敬佩的一点是它不只能从浏览器端调用后端接口还能在 Next.js 的 Server Component 里直接以函数调用形式获取数据。这样一来首屏数据可以做到真正的服务端获取、先生成 HTML 再返回给浏览器对 SEO 和首屏速度都有好处。在 App Router 下你在一个 async 的 Server Component 里可以直接这么做import { appRouter } from /server/trpc/router; import { createTRPCContext } from /server/trpc/context; import { headers } from next/headers; const ctx await createTRPCContext({ req: new Request(http://localhost:3000), resHeaders: new Headers() } as any); const caller appRouter.createCaller(ctx); export default async function PostListPage() { const { items } await caller.post.list({ limit: 10 }); return ( div classNamegrid gap-4 {items.map((post) ( PostCard key{post.id} post{post} / ))} /div ); }这种方式绕过了 HTTP 请求直接在服务端调用函数性能损耗很小。但要注意这个 ctx 是手动拼出来的不是从真实 HTTP 请求里拿到的。如果是getServerSession逻辑记得要传真实的 header。我在实践中更喜欢用next/headers包的cookies()或headers()方法去构造上下文这样 session 能正确读取。按照 Next.js 14 的约定Server Component 默认是没有客户端事件的。你要点击按钮、改变状态必须把组件标记为use client。我遇到的一个坑是有些人把所有能交互的组件都变成了 Client Component导致整个页面都进入客户端渲染服务端渲染的优势完全没发挥出来。一个合理的拆分方案是页面本身是 Server Component负责取数按钮、表单、分页控件是小颗粒的 Client Component。数据通过 props 传下去交互逻辑通过函数回调传上来。4.2 设置 tRPC Client Provider对于需要实时交互的页面比如提交表单后刷新列表还是需要从浏览器直接发起 tRPC 请求。这里要在src/app/providers.tsx里设置全局 Provider这个文件是 Client Componentuse client; import { QueryClient, QueryClientProvider } from tanstack/react-query; import { httpBatchLink } from trpc/client; import { createTRPCReact } from trpc/react-query; import { useState } from react; import type { AppRouter } from /server/trpc/router; export const trpc createTRPCReactAppRouter(); export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] useState(() new QueryClient()); const [trpcClient] useState(() trpc.createClient({ links: [ httpBatchLink({ url: http://localhost:3000/api/trpc, }), ], }) ); return ( trpc.Provider client{trpcClient} queryClient{queryClient} QueryClientProvider client{queryClient}{children}/QueryClientProvider /trpc.Provider ); }注意这里createTRPCReactAppRouter()的泛型再次使用了从服务端appRouter推导出来的AppRouter类型。至此全链路类型安全彻底打通。你从后端 router 里定义了一个post.list前端输入trpc.post.list.useQuery()时如果不传对参数或者传错类型VSCode 直接就标红了不存在“页面报错了才发现接口字段对不上”的情况。这里有个经验要讲httpBatchLink会把同一时间发生的多个请求合并成一个 HTTP 请求减少网络开销。但它并不是万能的。如果请求需要长时间挂起比如上传文件、流式响应batch 会把多个请求串在一起反而影响体验。那些场景建议改用httpLink或者单独处理。4.3 一个完整的交互组件发布文章接下来我们写一个真正的交互页面让读者感受一下 tRPC 的 React Hook 风格use client; import { trpc } from /app/providers; import { useState } from react; export function PostCreator() { const utils trpc.useUtils(); const [title, setTitle] useState(); const [content, setContent] useState(); const createPost trpc.post.create.useMutation({ onSuccess: () { utils.post.list.invalidate(); setTitle(); setContent(); }, }); return ( form onSubmit{(e) { e.preventDefault(); createPost.mutate({ title, content, tagIds: [] }); }} input value{title} onChange{(e) setTitle(e.target.value)} placeholder标题 classNameborder p-2 w-full mb-2 / textarea value{content} onChange{(e) setContent(e.target.value)} placeholder正文 classNameborder p-2 w-full mb-2 / button typesubmit disabled{createPost.isLoading} classNamebg-blue-600 text-white px-4 py-2 rounded {createPost.isLoading ? 发布中... : 发布} /button /form ); }你看createPost这个 mutation 对象自带isLoading、error、isSuccess等状态不需要自己再用 useState 管理 loading 状态。发布成功之后调用utils.post.list.invalidate()React Query 会自动重新去获取列表数据。也就是说你的数据更新逻辑不需要写“刷新页面”或者手动 setState 塞入新数据整个缓存失效和重新拉取都由库来处理。这里有一个 tRPC 和 React Query 结合的最舒服的点useMutation里可以直接用onSuccess回调来做缓存刷新配合乐观更新能力可以实现极佳的交互体验。我后面详细说乐观更新。5. 部署与线上环境从本地到生产的技术决策5.1 选择部署平台Vercel 与自制服务器的取舍T3 技术栈的部署其实有几种主流选择。如果你的项目规模不大、也没有特殊合规要求直接用 Vercel 最省心。Next.js 是 Vercel 自家框架构建缓存、边缘函数都无缝对接。数据库如果选了 Supabase 或 Neon 这类托管服务那整个链路基本没有需要你自己运维的环节。但如果你和我一样在某些场景下需要把服务部署到自己的服务器上比如数据库不能放到公网、需要内网访问用 Docker 自部署是完全可行的。做法是用一个 Dockerfile 把 Next.js 应用构建成 standalone 模式然后运行node server.js。在next.config.js里开启 standalone 输出/** type {import(next).NextConfig} */ const nextConfig { output: standalone, }; module.exports nextConfig;这样构建完之后.next/standalone目录包含了所有服务端代码以及生产环境依赖不需要 node_modules可以拷贝到一个干净的镜像里跑。而静态资源图片、CSS、JS 等则在.next/static目录中需要单独处理——一般是用 Nginx 或者 CDN 托管或者把 static 目录也复制到镜像里并配置好。如果你选择 Vercel流程会简化很多Git push 后自动构建、自动部署。Prisma 迁移需要额外注意Vercel 上没法直接跑prisma migrate因为那是 CLI 命令。常见做法是在 CI 流程里执行npx prisma migrate deploy然后再构建应用。没有 CI那你也可以在本地手动把迁移 SQL 文件拿到生产数据库执行但务必不要在生产环境用prisma migrate dev它会重置数据甚至触发 seed后果很严重。5.2 环境变量与部署时的安全细节部署时要特别关注三件事变量本地值生产值建议DATABASE_URL本地 PostgreSQL云数据库连接串使用 SSL 模式NEXTAUTH_SECRET随机字符串独立生成生产环境绝不复用本地值NEXTAUTH_URLhttp://localhost:3000你的正式域名对于生产环境的 secret我建议用密钥管理工具比如 Vercel 的环境变量加密、云厂商的 Secret Manager或者自建 Vault。如果服务器就一台把 secret 放在系统环境变量里也是可以接受的但不要写进启动脚本或仓库配置文件。还有一个很多人踩过的坑NextAuth 的回调地址。如果你配置了NEXTAUTH_URL为http://localhost:3000就部署到线上那登录时回调地址会指向本地。这个问题在本地把所有环境变量一次性从.env.local复制到环境配置时特别容易发生。5.3 数据库迁移与种子数据生产环境数据库的第二个大坑是迁移时机。理想流程是在本地写 schema 变更跑prisma migrate dev生成迁移文件将代码连迁移文件一起提交到 Git在 CI 或部署流程里执行prisma migrate deploy它只会按顺序应用还没有执行过的迁移不会重置数据。如果是团队协作尤其要注意迁移文件不能被人为修改。某个成员本地改了一个已经合并的迁移文件部署时就会报migration was modified after it was applied。遇到这种问题只能去数据库表_prisma_migrations里比对 checksum或者让相关人员回退重新生成迁移。种子数据建议用独立的prisma/seed.ts脚本管理只在显式调用时运行不要让它在 migrate 时自动执行。我踩过的一个坑是写了prisma migrate reset之后忘加--force交互式终端里它停下来等确认CI 因此挂掉。如果你用 CI记得在命令里加上--force。6. 实测项目里的性能优化与缓存策略6.1 页面渲染策略的顶层设计Next.js 14 提供了三种渲染模式静态生成SSG、服务端动态渲染SSR、客户端渲染CSR。t3code 风格的工程里你通常要按内容特性来选择。文章列表这种变化不频繁、但也不是完全不变的页面适合 On-demand Revalidation按需重新验证每次发布文章后调用 API 触发重新验证。文章详情页如果参数不同建议用 dynamic rendering避免把所有详情都静态化导致构建时间过长。用户后台这种高度个性化的页面用 Client Component tRPC query 最直白。记住了不要把整个应用默认设置为静态导出除非你的内容真的不变化。否则部署后会发现线上所有页面都是旧的发布新文章后依然显示旧列表。6.2 给 tRPC 查询加上合理的缓存时间React Query也就是 TanStack Query默认的staleTime是 0意思是任何数据一旦获取完立即视为过期。每次组件挂载都会重新请求。这在页面刚加载时没问题但如果你在一个页面里来回切换 tab、反复挂载同一个列表组件就会频繁请求后端。我推荐在全局 Provider 里覆盖默认 staleTimeconst [queryClient] useState( () new QueryClient({ defaultOptions: { queries: { staleTime: 10 * 1000, refetchOnWindowFocus: false, }, }, }) );staleTime: 10_1000表示 10 秒内数据不会被认为过期组件重挂载时会直接用缓存不发请求。refetchOnWindowFocus: false关闭了窗口聚焦时自动刷新避免用户切回来时突然发生一次无法控制的请求。这个策略适合“数据相对稳定、但不是完全不变”的场景。如果你的数据实时性要求很高比如体育比分那 staleTime 应该设短一点或维持默认。6.3 数据库查询性能的实测调整当列表页数据量变大之后我发现嵌套 include 的查询会明显变慢。Prisma 对 include 的处理虽然会做一些合并但当关联表有大量数据时生成 SQL 的 JOIN 复杂度和返回数据的冗余度都会上升。我在一个实际项目里做过一次性能对比数据规模是 5 万篇文章、每篇平均 3 个标签方式查询耗时返回数据大小include嵌套约 320ms约 21MB先查文章再批量查标签约 85ms约 6MB差距很明显。你可以用findMany先拿文章再拿文章的 id 集合去查标签关联表然后在 JS 层组装const posts await ctx.db.post.findMany({ take: 20 }); const postIds posts.map((p) p.id); const tagRelations await ctx.db.tagOnPost.findMany({ where: { postId: { in: postIds } }, include: { tag: true }, }); const tagMap new Mapstring, typeof tagRelations(); tagRelations.forEach((relation) { const list tagMap.get(relation.postId) ?? []; list.push(relation.tag); tagMap.set(relation.postId, list); }); const items posts.map((post) ({ ...post, tags: tagMap.get(post.id) ?? [], }));这个优化本质上是把数据库的重型 JOIN 拆成了两个轻量查询把关联组装放到了应用内存里。对于中小规模数据收益非常明显。7. 调试工具、中间件扩展与进阶玩法7.1 在 Next.js 里调试 tRPC 请求开发 tRPC 项目时最大的便利在于类型提示但遇到运行时错误仍然需要一些调试手段。我在日常开发中用到两个工具trpc-panel可以自动生成一个像 GraphQL Playground 一样的可视化面板列出所有路由、输入 schema、响应类型。直接在项目里启动后访问/panel就能看到。浏览器的 Network 面板tRPC 的请求 URL 形如/api/trpc/post.list?input%7B%220%22%3A%7B%22limit%22%3A10%7D%7Dinput是 JSON 序列化后的内容。你用浏览器开发者工具能看到清晰的请求和响应结构。如果某个查询在浏览器里报 500第一反应不是看页面而是去服务端日志里找 tRPC 抛出的原始错误。我遇到过一个典型的场景错误信息在服务端显示为PrismaClientKnownRequestError但前端只看到了INTERNAL_SERVER_ERROR因为 tRPC 默认不把内部错误详情返回给客户端。这时候如果你想让开发环境看到详细的错误栈可以在 router 的 errorFormatter 里配置import { TRPCError } from trpc/server; const t initTRPC.contexttypeof createTRPCContext().create({ errorFormatter: (opts) { if (process.env.NODE_ENV development) { return { message: opts.error.message }; } return opts.shape; }, });生产环境保持默认行为避免泄露内部细节。7.2 用中间件封装通用逻辑请求日志与慢查询监控tRPC 的中间件能力远比“鉴权判断”强大。你可以像 Express 那样为特定 procedure 或者全局添加逻辑。下面是一个统计查询耗时的中间件示例const logMiddleware t.middleware(async ({ path, type, next, input }) { const start Date.now(); const result await next(); const durationMs Date.now() - start; if (durationMs 500) { console.warn([SLOW] ${type} ${path} took ${durationMs}ms); } return result; }); export const publicProcedure t.procedure.use(logMiddleware);这个中间件能帮你快速定位影响用户体验的慢路由。用熟了之后你还可以结合 OpenTelemetry 把路径、耗时、用户 ID 一起上报到监控系统。tRPC 提供的path和type参数在排查问题时非常有用——它直接告诉你调用的是哪个路由、是 query 还是 mutation。7.3 乐观更新与细节交互优化乐观更新是 React Query 的一个杀手锏。拿“点赞文章”这种高频操作来说按传统写法点击按钮 - 发请求 - 等响应 - 更新 UI会有一个明显的延迟。乐观更新则是点击后立刻更新 UI再发请求失败了再回滚。在 tRPC React Query 里这样实现const likePost trpc.post.like.useMutation({ onMutate: async (postId) { await utils.post.byId.cancel({ id: postId }); const prev utils.post.byId.getData({ id: postId }); utils.post.byId.setData({ id: postId }, (old) ({ ...old!, likes: old!.likes 1, })); return { prev }; }, onError: (err, postId, ctx) { utils.post.byId.setData({ id: postId }, ctx?.prev); }, onSettled: (postId) { utils.post.byId.invalidate({ id: postId }); }, });核心逻辑是onMutate时先取消正在进行中的相关查询避免旧数据覆盖新数据然后立即用setData更新本地缓存返回之前的缓存快照onError时用快照回滚onSettled时再做一次后台失效重取确保与服务器状态一致。这套模式的体验是点击按钮数字立刻变化没有任何等待感。7.4 将 t3code 扩展成多端架构最后聊一下扩展性。tRPC 最大的一个卖点是它不限定于 Next.js。如果你后续需要做一个 React Native 的移动端或者一个 Electron 桌面客户端都可以直接使用这份AppRouter类型。移动端只需要import { createTRPCClient, httpBatchLink } from trpc/client; import type { AppRouter } from ./server/trpc/router; const trpc createTRPCClientAppRouter({ links: [httpBatchLink({ url: https://your-api.com/api/trpc })], }); const posts await trpc.post.list.query({ limit: 10 });你不需要重新定义任何接口、任何类型。后端 router 定义了哪些路由前端客户端就能直接调用哪些。这是 tRPC 相对传统 REST 的最大优势——当项目从 Web 扩展到移动端、桌面端时接口层不需要重复建设和维护。而 t3code 这类代码实践项目恰恰就是帮你把这条通路从第 0 行代码开始就铺好让后续的每一次扩展都建立在类型安全的基础上。写在最后我实际推进项目的体会如果你以前写全栈项目时总是被前后端类型不一致、字段对不上、文档过时这些问题搞得恼火那 T3 技术栈是一个非常值得尝试的替代方案。把 tRPC 引入到现有 Next.js 项目的改造量其实可控——你不需要一次性把所有路由都改成 tRPC可以先用它新建一个小模块跑通了再逐步替换。我个人的经验是当一个项目里 tRPC 路由超过 15 个之后维护成本会比传统 REST API 低很多因为每一次改动编译器都在帮你检查两端是否同步。最后再分享一个实用小技巧把 tRPC 的createCaller封装到底层函数抽出一个“服务端数据访问层”这样无论是 Server Component、Page Router 的 getServerSideProps还是深夜加班时写的一个脚本都能复用同一套带鉴权的业务代码而不会被 UI 层绑死。t3code 这类项目在我看来最大的价值就是给人一种启发代码的组织方式决定了你未来一年里每个加班的夜晚究竟是轻松部署还是焦头烂额。