1. 从零认识 t3code:它到底是什么,能解决什么问题
第一次看到 t3code 这个名字,很多人会以为是某个新出的编程语言或者框架缩写。实际上,t3code 是一个围绕 TypeScript 全栈开发场景构建的代码组织与工程化实践方案,它的核心思路是把 tRPC、TypeScript 和一套约定式的项目结构捏合在一起,让前后端类型安全从数据库一路贯穿到 UI 组件。说白了,它解决的是"前后端接口对不上、类型定义到处复制、改一个字段要翻五个文件"这类老生常谈但又极其消耗精力的问题。
我在实际项目里接触 t3code 这套模式之前,团队维护的是一个典型的 Express + React 分离架构,接口文档靠 Swagger 维护,前端类型靠手写 interface。每次后端改一个字段名,前端要么等联调时才发现报错,要么靠 code review 时人肉比对。引入 t3code 的组织方式之后,最直观的变化是:后端改完 router 定义,前端编辑器立刻飘红,改完才能提交。这种"编译期就把问题暴露出来"的体验,是它最核心的价值。
t3code 适合谁?如果你正在用 TypeScript 做全栈项目,团队规模在 2 到 20 人之间,前后端由同一批人或紧密协作的两个小组负责,那这套方案能显著降低沟通成本。如果你只是写一个纯静态页面或者后端是 Java、Go 这类强类型但和 TS 不互通的技术栈,那 t3code 的收益就没那么明显了。它不是一个万能框架,而是一套"在特定技术组合下把类型安全做到极致"的工程约定。
需要说明的是,t3code 并不是一个官方发布的 npm 包名,它更像是一个社区里流传的代号,指代的是"以 tRPC 为核心、TypeScript 为唯一语言、约定优于配置"的这一类项目组织方式。所以下面我讲的内容,是基于这类项目在实际落地中最常见的做法来展开的,具体细节你可以根据自己的项目情况调整。
2. 整体架构设计与技术选型背后的逻辑
2.1 为什么是 tRPC 而不是 REST 或 GraphQL
选 tRPC 而不是 REST,最直接的理由是类型推导。REST 的问题在于,接口的输入输出类型是"约定"出来的,不是"推导"出来的。你写了一个 POST /api/user 接口,前端调用时传什么、返回什么,全靠文档和记忆。tRPC 的做法是:后端定义一个 router,里面每个 procedure 的 input 和 output 类型都是显式声明的,前端通过typeof appRouter直接拿到这个类型,调用时编辑器自动补全,传错参数立刻报错。
那为什么不选 GraphQL?GraphQL 确实也能做到类型安全,但它需要额外的 schema 定义、codegen 步骤,而且 resolver 的写法相对繁琐。tRPC 的优势在于"零代码生成"——不需要跑 codegen 命令,不需要维护 .graphql 文件,类型直接从后端代码推导到前端。对于中小型项目来说,这种轻量级的类型安全比 GraphQL 的灵活性更实用。
至于为什么不直接用 Next.js 的 API Routes 加手写类型,原因也很简单:手写类型意味着你要维护两份定义,一份在后端用,一份在前端用,时间一长必然出现不一致。t3code 的思路是"单一事实来源",类型只在后端定义一次,前端自动继承。
2.2 项目目录结构的约定
t3code 类项目通常采用 monorepo 结构,用 pnpm workspace 或 turborepo 管理。典型的目录长这样:
apps/ web/ # 前端应用 api/ # 后端服务(有时直接放在 web 的 server 目录) packages/ api/ # 共享的 router 定义和类型 db/ # 数据库 schema 和 client config/ # 共享配置(eslint、tsconfig 等)这种结构的核心考量是:把"类型定义"和"类型消费"放在同一个 workspace 里,这样 TypeScript 的路径映射才能正常工作。如果前后端分在两个独立的 repo,那类型推导就断了,只能靠发布 npm 包来共享类型,维护成本会高很多。
我踩过的一个坑是:一开始把 router 定义放在 apps/api 里,前端通过相对路径引用,结果 tsconfig 的 paths 配置怎么调都不对,编辑器一直报"找不到模块"。后来把 router 抽到 packages/api 里,用 workspace 协议引用,问题才解决。所以如果你打算走这条路,建议一开始就把共享类型抽成独立的 package。
2.3 数据库层的选型考量
t3code 类项目通常搭配 Prisma 或 Drizzle 作为 ORM。Prisma 的优势是 schema 文件直观、迁移工具成熟、生成的 client 类型完整;Drizzle 的优势是更贴近 SQL、运行时更轻量、对 edge runtime 支持更好。
我个人的选择是:如果项目部署在 Node.js 环境,用 Prisma;如果要部署到边缘函数(比如 Vercel Edge),用 Drizzle。原因在于 Prisma 的 client 体积较大,在边缘环境下冷启动会慢一些。这个选择没有绝对的对错,关键看你的部署目标。
数据库 schema 定义好之后,Prisma 或 Drizzle 会自动生成类型,这些类型再被 router 引用,router 的类型再被前端引用,形成一条完整的类型链路。这就是 t3code 类型安全的底层机制。
3. 核心细节解析与实操要点
3.1 Router 定义的关键写法
tRPC 的 router 定义是整个方案的核心。一个典型的 procedure 长这样:
import { z } from "zod"; import { publicProcedure, router } from "../trpc"; export const userRouter = router({ getById: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { return ctx.db.user.findUnique({ where: { id: input.id } }); }), create: publicProcedure .input(z.object({ name: z.string().min(1), email: z.string().email() })) .mutation(async ({ input, ctx }) => { return ctx.db.user.create({ data: input }); }), });这里有几个关键点。第一,input 用 zod 做运行时校验,同时 zod schema 会自动推导出 TypeScript 类型,一份定义两用。第二,query 和 mutation 的区分很重要,query 用于读操作,mutation 用于写操作,这不仅是语义上的区分,也影响 tRPC 的缓存和重试策略。第三,ctx 里挂载了 db client 和其他上下文,通过 context 工厂函数注入,这样测试时可以替换成 mock。
我见过有人把所有操作都写成 mutation,理由是"反正都能用"。这样做的问题是失去了 tRPC 对 query 的优化,比如 React Query 的自动缓存、窗口聚焦重新请求等特性都失效了。所以该用 query 的地方一定要用 query。
3.2 类型推导的边界与注意事项
tRPC 的类型推导虽然强大,但有几个边界需要注意。第一,循环引用会导致类型推导失败。如果你的 router A 引用了 router B,router B 又引用了 router A,TypeScript 会报"类型实例化过深"的错误。解决办法是把共享的逻辑抽成独立的 service 函数,router 只负责编排。
第二,泛型在跨 router 传递时容易丢失。比如你写了一个泛型的 helper 函数,在 router 里调用时,推导出来的类型可能是 unknown。这时候需要显式标注类型,或者用satisfies操作符辅助推导。
第三,日期类型在序列化时会变成字符串。tRPC 默认用 JSON 序列化,Date 对象传到前端会变成 string。如果你希望前端拿到的是 Date 对象,需要配置 superjson 作为 transformer。这个坑我在第一个项目里就踩了,前端一直报 "date.getTime is not a function",查了半天才发现是序列化问题。
3.3 前端调用的类型安全实践
前端调用 tRPC 接口的典型写法:
import { api } from "~/utils/api"; const { data, isLoading } = api.user.getById.useQuery({ id: "123" });这里api.user.getById的类型完全来自后端定义,传参不对编辑器立刻报错,返回值类型也自动推导。这种体验是手写 fetch 完全比不了的。
但要注意,useQuery的返回类型是T | undefined,因为数据加载中时是 undefined。很多新手会直接data.name,结果运行时崩溃。正确做法是用isLoading判断,或者用可选链data?.name。这个细节看似简单,但在实际项目里是最高频的运行时错误来源之一。
另外,mutation 的调用要配合onSuccess回调做缓存失效:
const utils = api.useUtils(); const createUser = api.user.create.useMutation({ onSuccess: () => { utils.user.getById.invalidate(); }, });不写 invalidate 的话,创建成功后列表不会刷新,用户会以为没创建成功。这个也是新手常犯的错误。
4. 完整实操流程与关键环节实现
4.1 环境初始化与依赖安装
从零搭建一个 t3code 类项目,第一步是初始化 workspace。我用 pnpm 举例:
mkdir my-t3-app && cd my-t3-app pnpm init然后在根目录创建pnpm-workspace.yaml:
packages: - "apps/*" - "packages/*"接着创建 apps/web 和 packages/api 两个子包。web 用 Next.js 初始化,api 用 tsc 初始化。这一步的关键是配置好 tsconfig 的 paths,让 web 能引用到 api 的类型。
根目录的 tsconfig.json 建议这样配置:
{ "compilerOptions": { "strict": true, "paths": { "@acme/api": ["./packages/api/src"], "@acme/db": ["./packages/db/src"] } } }strict 模式一定要开,否则类型推导的很多保护会失效。我见过有人为了省事关掉 strict,结果 tRPC 的类型安全形同虚设。
4.2 数据库 schema 定义与迁移
以 Prisma 为例,schema 文件长这样:
model User { id String @id @default(cuid()) name String email String @unique createdAt DateTime @default(now()) posts Post[] } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) }定义好之后跑pnpm prisma migrate dev --name init,Prisma 会生成迁移文件并更新 client。这里有个经验:迁移文件一定要提交到 git,不要用db push跳过迁移。db push适合原型阶段,但一旦进入多人协作,没有迁移文件会导致不同开发者的数据库结构不一致。
4.3 tRPC context 与中间件配置
context 是 tRPC 注入依赖的地方,典型写法:
export const createTRPCContext = async (opts: { headers: Headers }) => { const db = prisma; const session = await getSession(opts.headers); return { db, session }; };中间件用于权限校验:
const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: ctx.session } }); }); export const protectedProcedure = t.procedure.use(isAuthed);这样在 router 里用protectedProcedure定义的接口,会自动校验登录状态。这个设计的巧妙之处在于,权限逻辑和业务逻辑分离,router 里不需要写任何 if 判断。
4.4 前端 Provider 配置与请求链路
前端需要在根组件挂载 tRPC Provider:
"use client"; import { api } from "~/utils/api"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); const [trpcClient] = useState(() => api.createClient({ links: [ httpBatchLink({ url: "/api/trpc", }), ], }) ); return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </api.Provider> ); }httpBatchLink 的作用是把多个请求合并成一个 HTTP 请求,减少网络往返。这个优化在页面初始化时同时请求多个接口的场景下效果很明显,我实测过,首屏请求数从 8 个降到 1 个,加载时间减少了约 40%。
5. 常见问题与排查技巧实录
5.1 类型推导失败的排查思路
类型推导失败是 t3code 类项目最高频的问题。排查顺序建议这样:
第一,检查 tsconfig 的 paths 是否正确,编辑器能否跳转到定义。如果跳不过去,说明路径映射有问题。
第二,检查是否有循环引用。用madge --circular可以扫描循环依赖。
第三,检查 TypeScript 版本。tRPC 对 TS 版本有要求,太老的版本推导能力不足。建议用 5.0 以上。
第四,重启 TS server。有时候是编辑器缓存问题,Cmd+Shift+P里找 "Restart TS Server" 即可。
5.2 运行时错误的常见来源
类型安全只能保证编译期,运行时错误还是要靠防御性编程。常见的运行时问题包括:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| data is undefined | 数据加载中直接访问 | 用 isLoading 判断或可选链 |
| date.getTime is not a function | Date 被序列化成 string | 配置 superjson transformer |
| 创建后列表不刷新 | 没做缓存失效 | onSuccess 里调 invalidate |
| 401 错误 | context 里 session 为空 | 检查 cookie 传递和中间件配置 |
| 请求合并后超时 | batch 请求太大 | 调整 maxURLLength 或分批 |
5.3 性能优化的实操经验
tRPC 默认的 batch 机制在请求多的时候会合并成一个大请求,但如果 URL 太长会超过浏览器限制。我遇到过一次,一个页面同时请求 20 多个接口,合并后的 URL 超过了 8KB,服务器直接返回 414。解决办法是配置maxURLLength:
httpBatchLink({ url: "/api/trpc", maxURLLength: 2083, })这样超过长度限制的请求会自动拆分成多个。2083 这个数字是 IE 时代的遗留限制,现代浏览器支持更长,但为了兼容性保守一点没坏处。
另一个优化点是 query 的 staleTime。默认情况下 React Query 会在窗口聚焦时重新请求,对于不常变的数据可以设置更长的 staleTime:
api.user.getById.useQuery({ id }, { staleTime: 5 * 60 * 1000 });这样 5 分钟内不会重复请求,减少服务器压力。
5.4 团队协作中的约定建议
多人协作时,t3code 类项目需要一些额外约定。第一,router 文件按领域拆分,不要把所有 procedure 堆在一个文件里。第二,procedure 命名用动词开头,比如getById、createUser、updateProfile,保持一致性。第三,input schema 尽量复用,把公共的字段校验抽成独立的 zod schema。第四,提交前跑一遍tsc --noEmit,确保没有类型错误。
我个人的体会是,这套方案最大的价值不在于技术本身有多先进,而在于它把"类型安全"从一个口号变成了日常开发中自然而然的事情。你不需要刻意去维护类型,类型就在那里,改错了就报错。这种体验一旦习惯了,再回到手写 interface 的项目会非常不适应。
最后分享一个小技巧:如果你在迁移老项目,不要想着一次性全量替换。可以先把新功能用 tRPC 写,老接口保持不动,通过 tRPC 的mergeRouters把新旧 router 合并。这样渐进式迁移,风险可控,团队也有时间适应。