1. 从“t3code”这个名字说起:它到底是什么
第一次看到“t3code”这个词,很多人会以为是某个新出的编程语言,或者某个开源框架的缩写。实际上,在开发者圈子里,t3code 通常指向的是一套围绕 TypeScript 全栈开发的技术组合方案,核心是把 T3 Stack 那一套东西——Next.js、tRPC、Prisma、Tailwind CSS、TypeScript——用在一个真实可跑的项目骨架里,并且把“代码”这件事从端到端串起来。你可以把它理解成一份“从数据库到界面”的完整代码组织范式,而不是一个具体的库。
它解决的问题很实在:全栈项目里,前后端类型对不上、接口文档永远滞后、改一个字段要动五个文件、新人接手要花一周才能跑起来。t3code 这套思路的价值就在于,用 TypeScript 的类型系统把整条链路焊死,让编译器替你做接口联调。适合谁看?如果你已经会写 React,但对“怎么把前后端优雅地连起来”还停留在手写 fetch 加 any 的阶段,那这套东西就是给你准备的。如果你是完全零基础的小白,建议先把 TypeScript 基础类型和 React 组件通信过一遍再回来,不然会有点吃力。
我前后用这套结构落地过三个中小型项目,踩过的坑不算少,下面把整体设计、核心细节、实操过程和排查经验完整拆一遍。
2. 整体架构设计与技术选型逻辑
2.1 为什么是这套组合而不是别的
先说选型的底层逻辑。全栈开发最痛的点从来不是“写不出来”,而是“改不动”。一个字段从数据库改到界面,传统做法要经过:改表结构、改后端 DTO、改接口返回、改前端类型定义、改组件渲染,五步里任何一步漏了,运行时才报错。t3code 这套方案的核心目标就是把这五步压缩成一步——改 Prisma schema,剩下的靠类型推导自动流转。
具体来说,Next.js 负责路由和渲染,它同时提供 API Route 和页面,省掉了单独起一个后端服务的运维成本。tRPC 负责前后端通信,它的杀手锏是“没有 schema 文件”,后端函数的输入输出类型直接被前端 import 使用,改后端签名前端立刻飘红。Prisma 负责数据库访问,schema 即类型来源,生成的 Client 自带完整类型。Tailwind 负责样式,省去 CSS 文件切换。TypeScript 是粘合剂,把上面所有东西串成一个类型闭环。
对比一下其他方案:REST + Swagger 需要维护额外的接口文档,文档和代码不同步是常态;GraphQL 类型安全但要写 schema 和 resolver,样板代码多;纯 Serverless 函数拆分细但本地调试麻烦。t3code 这套在“中小团队快速迭代”这个场景下,综合成本最低。
2.2 目录结构背后的取舍
一个典型的 t3code 项目目录大概长这样:
src/ server/ api/ routers/ user.ts post.ts root.ts trpc.ts db.ts pages/ 或 app/ api/trpc/[trpc].ts index.tsx utils/ api.ts components/ prisma/ schema.prisma这个结构的关键在于server和pages的边界。所有数据库和业务逻辑都锁在server里,前端只能通过utils/api.ts导出的 hooks 访问。这样做的好处是,你永远不会在组件里不小心 import 了 Prisma Client 导致打包体积爆炸——因为 Prisma 只在 server 侧被引用。我见过太多项目把数据库查询写在组件里,最后 bundle 里塞进了整个数据库驱动,首屏加载慢得离谱。
root.ts是路由聚合点,把所有子 router 合并成一个appRouter。这个文件同时导出AppRouter类型,前端utils/api.ts就靠这个类型拿到全部接口签名。整个类型闭环的入口就在这里,所以每次新增 router 记得在 root 里注册,否则前端拿不到类型。
2.3 类型安全的边界在哪里
需要清醒认识到,tRPC 的类型安全只覆盖“前端调用后端”这一段。后端到数据库这一段,Prisma 保证了 schema 和查询结果的类型,但如果你在 resolver 里手动拼了一个对象返回,那个对象的类型是你自己声明的,编译器不会帮你验证它和数据库真实字段是否一致。所以真正的类型闭环是:Prisma schema → Prisma Client 类型 → resolver 返回类型 → tRPC 推导 → 前端 hooks。中间任何一环你手动as any,闭环就断了。
我的经验是,resolver 里尽量直接返回 Prisma 查询结果,不要手动重组对象。如果确实需要裁剪字段,用 Prisma 的select而不是在 JS 里解构,这样类型推导能一路跟到底。
3. 核心细节解析与实操要点
3.1 Prisma schema 是整个项目的类型源头
很多人把 schema.prisma 当成“数据库配置文件”,这是最大的误解。在这套体系里,它是整个项目类型的唯一真相来源。你在这里定义的每一个 model、每一个字段类型、每一个可选标记,都会一路传导到前端组件的 props 上。
举个例子,定义一个 Post 模型:
model Post { id String @id @default(cuid()) title String content String? published Boolean @default(false) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }注意content String?这个问号。它意味着这个字段在数据库里可为空,Prisma Client 生成的类型里它就是string | null,这个 null 会一路传到前端。如果你前端组件写的是post.content.length,编译器直接报错,逼你处理 null 情况。这就是类型闭环的价值——它在编译期就帮你发现了潜在的运行时崩溃。
实操要点:每次改完 schema,必须跑npx prisma migrate dev --name xxx生成迁移并重新生成 Client。我踩过的坑是改了 schema 忘了 migrate,本地 Client 还是旧的,前端类型对不上,排查半天才发现是没重新生成。建议把prisma generate加到postinstall脚本里,团队协作时别人拉代码后自动生成。
3.2 tRPC router 的写法与常见陷阱
一个典型的 router 长这样:
import { z } from "zod"; import { router, publicProcedure, protectedProcedure } from "../trpc"; export const postRouter = router({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().optional() })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), });这里有几个关键点。第一,input用 zod 定义,它同时承担运行时校验和类型推导两个职责。前端调用时传错类型,编译期就报错;传了非法值,运行期 zod 拦截。第二,ctx里挂着db和session,这是通过 context 注入的,不要在 router 里直接 import db,否则测试时没法 mock。第三,protectedProcedure和publicProcedure的区别在于是否校验登录态,这个中间件逻辑写在trpc.ts里。
常见陷阱:在 query 里做写操作。tRPC 的 query 默认可能被并发调用和缓存,写操作必须用 mutation。我见过有人在 query 里更新访问计数,结果 React StrictMode 下双调用导致计数翻倍。记住 query 只读,mutation 才写。
3.3 前端 hooks 的自动生成与使用
前端不需要手写任何请求代码。utils/api.ts里通常只有几行:
import { createTRPCNext } from "@trpc/next"; import type { AppRouter } from "../server/api/root"; export const api = createTRPCNext<AppRouter>({ /* config */ });之后在组件里:
const { data: posts, isLoading } = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), });api.post.list这个路径是自动补全的,因为AppRouter类型里包含了所有 router 和 procedure 的名字。改后端 router 名字,前端这里立刻报错。useMutation的onSuccess里调invalidate是刷新缓存的标配,不写的话创建完列表不更新,用户以为没成功。
注意事项:useQuery的依赖数组和enabled选项要会用。比如详情页要根据路由参数查数据,得写api.post.byId.useQuery({ id }, { enabled: !!id }),否则 id 还没拿到就发请求,后端收到 undefined 报错。
4. 完整实操流程:从零跑起来一个 t3code 项目
4.1 环境准备与初始化
先确认本地 Node 版本在 18 以上,包管理器我用 pnpm,速度快且对 monorepo 友好。初始化命令:
pnpm create t3-app@latest my-app交互式选项里,TypeScript 必选,Next.js App Router 和 Pages Router 按习惯选,我一般选 Pages Router 因为生态资料多。tRPC、Prisma、Tailwind、NextAuth 全勾上。生成后进入目录,配置.env:
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb" NEXTAUTH_SECRET="随便一串随机字符串"数据库我推荐本地用 Docker 起一个 Postgres,一条命令搞定:
docker run --name t3db -e POSTGRES_PASSWORD=pass -p 5432:5432 -d postgres:15然后pnpm prisma migrate dev建表,pnpm dev启动。这一步如果卡在数据库连接,九成是 DATABASE_URL 里的用户名密码或端口写错了,用docker ps确认容器在跑,用psql或 TablePlus 试连一下。
4.2 新增一个完整业务模块的步骤
假设要加一个“评论”功能,完整流程如下。
第一步,改 schema:
model Comment { id String @id @default(cuid()) text String postId String post Post @relation(fields: [postId], references: [id], onDelete: Cascade) authorId String createdAt DateTime @default(now()) }同时在 Post 模型里加comments Comment[]反向关系。跑pnpm prisma migrate dev --name add_comment。
第二步,写 router:
export const commentRouter = router({ byPost: publicProcedure .input(z.object({ postId: z.string() })) .query(({ ctx, input }) => ctx.db.comment.findMany({ where: { postId: input.postId }, orderBy: { createdAt: "asc" }, }) ), add: protectedProcedure .input(z.object({ postId: z.string(), text: z.string().min(1) })) .mutation(({ ctx, input }) => ctx.db.comment.create({ data: { ...input, authorId: ctx.session.user.id }, }) ), });第三步,在 root.ts 注册comment: commentRouter。
第四步,前端组件里直接用api.comment.byPost.useQuery({ postId })和api.comment.add.useMutation()。
整个过程不需要写一行接口定义、不需要写一行请求封装、不需要手动声明返回类型。这就是这套方案的核心效率来源。我实测下来,一个标准 CRUD 模块从 schema 到界面跑通,熟练后 20 分钟内能完成。
4.3 参数校验与错误处理的实际写法
zod 的校验规则要写细。比如text: z.string().min(1).max(500),前端和后端共用同一套规则,前端可以在提交前先本地校验,后端再兜底。错误处理上,tRPC 抛出的TRPCError会带 code,前端useMutation的onError里能拿到:
onError: (err) => { if (err.data?.code === "UNAUTHORIZED") router.push("/login"); else toast.error(err.message); }我习惯在trpc.ts里统一处理常见错误,比如 session 过期跳登录,避免每个组件重复写。另外 zod 的报错信息默认是英文的,要中文提示得用z.string().min(1, "不能为空")这样逐条写 message,或者用z.setErrorMap全局配置。
5. 常见问题与排查技巧实录
5.1 类型报错排查速查表
| 报错现象 | 大概率原因 | 解决方式 |
|---|---|---|
前端api.xxx没有补全 | root.ts 没注册该 router | 在 root 里加一行注册 |
类型显示any | 某处用了as any或没导出类型 | 全局搜as any逐个消除 |
| Prisma Client 类型过期 | 改了 schema 没 generate | 跑pnpm prisma generate |
ctx.session为 null | 用了 publicProcedure | 换成 protectedProcedure |
| 打包报 Prisma 相关错误 | 前端误 import 了 db | 检查组件里是否直接引 server 代码 |
5.2 几个我踩过的深坑
第一个坑:循环依赖。root.ts引 router,router 引trpc.ts,trpc.ts又引root.ts拿类型,一不小心就循环。解决办法是把 context 类型单独放一个文件,trpc.ts只引类型不引值。
第二个坑:Next.js 的 SSR 和 tRPC 缓存不同步。服务端渲染时拿到的数据和客户端 hydration 后的数据如果不一致,React 会警告。用createTRPCNext的ssr: true配置,并在_app.tsx里正确包裹api.withTRPC,能解决大部分问题。
第三个坑:Prisma 在 Serverless 环境下的连接数爆炸。每个函数实例都开一个连接池,并发一高数据库就拒绝连接。解决办法是在db.ts里用全局单例模式:
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这个模式在开发环境热重载时避免重复创建 Client,生产环境配合连接池参数调优。
5.3 性能相关的注意事项
tRPC 的 query 默认会做请求去重和缓存,但useQuery的staleTime默认是 0,意味着组件重新挂载就重新请求。列表页这种不常变的数据,设staleTime: 30 * 1000能明显减少请求。另外invalidate要精确到具体 query,不要全局 invalidate,否则一个 mutation 触发全站刷新,体验很差。
数据库层面,Prisma 的findMany如果不加select,会返回所有字段,包括大文本字段。列表页只展示标题和摘要的话,用select: { id: true, title: true }能省不少带宽。我有个项目列表页没加 select,每条记录带了几 KB 的 content,一页 20 条就是几十 KB 的无用传输。
6. 这套方案适合与不适合的场景
t3code 这套结构在“团队规模 2 到 10 人、需要快速迭代、前后端同一批人写”的场景下几乎是最优解。类型闭环带来的重构信心是实打实的——改一个字段名,编译器把所有需要改的地方列出来,挨个改完就能跑,不用全局搜索字符串。
但它不适合的场景也要说清楚。如果后端要同时服务多个客户端(iOS、Android、第三方),tRPC 的类型优势只对 TypeScript 客户端有效,其他语言还是得走 REST 或 GraphQL,这时候硬上 tRPC 反而多一层。如果团队里有大量非 TS 背景的后端,Prisma 和 tRPC 的学习曲线会拖慢进度。还有就是超大型项目,单个AppRouter类型膨胀到几千个 procedure 时,IDE 的类型推导会变慢,这时候需要按领域拆分多个 tRPC 实例。
我个人在实际操作中的体会是,这套东西最大的价值不在“写得快”,而在“改得稳”。项目上线半年后回头加功能,打开编辑器,类型系统会告诉你哪里会受影响,这种安全感是手写 REST 加 any 永远给不了的。最后分享一个小技巧:把常用的 zod schema 抽到src/server/validators目录下,前后端共用,表单校验和接口校验用同一份规则,能省掉大量重复代码,也避免了“前端校验过了后端没过”的尴尬。