我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

T3 Stack 全栈实战:TypeScript、tRPC、Prisma 打造端到端类型安全应用

T3 Stack 全栈实战:TypeScript、tRPC、Prisma 打造端到端类型安全应用 1. 项目概述与选型思路1.1 “t3code”到底是在做什么t3code说白了就是一整套用 T3 Stack 来写全栈应用的实践。T3 Stack 这个名字本身在圈子里很响它并不是某个单一框架而是把 TypeScript、Next.js、tRPC、Prisma、Tailwind CSS 这五个工具拧在一起的标准组合。很多人在网上搜 t3code其实就是在搜这一套东西怎么落地、怎么把前后端打通、怎么避开那些文档里没写清楚的坑。我个人的理解是t3code 代表的不只是“用 T3 技术栈写代码”这个动作更是一种开发理念在同一个语言、同一个类型系统里把数据库、API、页面全串起来前后端能做到类型共享改一个字段编译期就能帮你把整条链路的问题全揪出来。对于中小型项目、内部工具、独立开发者的产品原型来说这套组合的产出效率非常高尤其适合那种“一个人干完前端后端”的场景。这篇内容适合谁看呢我觉得主要面向两类人。一类是已经会点 React 和 Node.js想试试完整全栈框架但被各种配置折腾得头大的开发者另一类是听说 T3 Stack 很火想知道“它凭什么火”“到底值不值得学”的朋友。我会尽量把选型背后的逻辑、实操中容易踩的坑、以及一些文档里不太会写的小经验都讲清楚你可以直接照着步骤跑一遍。1.2 为什么选这套技术组合而不是其他方案先聊一个很多人纠结的问题前后端都做为什么不直接用传统方案比如前端用 Vue/React后端用 Express 或者 Spring Boot中间再搞一套 REST API 文档这套路当然没问题但它有几个绕不开的痛点。第一类型断层。前端一个User对象后端一个User模型数据库一张users表三份定义全靠人肉同步。今天后端加了个字段前端 TypeScript 里不知道等运行时报错才发现改个接口参数名全前端调用处跟着遭殃。这种问题在多人协作里折磨人在单人全栈项目里同样存在——因为你会忘。第二API 胶水代码太多。每一个接口都要写路由、写请求处理、写参数校验、写错误返回前端再写一遍 fetch 封装、类型定义。两边的样板代码加起来可能占了实际业务逻辑的一半。T3 Stack 里最核心的设计就是解决这两个问题。tRPC 让 API 像调用本地函数一样简单且类型直接从后端推导到前端Prisma 用一个 schema 文件生成数据库客户端和类型Next.js 负责页面和接口宿主环境Tailwind 管样式TypeScript 把所有环节的约束统一起来。我在实际项目里最大的感受就是写业务代码的时间占比从以前的一半提升到了百分之七八十剩下的才是真正需要思考的工程问题。当然选这套组合也不是没有代价。它要求你接受“绑定在一块”的生态某个环节的版本更新会牵连其他模块它也不太适合那种后端有大量独立服务、需要单独部署的场景。做技术选型不是找“最好的”而是找“最匹配的”。T3 Stack 的匹配场景就是全栈 TypeScript 开发者、MVP 快速交付、不想维护一堆胶水层的项目。2. 核心细节解析与实操要点2.1 TypeScript 全链路类型安全的关键点整个 T3 Stack 的地基是 TypeScript后面所有对类型安全的追求都建立在这上面。具体到实操层面有几个细节很关键。首先是 tsconfig 的严格模式。用create-t3-app初始化的项目默认帮你开好了strict: true还有noUncheckedIndexedAccess这种很狠的选项。这个选项会把你访问数组元素或对象索引时的类型标记为“可能是 undefined”说白了就是逼你在代码里做边界处理。很多人一开始不习惯觉得烦但它确实是拦住了大量线上才会暴露的空值错误。我自己写代码的经验是别为了编译通过去写!非空断言宁可多写一个判断分支后面维护起来会轻松很多。然后是“类型从数据源单向流动”的原则。数据库层类型由 Prisma 生成路由层的输入输出类型依靠 zod 在 tRPC 里做校验和推断前端的useQuery返回值类型跟着后端 procedure 走。也就是说类型只有一个真正的源头剩下的全靠推导而不是靠你手动定义一大堆 interface 去“复述”一遍。我在代码 review 时看到有人在前端又手写了一遍User类型这就是没理解 T3 的玩法——手动重复定义多了早晚会有一处和新 schema 对不上。最后提一下编辑器体验。因为类型贯穿全链路VSCode 里的智能提示会非常舒服比如在写prisma.user.findMany时你能看到完整的字段列表和过滤条件在调用 tRPC 的api.user.get时入参和返回类型都是精确的。这种即时反馈是这套技术栈最让人上头的部分。2.2 tRPC 的 router 设计与 context 理解tRPC 是 T3 Stack 里最容易“一看就会、一写就懵”的部分。它的核心概念就三个procedure过程、router路由、context上下文。procedure 可以理解为定义了一个可调用的 API 端点router 把这些 procedure 组织起来context 则是每个请求进来时预先构建好的共享数据比如当前登录用户、数据库连接池。新手最容易忽略的就是 context。很多报错都在提示“context 里拿不到某个字段”原因是 context 是在创建 tRPC 实例时通过createContext函数注入的。如果你的 procedure 里用了ctx.session.user但 context 根本没挂载 session 解析逻辑运行时就必定报错。我习惯的做法是先在src/server/api/context.ts里把依赖统一建好包括 Prisma 客户端实例和 NextAuth 的 session 解析然后通过createTRPCContext导出。这样任何 procedure 里都能安全地访问这三样东西。另外tRPC 的中间件机制也很好用比如protectedProcedure就是publicProcedure加上一个“未登录就抛错”的中间件。项目里所有需要登录的接口都统一走这个受保护的 procedureauth 逻辑就不会散落在各个 handler 里。有个设计原则值得记住router 的层级最好跟着业务走不要一个 router 里堆上百个 procedure。比如userRouter、postRouter、commentRouter分开建然后在根 router 里合并。这样文件小、职责清楚排查问题的时候也能很快定位。2.3 Prisma 数据建模的坑与经验Prisma 在这套架构里的角色是 ORM负责和数据库打交道。它最舒服的地方是 schema 文件可读性极好改模型后执行迁移命令就能同步数据库同时自动生成最新的类型。但 Prisma 有几个高频坑我提前讲一下你可以少走弯路。第一个是和 Next.js 开发热重载的兼容性问题。在开发环境下Next.js 的热更新会反复加载模块如果每次加载都new PrismaClient()很快会把数据库连接池撑爆日志里疯狂报连接数超限。官方推荐方案是搞一个全局单例把PrismaClient挂在globalThis上只有不存在时才创建。create-t3-app默认初始化好的模板里其实已经写了这个逻辑但如果你是从零搭的千万别漏掉。第二个是关系字段的命名。Prisma 里一对多关系如果不显式指定relation它默认会根据字段名推断外键名。我遇到过最烦的问题是命名不规范导致外键字段变成userId和authorId混用最后迁移时 Prisma 报出一堆歧义错误。建议所有关系字段在 schema 里都写清楚relation和对应的外键字段宁可多写几行也别依赖默认行为。第三个是迁移流程。开发阶段改 schema 后直接跑prisma migrate dev没问题它会帮你生成迁移文件并同步数据库生产环境则要跑prisma migrate deploy绝不能在生产上执行dev命令。这个习惯务必要养成不然迁移记录和数据库状态错位以后想恢复特别痛苦。2.4 Tailwind 样式组织的个人心得Tailwind 的好处不需要我多吹原子化类名配合响应式前缀确实能大幅减少自定义 CSS。但我也发现很多刚上手的朋友把整个页面都写成一串长的 className到了后期根本没法维护。我的做法是把“布局类”和“视觉类”分开思考。布局类比如flex、grid、justify-between、间距这类直接写在元素上没问题但重复出现的视觉组合比如一个卡片组件的阴影、圆角、边框、背景色我会拆成组件或者用layer components定义成基础类。本质上和你写传统 CSS 时抽象样式的思路是一致的只不过是在 Tailwind 的体系里做。另一个心得是善用 Tailwind 的配置扩展把主题色、字体、间距变量都定义到tailwind.config.ts里。全项目统一用语义化的颜色 token比如primary、danger而不是到处写死#319795。这样哪天换主题色改一处配置就够了不用全局搜索替换。至于暗黑模式用darkMode: class配合一个切换 class 的机制比依赖系统偏好灵活得多用户体验和实现成本之间能取得很好的平衡。3. 实操过程与核心环节实现3.1 初始化项目与目录结构搭建一个 t3code 项目最省事的方式就是用官方脚手架npm create t3-applatest执行之后它会有几个交互式选择我建议第一次跑项目的朋友直接全选默认推荐的项包括 Next.js、tRPC、Prisma、Tailwind、NextAuth。后面熟悉了再按需裁剪。初始化完成后你会看到一个结构非常清晰的项目src/ pages/ // Next.js 页面路由 server/ api/ // tRPC root 配置 db.ts // Prisma 客户端单例 auth.ts // NextAuth 配置 styles/ utils/ prisma/ schema.prisma这个目录结构本身就是一种最佳实践server层的东西不会轻易被前端代码导入从物理上避免了“客户端代码引用了服务端依赖”这种问题。我的建议是尽量保持这个骨架不要为了“顺手”乱改。初始化以后先做一件事把.env里数据库连接串配置好。我本地习惯用 Docker 起一个 PostgreSQL 容器然后连接到localhost端口。如果你不想碰 Docker也可以用 SQLite把DATABASE_URL改成file:./db.sqlite对入门来说非常友好。唯一要注意的是后期要上生产环境时SQLite 的并发能力是短板需要尽早切到 PostgreSQL。3.2 从 schema 到数据库迁移接下来是定义数据模型。拿一个简单的博客系统举例我们在prisma/schema.prisma里写model User { id String id default(cuid()) name String? email String? unique 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? createdAt DateTime default(now()) updatedAt DateTime updatedAt }写完 schema 后执行npx prisma migrate dev --name init这个命令会做三件事对比 schema 和当前数据库差异、生成迁移 SQL 文件、重新生成 Prisma Client。注意看终端输出它会告诉你刚建了哪些表。完成后你还可以用npx prisma studio打开一个可视化的数据库管理界面开发阶段查看数据非常方便。这里有个容易忽略的细节updatedAt字段不需要你在代码里手动赋值Prisma 会自动维护但查询时要记得 select 出来才能拿到。另一个细节是主键生成方式我用cuid()而不是自增数字好处是对象 ID 不依赖数据库生成前端可以先有 ID 再提交数据这个特性在某些交互场景下特别有用。3.3 后端过程与前端调用的完整闭环有了模型接下来把 tRPC 的环路跑通。先在src/server/api/routers/post.ts写一个简单的查询过程import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; export const postRouter createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) { return ctx.db.post.findMany({ orderBy: { createdAt: desc }, include: { author: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(1), content: z.string().optional(), }) ) .mutation(async ({ ctx, input }) { return ctx.db.post.create({ data: { title: input.title, content: input.content, }, }); }), });注意input用了 zod 做结构校验这意味着如果有人传了缺失或非法参数tRPC 会直接返回校验错误代码里不用写一堆手动的 if 判断。query用于读数据mutation用于写数据这个区分在语义上很清晰也让前端的方法调用路径一目了然。把 router 挂到根配置后前端就可以这样调用import { api } from ~/utils/api; // 在组件里 const { data: posts, isLoading } api.post.list.useQuery(); const createPost api.post.create.useMutation({ onSuccess: () { // 刷新列表 utils.post.list.invalidate(); }, });算一下这套组合拳省掉了什么没有手写 REST 路由、没有 request/response 类型、没有 fetch 封装、没有参数序列化、没有错误码约定。你看到的是一串类型安全的函数调用数据在前后端之间的流动完全被类型系统保护着。我在第一次完整跑通这个闭环的时候确实挺感慨的这种“前后端像在一个项目里写代码”的体验才是 T3 Stack 最大的价值。还有一个小细节值得讲invalidate是 tRPC 提供的数据失效机制。一次 mutation 成功后调用对应查询的 key 让它自动重新请求前端状态就拉平了完全不用手写 loading 状态和手动 setState。这个机制在写内部工具的时候尤其省心。3.4 加认证与部署前的检查T3 模板默认集成 NextAuth接 GitHub 登录的流程在官方文档里有完整示例。配置好在.env里填上GITHUB_CLIENT_ID和GITHUB_CLIENT_SECRET后登录链路就能跑起来。配合 session 在 tRPC context 里的透传protectedProcedure就能拿到当前用户。部署方面我实测过最顺的道路是后端数据库用托管 PostgreSQL前端项目推到 Vercel。Vercel 能自动识别 Next.js环境变量在控制台里填一次就完了。需要注意几个点Prisma 在构建阶段会执行generate确保生产环境的构建脚本里带上了这一步迁移不要在构建时自动跑 dev 命令正确做法是发布前手动对生产数据库执行prisma migrate deploy环境变量里的NEXTAUTH_SECRET一定要设置成强随机字符串否则每次部署 session 都会失效。还有如果你的项目有定时任务、音频转码这种长时间运行的操作那就不太适合全放在 Vercel 的函数里要考虑单独的 worker 服务。这个属于架构层面的扩展T3 模板本身解决不了但至少它没有锁死你tRPC 和 Prisma 都支持拆出去独立使用。4. 常见问题与排查技巧实录4.1 tRPC 类型报错排查tRPC 最常遇到的问题就是前端在useQuery调用时报类型不匹配。最常见的诱因有三个后端路由没有重启开发服务器导致类型没更新在错误的位置导入api客户端比如从server路径导入输入参数和 zod schema 不一致。排查的第一步永远是重新跑一下tsc --noEmit看具体报错在哪个文件而不是盯着浏览器红屏猜。我遇到过好几次“明明代码没问题但一直报错”的情况最后发现是 dev server 没重启tRPC 生成的类型还停留在旧版本。T3 生态最新版本的脚手架已经用 next dev 的缓存机制优化了这个问题但如果你用的是稍早的版本重启不丢人。4.2 Prisma 迁移冲突与连接池问题迁移冲突的场景主要发生在多人协作时。你本地执行 migrate 时发现和远程数据库状态不一致提示需要 reset。这时候别急着跑migrate reset它会清空所有数据。如果数据不重要那没问题如果数据库里有真实的业务数据正确做法是用migrate diff手动对比差异或者让负责数据库结构的人先合并迁移记录。连接池问题前面提过核心就是 PrismaClient 单例。如果你发现开发环境日志里频繁出现too many connections之类的错误优先检查是不是每次请求都 new 了一个 PrismaClient。单例代码非常简单但效果立竿见影import { PrismaClient } from prisma/client; const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const prisma globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma prisma; }4.3 Tailwind 样式不生效的怪问题Tailwind 偶尔会出现类名写了但样式没出来的情况绝大多数和内容扫描路径有关。Tailwind 通过扫描源码文件里的类名来生成样式表如果你的页面文件放在了没有包含在content配置里的目录类名就不会被识别。默认模板把content配置成了./src/**/*.{ts,tsx}如果你自己新建了非src下的目录放组件别忘了更新配置。另一个常见问题是对 Tailwind 生成顺序的误解。CSS 里的层叠规则是具体性优先而不是书写顺序优先。如果你在全局 CSS 里写了一个.btn类又在元素上用了 Tailwind 的mt-4两者是否冲突要看具体样式属性。遇到“样式被覆盖”的情况先打开浏览器开发者工具确认是哪条规则生效再决定是调整具体性还是使用!important。别动不动就!important治理成本极其高昂。4.4 前后端功能联调中的环境问题最后说说联调环境。很多人本地开发正常一部署就出各种怪问题十有八九是环境变量或域名配置问题。NextAuth 在服务端读取NEXTAUTH_URL前端访问的 URL 和在环境变量里写的必须一致否则 OAuth 回调会报 redirect 错误。本地是http://localhost:3000生产是https://你的域名两套环境不要混填。跨域问题在 T3 项目里反而少见因为前后端同源部署在同一服务上。如果以后拆分了服务或者需要给手机 App 提供接口才需要考虑单独暴露一个 REST API 或给 tRPC 加 CORS 配置。大部分时候我们都不需要为这个操心这也是同源架构很大的好处。一点个人总结跑过几个真实项目之后我的感受是 t3code 这套打法真正解决的不是“不会写代码”而是“代码之间怎么配合”的工程问题。它把类型系统当成一条贯穿始终的纽带把数据库、接口、页面之间的隐性约定变成显性约束让开发者的注意力重新回到业务本身。如果你刚接触它我的建议是先照着模板跑通一个最小闭环——建表、写一个 procedure、前端调用一次。别急着上认证别急着优化部署先把那条“类型从数据库流到页面”的链路亲手走一遍。过程中遇到类型报错不要慌编译器正在帮你提前发现问题这恰恰是它的价值所在。最后分享一个小技巧把npx prisma studio和npx tsc --noEmit这两个命令牢记于心。前者让你随时看到数据后者让你随时验证类型。开发时交替使用这两个工具配合热更新效率会高很多。等哪天你对这套组合已经“闭眼能写”了再考虑往里面加超时、限流、灰度发布这些更偏平台化的能力就不会被技术细节绊住脚了。
返回列表