ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

T3 Stack全栈实践:tRPC与Prisma打通端到端类型安全

T3 Stack全栈实践:tRPC与Prisma打通端到端类型安全

最近在折腾全栈项目的时候,我把研究重点放在了 t3code 这个代号下的整套技术组合上。如果你也关注过 Next.js 生态,T3 Stack 这个名字多半不陌生,它把 Next.js、TypeScript、tRPC、Prisma、Tailwind CSS、NextAuth 这六样东西打包成一套开箱即用的全栈方案。我最初以为它不过又是一个脚手架模板,实际用下来发现,这个技术栈最大的价值不是省掉配置时间,而是从数据模型到页面组件,类型安全能一路贯通到底,彻底解决了我以前在前后端对接时反复对接口文档、手动维护类型的痛点。

这篇文章不是官方文档的翻译,而是我从初始化项目到上生产环境整个过程中,踩过的坑、验证过的方案、以及拆开看过的每一条关键链路。适合那些准备用或正在用 T3 Stack 写真实项目的开发者,也适合还没入坑、想搞清楚这一套东西到底值不值得学的朋友。

1. T3 三原则与它真正想解决的"全栈类型割裂"问题

1.1 为什么 T3 把"类型安全"放在不容商量的位置

我在接触 T3 之前,前后端分离的项目做了不少,最头疼的永远是那个"类型断层":后端定义好接口返回结构,前端为了拿到类型,要么手写 interface,要么靠 openapi 代码生成,但一遇到接口改字段,前端必然有一批类型过时,然后运行时不报错、业务逻辑悄悄出错。T3 的三原则里第一条就是:类型安全是底线,不可妥协。这不是一句口号,而是通过 tRPC 在协议层把类型直接变成了确定的约束,让类型安全不依赖文档维护、不靠人肉同步。

tRPC 的思路其实很朴实:既然前端代码和后端代码最终都在同一个 monorepo 里,为什么不能直接调用一个函数,而是要去拼 URL、发请求、再手动解析响应?它让前端像调用本地函数一样调用后端的 Procedure,而 TypeScript 的类型推导在编译期就把两端的协议对齐了。你可以这样理解:传统 REST API 像在不同城市之间寄包裹,需要写清楚地址和物品清单;tRPC 则像一个内部传送带,包裹沿着管道直接送到对面,物品清单就在代码里自动校验。

1.2 T3 三原则中的"保守优化"与"会扩展吗"

第二条原则"保守优化"的意思是:默认不引入复杂抽象,不追求所谓的高性能预热,能用最简单的方式解决就不用重型方案。我把这套原则翻译成一句大白话:先用最简单的方式把功能做出来,等数据验证了瓶颈再上优化手段。第三条"功能性优先"其实问的是:这个设计到底是为了面试和晒朋友圈,还是为了上线后扛住真实流量?这直接决定了技术选型时该不该引入消息队列、该不该拆微服务。

我见过不少团队把 T3 误解为"只要用了就高级",结果在只有几千日活的时候引入了 kafka、redis 集群、k8s,运维成本直接压垮交付速度。T3 的思路恰恰相反:在 Next.js 的 serverless 环境里,一个 Prisma 加 Postgres 就能跑得很稳,等真到了需要异步处理的场景,再渐进式引入队列系统也不迟。

1.3 哪些项目适合以及不适合用 T3

我试用三个月后,对适用场景有了比较明确的判断。适合的典型场景包括:需要快速交付的 Web 应用、内部后台系统、SaaS 产品的 MVP、以及小团队从零起步但希望保持代码质量的项目。T3 的高度集成能显著减少"来回对类型"的时间成本,开发体验非常顺滑。

不适合的场景也真实存在:如果你的项目需要面向第三方开放 API,或者有复杂的权限体系和多租户隔离,或者团队里有后端同学坚持用 Java/Go 提供微服务,那 T3 的"端到端类型贯通"优势就发挥不出来。毕竟类型安全这种东西是闭环内的红利,一旦跳出闭环,还是要走 OpenAPI 那套老路。

提示:选型时不要被"技术栈酷炫"迷惑,先回答一个朴素问题:你的前后端是不是真的由一个团队甚至一个人来写?如果是,T3 的收益是肉眼可见的。如果不是,T3 的价值会大打折扣。

2. 初始化脚手架:create-t3-app 每个选项都不是白给的

2.1 交互式选项逐项分析:选与不选的标准

创建一个 T3 项目非常简单,一条命令就能跑:

pnpm create t3-app@latest

但脚手架里的每个交互式选择都值得停下来想清楚,因为它们决定了项目之后的基本架构。我实际选过的组合和理由如下:

选项我选择的答案选择理由
Next.js 版本App Router新项目默认走 App Router,Server Components 配合 tRPC 的 server caller 效果很好
TypeScript是没有 TypeScript 的 T3 等于失去灵魂
Tailwind CSS是用它做样式,写页面速度确实快,且不容易和组件库起冲突
tRPC是不需要犹豫,这正是 T3 的核心
Prisma是数据库 ORM 里和 TypeScript 配合最顺的选项之一
NextAuth.js是登录认证直接和 tRPC 中间件联动
ESLint / Prettier是省得后面再补,团队协作时能少很多无谓争吵
Docker按需本地开发如果已经用 Docker Compose 跑 Postgres 就选是
环境变量校验是防止 DATABASE_URL 写错导致线上启动失败

如果你只是先跑通一下,不想被这些选项搞乱,直接全部选"是"也不会出问题。脚手架默认会生成一套可以立刻运行的最小代码。

2.2 初始化后的目录结构:每个关键文件的职责

初始化完成后,你会看到一套组织得非常清晰的目录。我认为理解下面这几个文件,是后面所有调试工作的基础:

src/ ├── app/ │ ├── api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 │ ├── layout.tsx │ └── page.tsx ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts # 示例业务路由 │ │ │ └── root.ts # 路由聚合根 │ │ ├── trpc.ts # tRPC 实例、公共/受保护 Procedure │ │ └── root.ts # 创建上下文、所有 router 汇总 │ └── db.ts # Prisma Client 单例 ├── trpc/ │ ├── react.tsx # 前端 API Provider 封装 │ └── server.ts # 服务端 caller 封装 └── env.js # 环境变量校验

我第一次看到[trpc]/route.ts时其实是懵的,后来才搞明白:在 App Router 下,tRPC 需要一个 HTTP 端点将所有 Procedure 请求集中起来,而这个文件就是那个统一的出口。浏览器端发起请求时,请求会汇聚到这个路由上,再由它分发到对应的 router 和 procedure。

src/server/api/trpc.ts更重要,因为所有的中间件、公共查询、受保护查询都在这里定义。项目刚生成时自带的示例代码已经给出了publicProcedure和protectedProcedure两个基本款,理解它们的区别几乎等于理解了 T3 的鉴权模型。

2.3 我建议先删掉再重写的文件

脚本生成的项目里带着示例的postrouter 和页面代码。真实项目里这些示例基本用不上,我的建议是:动手写第一个真实功能之前,先把routers/post.ts删掉,把首页的示例逻辑清空,然后自己在root.ts里注册第一个业务 router。这样做不是为了干净,而是为了强迫自己走一遍完整的 router 注册和调用链路。很多人在脚手架示例上跑通了,但换到自己的业务时却连路由都注册不上,问题往往就出在没理解"router 必须注册到 root 之后前端才有对应的 hooks 可用"这一环。

3. tRPC 调用链路拆解:从 zod schema 到前端 useQuery 的全程类型贯通

3.1 核心概念:Procedure、Router 和 Context

tRPC 有三个核心概念,理解了它们就能读懂几乎所有代码。Procedure 是最小的可调用单元,相当于一个接口;Router 是 Procedure 的集合,相当于一个控制器;Context 是所有 Procedure 都能访问的共享数据,通常包含数据库实例和 session 信息。

创建 tRPC 实例时,最关键的代码是createTRPCContext,它负责把每个请求的上下文组装好。默认实现里它会读取当前请求的 session,然后连同数据库实例一起塞给所有 Procedure。换句话说,Procedure 里拿到的ctx.db和ctx.session,就是从这里来的。

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; import { getServerSession } from "next-auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerSession(); return { db, session, ...opts }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;

可以看到,db和session都被注入到了 context 里。后续每一个 procedure 里不需要重新创建数据库连接,也不需要重新获取 session,这既是性能考虑,也是统一鉴权的基础。

3.2 一个完整的 CRUD 示例:从服务端到客户端的调用

下面我用一个帖子功能来演示完整链路。服务端定义一个 query 和一个 mutation,输入参数全部由 zod 在运行时校验:

// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getAll: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1).max(5000), }), ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });

前端调用非常直接:

"use client"; import { api } from "~/trpc/react"; function PostList() { const { data: posts, isLoading } = api.post.getAll.useQuery(); const createPost = api.post.create.useMutation(); if (isLoading) return <div>加载中...</div>; return ( <div> {posts?.map((post) => ( <div key={post.id}>{post.title}</div> ))} <button onClick={() => createPost.mutate({ title: "测试标题", content: "测试内容", }) } > 新建 </button> </div> ); }

注意api.post.create.useMutation()这里的api对象是自动生成的。它怎么知道有哪些路由?因为 tRPC 的类型推导机制会从前端src/trpc/react.tsx里声明的AppRouter类型反推出所有的 hook。如果你新增了一个 router 却忘记在root.ts里注册,前端立刻会报类型错误,这在开发体验上是巨大的提升:类型系统直接在编译期就告诉你"你调的接口根本不存在"。

3.3 受保护路由的鉴权写法

上面的create用的是protectedProcedure,因为发帖子需要登录用户。它的实现核心是一个中间件,在进入业务逻辑之前检查 session:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); });

我见过不少初学者在业务代码里手动判断登录态:

const session = await getServerSession(); if (!session?.user) throw new Error("未登录");

这当然能用,但问题有三个:每个路由都要重复写、错误信息和规范无法统一、context 里的类型没有收窄。而protectedProcedure的好处是,中间件只写一次,后面所有需要登录的接口直接复用,并且通过了中间件之后,ctx.session.user在 TypeScript 里就是非空类型,业务代码里不需要再写防御性判断。

3.4 App Router 下 Server Components 调用 tRPC 的注意事项

T3 的示例代码里,服务端组件可以直接调用api,这个api来自src/trpc/server.ts里创建的 server caller。它不走 HTTP,而是直接调用 router 里的函数,性能更好,也没有网络开销。

但这里有一个非常容易踩的坑:服务端组件里调用了 query 之后,页面并不会自动更新。比如你在服务端组件里拉了一次帖子列表,然后用户在客户端创建了一个帖子,你希望服务端组件的列表自动刷新,却发现页面纹丝不动。这是因为服务端组件的渲染发生在请求时,客户端的 mutation 并不会触发服务端重新渲染。解决办法是调用router.refresh():

const utils = api.useUtils(); await createPost.mutateAsync({ title, content }); utils.post.getAll.refetch(); // 客户端数据刷新 router.refresh(); // 服务端组件重新渲染

这两个调用各管一层,丢了任何一个都会出现"数据不同步"的诡异现象。这是我实际开发中印象最深的一次排查,前后端数据一个更新一个不更新,最后才发现是忘了调router.refresh()。

3.5 tRPC v10 到 v11:升级后必须改的几个地方

我项目初期用的还是 v10,最近升级到 v11,有几个变化必须搞清楚。v11 里@trpc/client的httpBatchLink被移到了@trpc/client目录,createTRPCClient的类型参数从单个泛型变成了{ routes }对象结构。另外 v11 引入了一些新的缓存配置项,比如responseCache可以直接给 query 配置服务端响应缓存,这对高读接口来说收益非常明显。

升级时最容易踩的坑是 import 路径变了,旧代码里import { createTRPCReact } from "@trpc/react-query"在 v11 依然有效,但服务端 caller 的写法变了。我的建议是升级前先看一下官方 migration guide,别直接pnpm up,否则编译错误会多到让你怀疑人生。

4. Prisma 数据建模与迁移实战:我踩过的三个数据库坑

4.1 Prisma Client 初始化的全局单例模式

脚手架生成的src/server/db.ts长这样:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这段代码看着简单,实际非常关键。在 Next.js 开发模式下,模块文件会被反复加载,如果没有这个全局单例缓存,每次热更新都会 new 一个新的 PrismaClient,数据库连接数会一路飙升直到把你本地数据库拖垮。我第一次没意识到这个问题,接上 Postgres 后跑了半天,数据库连接数直接爆满,最后才发现是热重载导致的连接泄漏。这个模式我不建议改,也不需要理解得很深,直接用就行。

4.2 典型模型设计和关系选择

以用户和帖子为例,标准的schema.prisma模型如下:

model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] accounts Account[] sessions Session[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这里我犯过一个选型错误:一开始偷懒把用户表的主键设成了自增整数Int,后来做数据迁移和跨环境同步时发现整数主键在分布式的扩展性上不如cuid,而且拼接 URL 时会暴露数据量。换成cuid之后,主键不仅是全局唯一的,还能避免很多 ID 遍历滥用的安全问题。所以新项目我建议直接用cuid(),不要用自增整数。

4.3 迁移命令与三个常见报错

Prisma 的迁移流程是:先改schema.prisma,然后运行命令:

npx prisma migrate dev --name add_post_table

migrate dev会生成迁移文件、执行 SQL,并且自动重新生成 Prisma Client。如果数据库表结构已经和 schema 不一致了,它还会提示你。我在实际过程中遇到三个高频报错:

报错代码现象解决方案
P1001连接数据库超时检查 DATABASE_URL 的 host、端口、白名单
P2021查询的表不存在通常是迁移没有执行,跑一次prisma migrate dev或用prisma db push对齐结构
P2002唯一约束冲突检查插入数据的唯一字段,常见于重复邮箱、重复 slug

尤其 P2021,我遇到好几次。原因多是同事拉代码之后只跑了pnpm install没跑迁移。我的习惯是在项目启动脚本里顺手加上prisma migrate deploy,这样部署的时候不会漏。

4.4 查询性能:include 大礼包的代价

Prisma 里最容易写出慢查询的就是无脑include关联数据。比如查询帖子列表时把作者、点赞、评论全部 include 进来,数据量一上来,一条简单查询会变成多表 join,性能直线下降。

我处理这个问题的原则是:列表页只查列表需要的字段,详情页才查完整数据,常用select精确控制字段,而不是include大礼包。如果确实需要关联数据,也尽量用include但配合take限制关联数量。还有一个细节:count类的统计别在循环里逐条查询,改用groupBy或原始 SQL,不然 N+1 问题会把你拖死。

5. 登录态设计:NextAuth 和 tRPC 中间件如何共享 session

5.1 NextAuth 配置与 Prisma 如何对接

NextAuth 在 T3 里的角色是认证底座,它和 Prisma 的对接方式是通过PrismaAdapter:

// src/server/auth.ts import { PrismaAdapter } from "@auth/prisma-adapter"; import NextAuth from "next-auth"; import { db } from "~/server/db"; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [], session: { strategy: "jwt" }, });

PrismaAdapter的价值在于,它会自动把 User、Account、Session 等表同步到数据库中,你不需要自己手写这几张表的 CRUD。常见的第三方登录 provider(比如 GitHub、Google)的文档都写得比较清楚,配置好 client id 和 client secret 就能工作。

5.2 session 的两种策略:JWT 与 Database 的取舍

NextAuth 的 session 策略有jwt和database两种,这里需要做一个明确的选择。jwt策略是把用户信息编码进 JWT,每次请求通过getServerSession解密获取,优点是服务端无状态、适合 serverless;缺点是用户信息更新后需要等 token 过期才能生效。

database策略是把 session 存在数据库里,可以实时踢人、撤销会话,但每次请求都要查一次数据库,在 serverless 环境下会增加一层延迟。

我的经验是:MVP 阶段用jwt足够,能省掉不少数据库查询;等真到了需要做"强制下线""查在线用户"这种管理功能时,再切到database策略也不迟。切过去也就是改一个配置项。

5.3 tRPC 中间件中注入当前用户

真正把 NextAuth 和 tRPC 打通的关键,是在 context 里读取 session,然后让protectedProcedure能用它。前面已经展示过这个流程,我再补充一个细节:如果你想在受保护接口里拿到更多用户信息,比如用户角色、昵称,可以通过session.user的 id 去数据库里查,把完整的用户对象注入到 context 里。

return next({ ctx: { user: await ctx.db.user.findUnique({ where: { id: ctx.session.user.id }, }), }, });

好处很明显:业务 procedure 里直接用ctx.user.role做权限判断,不需要每个接口都查一遍用户。但要注意,这个查询会发生在每个受保护接口调用时,如果接口量大了,建议做成按需加载而不是每次都全量读取。

5.4 前端登录状态的几种判断方式

前端判断登录态,我常用的方式有三种。第一种是直接用useSession():

const { data: session, status } = useSession();

第二种是调一个getMe类的 tRPC procedure,把当前用户数据拉出来。第三种是某些页面在服务端通过auth()判断后直接重定向。三种方式的适用场景不同:useSession适合全局判断"要不要显示登录按钮",getMe适合需要用户完整资料时,服务端重定向适合保护页面路由。

有个细节容易忽略:useSession在第一次加载时可能会短暂返回unauthenticated状态,导致页面闪一下"未登录"的 UI。如果不想闪屏,可以在布局层用SessionProvider配合status === "loading"做全局加载态。这个小细节很多项目上线后才发现,体验影响还挺明显的。

6. 上生产环境:部署配置、缓存策略与性能体检

6.1 serverless 环境下的数据库连接管理

T3 项目通常部署到 Vercel 这类 serverless 平台,而 serverless 环境最大的问题就是函数实例会随时销毁和重建,数据库连接不能像传统服务器那样保持长连接。Prisma Client 在这种场景下,需要依赖连接池。

我的做法是:Postgres 数据库开一个连接池地址,DATABASE_URL 指向连接池而不是直连地址。这样每个 serverless 实例创建的连接都会被池化复用,不会打爆数据库的最大连接数。不接连接池的话,流量一上来,数据库很容易报"too many connections",而且这个错误在本地开发完全复现不了,只能在线上环境排查,特别折腾。

另外,生产环境的 Prisma Client 应该只初始化一份,千万不要在请求里反复new PrismaClient()。上一节提到的全局单例模式在生产环境同样适用。

6.2 缓存策略:superjson、响应缓存与 router.refresh

默认脚手架里配置了superjson作为数据序列化工具,它的主要作用是把Date、Map这类特殊类型在传输时完整保留。我建议不要关掉它,因为 Prisma 查询结果里的时间字段是Date对象,如果没有 superjson,时间数据会在网络传输中变成字符串,前端再用的时候非常别扭。

针对读多写少的接口,可以在 query 上配置缓存,避免每次请求都打数据库。tRPC v11 提供了responseCache,设置一个短时间的缓存能明显降低数据库压力。但缓存也会带来副作用:用户更新数据后,读接口可能拿到旧数据。我处理思路是:对实时性要求高的接口不缓存,对列表这类允许有几十秒延迟的数据开短缓存,同时客户端 mutation 成功后调用refetch()强制刷新。

6.3 部署后的性能检查与优化清单

项目上线后,我一般会按下面这个顺序做一轮体检:

  • 打开浏览器开发者工具的 Network 面板,看 tRPC 请求是否有批量合并(httpBatchLink 默认会批量),确认有没有出现海量单请求。
  • 查数据库慢查询日志,重点看有没有全表扫描、有没有在循环里发查询。
  • 检查前端打包大小,确认没有把整个数据库客户端打包进浏览器。
  • 用 Lighthouse 跑一下首屏,重点看服务端渲染时间。

我实际遇到过一次很典型的性能问题:列表页的 query 被放在多个子组件里各自调用,结果每次切换 Tab 都要重新拉一遍数据,页面卡顿明显。后来把所有 query 提升到页面顶层组件,用一次请求拿回全部数据,再通过 props 分发下去,首屏和交互都流畅了很多。这个问题的根子不是 T3,而是我自己的组件设计,但这种"把接口调用和组件生命周期绑太死"的毛病在全栈开发里特别常见,值得警惕。

还有一个容易被忽略的瓶颈是图片和静态资源。Tailwind 生成的 CSS 体量不小,如果直接默认引入,首屏样式体积会偏大。建议生产环境开启 Tailwind 的minify和 tree shaking,配合 Next.js 的 Image 组件做图片优化,首屏体积能再瘦一圈。

整个 t3code 折腾下来,我最深的体会是:这套技术栈最大的魅力不在于任何单一框架,而在于它们叠加之后产生的"类型安全的正反馈循环"——tRPC 让前后端类型一致,Prisma 让数据库类型和 TypeScript 类型对齐,zod 让运行时校验和编译期类型双保险。这种一致性带来的开发效率提升,是要自己写过一段时间才能直观感受到的。如果你正打算开始一个全栈项目,不妨先用 T3 跑一个完整的小功能试试,别急着点评它重不重、花哨不花哨,代码会给你答案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进