ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

t3code解析:T3 Stack全栈类型安全的工程实践

t3code解析:T3 Stack全栈类型安全的工程实践

最近好几个朋友跑来问我 t3code 到底是什么,说在 GitHub 上搜这个关键词,出来的仓库五花八门,有拿 Next.js 搭的,也有塞了一堆 Drizzle ORM 的,还有干脆只放了几个 tRPC 示例的。这其实挺正常。t3code 并不是某个官方框架的名字,更多是社区对“T3 Stack 工程化玩法”的一个约定俗成的代号。要把它讲清楚,还不如直接说说我这一年多来用 T3 这套组合做全栈项目的真实体验,以及这套东西背后的技术逻辑。

T3 指的是 TypeScript、Tailwind CSS、tRPC 这三个首字母 T 的组合,也可以把 Next.js 和 Prisma 算进默认套餐。它的核心主张很朴素:与其在大而全的框架里做选择,不如用几个小而确定的工具,把“类型安全”从数据库一路打通到前端组件。别说,这套组合真能省不少事。

下面进入正题。今天我会从三个角度展开:这套方案凭什么被这么多人用,关键环节的细节和原理是什么,以及一份可以直接抄的实操步骤和踩坑记录。如果你正打算搭一个全栈项目,或者想把团队的技术栈往类型安全方向收敛,这篇文章应该能给你一些参考。

1. t3code 到底是什么:从命名看技术选型逻辑

1.1 “T3”是三个 T,但核心从来不是某个框架

按社区里流传最广的说法,T3 来自 Create T3 App 这个脚手架项目。作者是 Theo,一个在推特上话很多但技术判断相当准的开发者。当时他提了一个观点:大部分全栈项目根本不需要一开始就引入 Redux、GraphQL、Monorepo 这些重型方案,你真正需要的是一套“写法最少、类型最全”的组合。

于是就有了 TypeScript + Tailwind CSS + tRPC 这个组合。TypeScript 负责语言层的类型约束,Tailwind 负责样式层不写重复 CSS,tRPC 负责让前后端共享类型。后来大家发现 Prisma 和 Next.js 跟这套组合太搭了,也被默认收了进来。所以你在很多 t3code 风格的仓库里会看到五个核心依赖:Next.js、TypeScript、Tailwind、tRPC、Prisma。

我自己的理解是,T3 更像是一种“口味”,而不是一个必须逐字遵守的框架。你可以把 Tailwind 换成 CSS Modules,可以把 Prisma 换成 Drizzle,但只要保留“端到端类型安全”这个核心思想,写出来的东西依然很 T3。这也是为什么 t3code 在 GitHub 上会呈现那么多变体——大家都在同一个理念下做自己的取舍。

1.2 这套组合能火,是因为它消灭了“联调地狱”

很多团队做全栈项目时最痛苦的不是写功能,而是“颗粒度对齐”。后端定义了一个响应结构,前端不知道;前端改了字段名,后端没同步;后端某个接口突然把数组改成了对象,前端直接白屏。这种问题在传统 REST 架构里几乎没法根治,因为类型定义是断开的。

t3code 这类方案的精髓,就是把“类型”作为唯一的沟通语言。用 tRPC 定义后端路由,前端 import 过来直接就是带类型的函数调用;用 Prisma 定义数据库表结构,生成出来的 client 从查询到返回值全程有类型。也就是说,从数据库表到 API 再到 React 组件,所有数据形状拧成一股绳,改动一处,编译期就会提醒你哪里需要同步改。

我拿一个真实对比来说一下:

环节传统 REST + 手写类型t3code 风格(tRPC + Prisma)
定义接口单独写 Controller + 路由文档在 tRPC router 里写一个方法
前端取值手写 fetch + 手动声明 interface直接调用带类型的方法
字段变更前后端各改各的,容易漏改 schema 后类型自动更新,编译报错提醒
文档维护Swagger/Postman 手动维护类型即文档,无需额外维护
调试成本靠 Network 面板和日志对比编辑器直接看到入参与返回值类型

这张表不是贬低 REST,而是在中小型全栈项目里,t3code 这套模式的边际收益确实非常高。我后来带过几个项目,新人也几乎没有因为“不知道接口返回什么”而来问我,类型已经把答案写死在编辑器里了。

1.3 为什么不是 GraphQL 或其他“大一统”方案

有人会问,类型安全这事 GraphQL 不也能做吗?确实能。但 GraphQL 的复杂度是另一层级的,你需要维护 Schema、Resolvers、客户端片段、缓存策略,光是“如何组织 Query 和 Mutation”就能吵一上午。

tRPC 的思路更直接:后端写一个函数,前端直接调用这个函数。没有 URL 拼接,没有参数序列化的心智负担,本质上就是 RPC(远程过程调用)模式。它不够花哨,但对应“快速做一个类型安全的全栈产品”这种需求,几乎是性价比最高的选择。

当然,如果是开放 API 给第三方团队用、或者需要极其灵活的聚合查询,那 GraphQL 依然有它的价值。t3code 的目标场景很明确:内部项目、MVP 验证、中小型产品。在这些场景里,简单直接就是最大的优势。

2. 核心细节拆解:类型安全如何从数据库一路贯穿到组件

2.1 tRPC 的“免 API 层”哲学:函数即接口

理解 tRPC 最好的方式,是忘掉“接口”这个概念。在后端你写一个函数:

// server/router/post.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ getLatest: publicProcedure.query(({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, }); }), });

前端调用:

import { api } from "~/trpc/react"; // 在组件里 const { data, isLoading } = api.post.getLatest.useQuery();

你没有写任何 fetch 封装,没有接口地址,没有 DTO 转换,甚至没有手写类型声明。api.post.getLatest直接告诉你入参长什么样、返回值长什么样。这个过程依赖的是 TypeScript 的类型推导和 tRPC 生成的类型定义文件。

在工程上,这带来的好处是“函数签名”成了团队之间唯一的约定。后端改参数,前端立刻编译报错;前端误传了字段类型,后端 zod 校验会拦下来。类型在编译期和安全边界两层同时兜底,出问题的概率大幅下降。

2.2 Prisma:用一份 Schema 当唯一事实源

t3code 里,数据库层用的是 Prisma。它在整个体系中的角色很特别:既有 ORM 的便利,又比传统 ORM 多了一层“代码生成”能力。

一个典型的 schema 长这样:

model User { id String @id @default(cuid()) email String @unique name String? 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]) createdAt DateTime @default(now()) }

写完 schema 后执行npx prisma migrate dev --name init,Prisma 会做两件事:同步数据库结构,并生成一套类型完整的 client。之后你在 tRPC 的 resolver 里写ctx.db.post.findMany(),返回的数组和字段都会自动推导出类型。

这里的重点不只是“自动出类型”,而是“一份 Schema 多处复用”。前端需要新增一个作者名字段,你就改 schema,跑一次 migrate,剩下的类型推导全自动。这比手写 interface 和 DTO 要稳定得多,尤其是表字段多的时候,手写类型一定会漏。

提示:改完 Prisma Schema 后,即使在开发环境也要记得跑npx prisma generate。很多“类型对不上”的问题,根源不是代码写错,而是 client 没有重新生成。

2.3 Next.js App Router 与 tRPC:服务端组件和客户端组件的分工

现在 Next.js 已经是 App Router 的天下了。t3code 在 App Router 下的最佳实践,是区分“服务端组件”和“客户端组件”。

在服务端组件里,你可以直接调用 tRPC 的方法拿数据。比如渲染博客首页时,直接这样:

export default async function HomePage() { const posts = await api.post.getLatest(); return <PostList posts={posts} />; }

注意这里的api.post.getLatest是服务端调用,不经过 HTTP 网络请求,而是直接执行 tRPC router 里的逻辑。这样可以少一次网络往返,也方便做 SSR。

在客户端组件里,比如搜索框、评论表单、交互按钮,就需要用api.post.getLatest.useQuery()。这个 hook 背后是 React Query,内置缓存、重试、loading state。你不需要自己管理请求状态,React Query 会帮你处理大部分脏活。

很多人卡在“服务端组件怎么用 tRPC”上。t3code 的做法是把 tRPC server 的调用暴露成一个异步函数,服务端组件里await api.xxx.yyy()就能拿到结果。但要注意,服务端组件里不能直接用useQuery,因为那依赖 React 的客户端运行时。如果混淆了这两者的边界,会出现“useQuery is not a function”或闪退问题。

2.4 Zod:在边界处守住最后一道防线

类型系统在编译期有效,但运行时数据仍然可能不合法。比如某个表单提交上来的age字段,前端 TypeScript 说是 number,实际请求里可能是字符串"18"。如果没有运行时校验,这个脏数据会一路打进数据库。

t3code 的默认方案是 Zod,它定义 schema 的方式几乎等同于声明类型:

import { z } from "zod"; export const createPostInput = z.object({ title: z.string().min(1).max(100), content: z.string().optional(), });

tRPC 的 procedure 直接把这个 zod schema 当入参校验器:

create: publicProcedure .input(createPostInput) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }),

这样写之后,前端即使传了多余字段或错误类型,tRPC 会在进入 resolver 之前直接抛错。虽然不是绝对安全,但已经把绝大多数非法输入挡在了业务逻辑之外。

我个人的经验是:不要在一个大对象里写巨复杂的 zod schema,尽量把入参校验缩小到“当前操作需要的最小字段集”。这样报错信息更精确,后续维护也更容易读懂。

3. 实操过程:四步快速搭起 t3code 风格的全栈项目

3.1 项目初始化:命令行创建与选项选择

搭建一个 t3code 风格的仓库,最省力的是直接用官方脚手架:

npx create-t3-app@latest my-t3-app

命令跑起来后,它会问你几个选项:

  • 是否使用 Next.js?
  • 是否启用 tRPC?
  • 是否启用 Prisma?
  • 是否启用 Tailwind CSS?
  • 是否使用 Auth.js(NextAuth)?

对于第一次体验,推荐全选。Auth 可以后面再加,但 Prisma 和 tRPC 建议一开始就带上,否则后面接回去的成本略高。

安装完成后,目录结构大致是:

prisma/ schema.prisma src/ server/ api/ root.ts trpc.ts auth.ts db.ts trpc/ react.tsx server.ts app/ page.tsx layout.tsx styles/ globals.css

src/server/db.ts里是 Prisma client 的实例,src/server/api/root.ts用来汇总所有 router,src/trpc/react.tsx是前端专用 hook 的封装。这套结构已经帮你把服务端调用和客户端 hook 的通道铺好了,接下来只需要往里面填业务。

3.2 定义第一个业务模块:从 Schema 到 Router

假设我们要做一个极简博客。第一步,在 Prisma schema 里定义 Post 模型。文件在prisma/schema.prisma,我之前已经给出了 User 和 Post 的例子,直接粘贴进去。

执行迁移:

npx prisma migrate dev --name init

这条命令会在本地数据库创建表,并生成 Prisma client。如果你还没有数据库连接串,开发环境通常用 SQLite,t3code 默认生成的就是 SQLite 配置,简单省事,适合先跑通流程。

接下来创建 tRPC router。在src/server/api/routers/post.ts里写:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), getLatest: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, }); }), });

然后在src/server/api/root.ts里把postRouter挂进去:

import { postRouter } from "./routers/post"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

这一步很关键:export type AppRouter是前后端类型同步的接口。前端所有的 hook 都依赖这个类型定义。

3.3 前端消费:Server 组件直接调,Client 组件走 Hook

搞定后端后,开始消费数据。首页用服务端组件直接展示文章列表:

// src/app/page.tsx import { api } from "~/trpc/server"; export default async function Home() { const posts = await api.post.getLatest(); return ( <main> <h1>最新文章</h1> <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> </main> ); }

然后做一个客户端组件,用于发布新文章:

// src/app/_components/post-creator.tsx "use client"; import { api } from "~/trpc/react"; export function PostCreator() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.getLatest.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const formData = new FormData(e.currentTarget); createPost.mutate({ title: String(formData.get("title")), content: String(formData.get("content")), }); }} > <input name="title" placeholder="标题" /> <textarea name="content" placeholder="正文" /> <button type="submit">发布</button> </form> ); }

utils.post.getLatest.invalidate()是 React Query 的失效机制。发布成功后,让 getLatest 的缓存失效,触发重新拉取。这里的类型都是自动推导的,createPost.mutate的入参类型和utils.post.getLatest.invalidate()是否合法,都由 AppRouter 上的类型定义严格约束。

3.4 环境变量与部署:从本地到线上需要注意的事

本地跑 SQLite 很爽,上线就不能再用 SQLite 了。我建议尽早切换到 PostgreSQL,可以在 Neon、Railway 或 Supabase 上开一个免费的实例,然后把它配到.env里:

DATABASE_URL="postgresql://user:password@host/dbname"

部署到 Vercel 是最顺滑的。只需要在 Vercel 的项目设置里把DATABASE_URL等环境变量填好,然后执行npx prisma migrate deploy把数据库表建好。

注意:Vercel 的 Serverless 环境下,每个函数实例都可能会创建新的 Prisma client。为了避免数据库连接被打满,建议在src/server/db.ts里做全局单例缓存,t3code 模板默认已经处理了,但如果你是自己拼装的版本,一定要加上:

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; }

这个“全局变量缓存”模式在 Serverless 环境里几乎是必须的,否则冷启动时容易报连接数过多。

4. 常见问题与排查技巧实录:最容易踩的七个坑

4.1 常见问题速查表

问题原因解决方法
前端说类型对不上改了 schema 没重新生成执行npx prisma generate
tRPC 调用老是 500入参不符合 zod 规则在 resolver 里先打日志看校验报错
useQuery 数据不更新没调 invalidate 刷新缓存在 mutation 的 onSuccess 里失效目标 query
部署后页面 404App Router 文件位置不对检查src/app下目录名是否拼错
Prisma 连接数超限Serverless 下 client 被反复创建用全局单例缓存
NextAuth 登录报错缺少 NEXTAUTH_SECRET在环境变量里配置随机生成的密钥
Tailwind 样式没生效postcss 或 content 配置漏了检查tailwind.config.ts的 content 数组

这七类问题占了我这一年 t3code 使用经历的八成以上。其中“类型对不上”和“缓存不刷新”几乎天天见,遇到时不用慌,先按表里对应方法排查,九成能解决。

4.2 三个独家调试技巧

第一个技巧是“把 tRPC 错误堆栈拉长”。开发环境里,tRPC 默认会缩放错误信息,有时候看起来只有一行“Unexpected token”。我会在src/server/api/trpc.ts里加一个错误格式化函数,把 zod 校验错误展开成可读文本:

import { TRPCError } from "@trpc/server"; import { ZodError } from "zod"; export const formatError = (error: TRPCError) => { if (error.cause instanceof ZodError) { return { message: error.message, issues: error.cause.issues, }; } return error; };

这样出错时能看到具体是哪个字段校验失败,而不是一脸懵地对着一行字符串。

第二个技巧是“在 router 入口统一打印参数”。早期调接口时,我喜欢在 mutation 里加无数个 console.log。后来发现直接在mutation外面包一层处理,看入参更高效:

create: publicProcedure .input(createPostInput) .mutation(async ({ ctx, input }) => { console.log("create input:", input); // ... }),

简单粗暴,但在联调阶段非常救命。

第三个技巧是用“全量 typecheck 作为提交钩子”。t3code 项目的类型关联非常强,我会在package.json里加一个脚本:

{ "scripts": { "typecheck": "tsc --noEmit" } }

提交代码前跑一遍。只要类型检查过了,前后端接口基本不会出现低级断裂。

4.3 团队协作四原则:让 t3code 项目保持长期健康

代码能跑只是第一步,长期维护更需要约束。我在带团队时通常会立四条规矩:

第一,所有跨端共享的类型放在受控目录里,比如src/server/api/routers下的 router 输出类型就是约定接口,其他人不允许绕过 tRPC 直接手写 fetch,否则类型闭环就破了。

第二,所有入参必须过 zod。这不是为了仪式感,而是给运行时留一条安全线。前端编译期可能骗过你,但 zod 不会。

第三,数据库字段一律用 Prisma schema 管。不要手动改数据库表,否则 schema 和真实表结构一旦开始漂移,后面的迁移会变得很痛苦。

第四,变更数据库结构后,必须提交迁移文件和生成的 client。很多团队 Prisma client 是本地生成、不进 git,这会导致别人拉代码后类型缺失。建议把prisma generate放进 postinstall 脚本,这样所有人都能自动生成。

最后说点我的个人体会

这一年多把好几个项目迁到 t3code 风格之后,我最大的感受不是“技术多先进”,而是“心智负担真的变小了”。以前做全栈,改一个字段要在后端、类型定义、前端三个地方来回叮嘱;现在只要改 schema 和 router,类型会替我盯住所有入口。团队里最明显的变化,是新人看代码更快了,因为 tRPC router 本身就像一份可执行的接口文档,不需要边翻文档边猜。

如果你正在用小项目验证想法,或者团队已经受够了类型不一致的苦,我建议你拿一个非核心模块试试这套组合,跑两周再决定要不要全面切换。还有一个压箱底的小技巧:每次改完 Prisma schema 或 tRPC router,先跑一遍npm run typecheck再写前端代码,这个习惯能帮你省下大量“看着像 bug 其实是类型没同步”的时间。

RELATED READING

延伸阅读

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