首页 / 资讯中心 / 文章详情

T3 Stack实战:t3code项目带你实现全栈类型安全

T3 Stack实战:t3code项目带你实现全栈类型安全 ★ FEATURED ARTICLE
最近整理代码仓库翻出一个我维护了大半年的项目代号叫 t3code。名字看着有点随意其实就是 T3 Stack 的实战代码库。T3 Stack 是这几年全栈圈里讨论度很高的一套技术组合核心成员是 Next.js、TypeScript、tRPC、Tailwind CSS 和 Prisma用官方脚手架 create-t3-app 一条命令就能拉起来。这套东西解决的真正问题不是代码能不能跑而是前后端之间的类型安全到底能不能贯穿到底。如果你受够了接口文档跟不上代码、改一个字段要翻七八个文件、前后端联调全靠口头约定那 t3code 这个项目就是一个完整的参考样本。这篇就基于我的真实维护经历把这套技术栈从选型到部署逐个环节拆开讲包括那些文档里不会写的坑。1. t3code 到底是什么先看清 T3 Stack 的设计初衷1.1 技术栈的来龙去脉与核心哲学T3 Stack 最早的推动者是 Theodo 社区的开发者后来因为 create-t3-app 这个脚手架工具的流行整套组合开始被大量全栈项目采用。T3 这个名字有过争议早期说法是指 TypeScript、Tailwind、tRPC后来的官方口径更偏向 Typesafe, Tailwind, tRPC把类型安全提到了第一位。这个细节很关键因为它直接解释了为什么这套技术栈会把 tRPC 放在核心位置——它不是让你少写代码那么简单而是让前后端共享同一套类型定义从数据库到组件 props 全部走 TypeScript 的静态检查。我当初做 t3code 的时候手头有一个比较典型的小团队项目三个人维护一个偏前端一个偏后端一个两边都沾。过去用 REST 接口最头疼的不是写接口而是接口返回结构变了之后前端根本不知道。后端改个字段名前端页面就静默崩掉等用户反馈才排查出来。t3code 本质上就是想验证一个问题如果类型定义成为前后端之间唯一的通信协议能不能把这类问题从根上消灭。答案是可以但前提是你得完全接受这套技术栈的约定。T3 Stack 不是把五个框架硬拼在一起它把 Next.js 当成应用外壳TypeScript 是底层语言tRPC 是前后端通信层Prisma 管数据库建模Tailwind 管样式。每一层都围绕类型这个核心展开所以一旦你用了它就不要想着我某个接口单独用 REST 实现就行混搭会把你拖入类型断裂的泥潭。1.2 这套组合适合什么场景不适合什么场景做技术选型最怕什么火用什么所以我把 t3code 的适用边界也一并测了一遍。先说适合的内部工具、后台管理系统、中小型 SaaS 产品、个人作品集、快速原型验证。这些场景有一个共同特征——功能迭代速度远大于团队规模你需要在极短时间内把数据库表、接口、页面串起来而且希望业务逻辑尽量集中在服务端前端只管调用。t3code 的典型路径是Prisma 定义模型迁移数据库tRPC 暴露查询和变更Next.js 页面直接调用整个链路带着类型检查几乎没有接口联调这个环节。不太适合的场景我也踩过对外开放的大规模 API 平台、需要多端iOS / Android / 第三方消费的接口层、复杂微服务架构。原因很简单tRPC 的强绑定依赖端到端 TypeScript如果你的客户端根本不是 TS 生态或者接口需要给公司外部的大量第三方开发者使用那 REST 或者 GraphQL 依然是更好的契约方案。还有一点如果你团队里有人对 TypeScript 本身比较抵触或者数据库模型经常被非开发角色直接修改T3 Stack 的收益会打折扣。我在 t3code 的 README 里写过一句话它不是银弹它是单应用全栈型项目的最优解之一。你把这句话作为选型判断标准基本不会跑偏。2. 五块基石逐层拆解为什么是它们五个2.1 Next.js不只是 React 框架更是全栈容器T3 Stack 把 Next.js 作为最外层骨架看中的不只是 SSR 和静态生成这些渲染能力更关键的是它可以同时承载前端页面和后端 API 逻辑让我们在一个代码库里完成全栈开发。Next.js 的 App Router 模式引入 Server Components 之后很多页面可以直接在服务端读取数据、渲染内容不需要客户端再发一轮请求这对性能和首屏体验的提升立竿见影。不过这里有个容易误解的地方Next.js 本身也能写 API Routes为什么还要引入 tRPC我在 t3code 里两者的分工是这样的——Next.js 的 Route Handlers 用来处理那些非业务查询的场景比如 Webhook 回调、第三方 OAuth 跳转、需要单独部署的端点而所有面向页面 UI 的数据读写全部走 tRPC。为什么这样切因为 Route Handlers 没有端到端类型保障适合做边界协议不适合做业务内部通信。分清这个边界整个项目结构才会干净。2.2 TypeScript让类型检查成为团队协作的粘合剂没有 TypeScriptT3 Stack 的另外四块基石全都失去意义。tRPC 的客户端调用依赖类型推断Prisma Client 的查询结果依赖类型生成Next.js 的 props 校验也依赖类型定义。所以 TypeScript 在这套技术栈里不是一个可选语法糖它是通信协议本身。实际项目里我为 t3code 定了几条规矩禁止在服务端和客户端之间传递any禁止用as断言绕过类型检查禁止在 tRPC 的返回类型里使用Omit这类技巧性太强的工具类型尽量用 Prisma 生成的类型作为唯一数据源。刚推行的时候团队觉得太严了但跑了一段时间后所有人都有同一个感受重构成本直线下降。以前改一个表结构要手动找哪些接口字段受影响现在 Prisma 模型一改npx prisma generate一跑TypeScript 编译器会把所有断点标出来你按着错误列表改就行。2.3 tRPC没有 API 文档类型就是文档tRPC 是 T3 Stack 里最有辨识度的一块。它做的事可以这样理解你在服务端定义一堆 procedure可以简单理解为可调用的函数然后客户端导入type Router typeof appRouter就会得到一个完全类型安全的调用对象。不需要手写请求函数不需要维护接口路径更不需要在客户端定义一套重复的接口类型。我在 t3code 里写过一个非常典型的例子服务端有个getProject的 procedure接收projectId: string返回Project对象。在客户端代码是这样写的const project trpc.project.getProject.useQuery({ projectId: abc123 });project的类型自动就是Project对象。你如果把projectId写成数字IDE 直接报错如果服务端改了返回结构客户端用到project.name的代码也会立刻标红。这就是 t3code 的核心体验写代码的速度不会比写 REST 快太多但改代码的安全性完全是另一量级。这里有个设计细节值得说tRPC 底层集成了 React Query所以useQuery天然带缓存、重试、失效机制。但这既是优点也是坑后面我在常见问题里会专门展开。2.4 Tailwind CSS样式层为什么也讲类型很多人把 Tailwind 理解成省得写 CSS的工具但在 T3 Stack 的语境里它承担的作用更像设计约束的强制器。Tailwind 的 class 是在构建时静态扫描的只有真正出现在源码里的类名才会被生成这保证了最终产物体积稳定。更实际的好处是它让你在页面里不再需要为每个元素单独维护 CSS 类名和层级关系内联的原子类本身就是一份可见的样式文档。我在 t3code 里使用 Tailwind 的一个心得是一定要在项目初期把主题配置颜色、字体、间距、圆角定义好做成 design token。否则团队里每个人自己定义颜色值很快整个项目就会变成五彩斑斓的灰。2.5 Prisma从数据库到类型系统的最后一公里Prisma 在这套技术栈里的身份是 ORM但它的杀手锏不只是查询 API 好用而是模型定义本身就参与类型推导。你在schema.prisma里定义一个模型运行prisma migrate和prisma generate后Prisma Client 的查询函数就是完全类型安全的返回的字段、关联关系、过滤条件全部有提示和校验。我在 t3code 里用 PostgreSQL 配合 Prisma写过最基础的用户和项目关系模型。定义大概是这样model User { id String id default(cuid()) email String unique name String? projects Project[] createdAt DateTime default(now()) } model Project { id String id default(cuid()) name String description String? ownerId String owner User relation(fields: [ownerId], references: [id]) createdAt DateTime default(now()) }这个定义的强大之处在于当你写prisma.project.findMany({ where: { ownerId: userId } })时TypeScript 能推断出返回的是一个Project[]并且这个类型可以直接通过 tRPC 传递到前端。从数据库字段到页面显示中间没有任何一层需要你手动维护类型。这就是 T3 Stack端到端类型安全的真正含义。3. 从零搭建 t3codecreate-t3-app 全流程实操3.1 初始化命令与选项逐项解析搭建 t3code 最省力的方式是用官方脚手架命令如下pnpm create t3-applatest执行后它会问你项目名字然后弹出一系列选项。这里我逐个说下我当时的取舍。Next.js必选没有它整套框架跑不起来。TypeScript必选T3 的灵魂。Tailwind CSS我选了毕竟样式层需要快速输出。tRPC必选前端调用链路的核心。Prisma我选了数据库端到端类型安全就靠它。ESLint / Prettier全部选上团队协作时统一代码风格能省无数吵架。src/ 目录别名建议选/这个路径别名在很多项目里实际上是默认约定后面写 import 能少敲不少字。初始化完成后项目会自带一个完整的前后端文件夹结构。我为什么推荐用脚手架而不是手动搭因为 create-t3-app 已经把 tRPC 的初始化、Next.js 的 App Router 配置、Prisma 的客户端封装都处理好了你手动拼装这些零零碎碎的东西至少要多花两天而且很容易在版本兼容性上踩坑。3.2 目录结构约定每一层放什么t3code 的目录结构遵循 T3 Stack 的默认约定我实际跑下来觉得非常顺手核心结构大致如下src/ app/ # Next.js App Router 页面 pages/api/trpc/ # tRPC 的 HTTP 入口 server/ api/ routers/ # 业务路由按模块拆分 root.ts # 根路由汇总所有子路由 trpc.ts # tRPC 初始化配置 db/ # Prisma Client 实例 trpc/ client.ts # 客户端 tRPC 调用封装 server.ts # 服务端调用封装 query-client.ts # React Query 客户端配置这套结构的核心逻辑是前后端都在一个仓库里但边界清晰server/下面放所有服务端逻辑trpc/下面放客户端连接服务端的桥接层app/下面只负责渲染和交互。我在实际迭代中发现只要遵守页面组件不直接操作 Prisma、Routers 里不写页面逻辑这条纪律项目体积膨胀也依然可控。3.3 第一个完整 CRUD从数据库到页面的类型安全闭环我从 t3code 里提炼一个最小可跑的 CRUD 流程带你把整条链路走一遍。第一步在schema.prisma里定义模型。沿用上面的 User 和 Project 例子运行npx prisma migrate dev --name init这会创建数据库表并生成 Prisma Client。第二步在server/api/routers/project.ts里写 tRPC 路由。一个简单的列表加创建import { z } from zod; import { createTRPCRouter, publicProcedure } from ../trpc; import { prisma } from ../../db; export const projectRouter createTRPCRouter({ list: publicProcedure.query(async () { return prisma.project.findMany({ include: { owner: true } }); }), create: publicProcedure .input(z.object({ name: z.string(), description: z.string().optional() })) .mutation(async ({ input }) { return prisma.project.create({ data: input }); }), });这里有几个点新手容易忽略publicProcedure表示无需登录真实项目里需要换成protectedProcedure配合鉴权中间件输入校验用的是 zod它不是可选项而是防止脏数据打进数据库的第一道防线。第三步在根路由里挂载子路由export const appRouter createTRPCRouter({ project: projectRouter, }); export type AppRouter typeof appRouter;第四步在客户端页面里调用use client; import { trpc } from /trpc/client; export default function ProjectList() { const { data, refetch } trpc.project.list.useQuery(); const createProject trpc.project.create.useMutation({ onSuccess: () refetch(), }); return ( div {data?.map((project) ( div key{project.id}{project.name}/div ))} button onClick{() createProject.mutate({ name: 新项目 })} 创建 /button /div ); }注意几个关键点组件必须用use client标记因为useQuery是客户端 hookscreate调用成功后我手动refetch因为 tRPC 的自动失效需要额外配置。如果你跳过这两个细节很容易遇到数据没更新或者页面报错 Hydration failed。这一步跑通之后你就拥有了一个从 PostgreSQL 表结构到 React 组件状态全部由 TypeScript 连接起来的最小闭环。后续所有业务功能本质上都是在这个闭环上添加更复杂的查询、变更和权限控制。4. 深入核心类型安全背后的关键细节4.1 tRPC 的输入校验与错误处理很多教程讲了 tRPC 有多爽但对错误处理往往一笔带过。我在 t3code 里踩过最深的坑就在这里。tRPC 默认的错误信息不会完整返回给客户端。在开发环境你可能还能看到栈信息生产环境直接就是一串笼统的错误码。所以我在每个 mutation 里显式抛出带业务语义的TRPCError例如import { TRPCError } from trpc/server; if (!hasPermission) { throw new TRPCError({ code: FORBIDDEN, message: 当前用户没有操作该项目的权限, }); }同时客户端需要做一个错误消息的解析层把TRPCError的 code 映射成用户可读的提示。这个问题不处理好tRPC 项目上线后排查问题会非常痛苦因为你看到的错误码可能只有一个数字完全不知道是哪一环出的问题。4.2 Prisma 关联查询与 N1 陷阱Prisma Client 用起来确实舒服但它的关联查询存在一个性能陷阱。我在 t3code 的列表页遇到过查询 50 个项目每项都include了 owner结果数据库日志显示发出了 51 条 SQL 语句。这就是典型的 N1 问题。解决办法有几个思路。第一个是减少include的层级只查需要的关联。第二个是对于列表场景用更细粒度的字段选择代替完整对象。第三个也是我最终采用的是在 tRPC 的 query 函数里接受一个可选参数前端根据场景决定是否加载嵌套数据。把数据加载策略的控制权放在调用方手里而不是在服务端硬编码这样既不牺牲类型安全又能兼顾性能。4.3 Server Components 与 tRPC 的边界T3 Stack 默认生成的模板里所有页面组件都是客户端组件。但我发现 Next.js 的 Server Components 其实很香尤其是那些不需要交互的展示型页面完全可以在服务端把数据取好再渲染。不过在服务端组件里直接调用 tRPC 会出现一个问题tRPC 客户端初始化依赖 React Context而 Server Components 里没有这套上下文。我在 t3code 里的解决方案是在server/trpc.ts里导出一个直接调用 procedural 的函数或者把数据获取抽成独立的函数在 Server Component 和 Client Component 里分别复用。这个边界需要一开始就规划好否则写一半再改会很痛苦。4.4 React Query 缓存带来的幽灵数据tRPC 深度绑定 React Query这意味着你查过的数据会进缓存。好处是重复查询很快坏处是如果你在另一个页面改了数据原来的缓存可能还停留在旧状态页面上出现改完没更新的错觉。我为此定了一套缓存更新约定创建、编辑、删除操作成功后优先用onSuccess回调手动更新该对象的缓存列表查询使用稳定的input作为 key避免因为对象引用不同导致缓存失效。这套约定一开始执行成本略高但项目到了中期它的价值就体现出来了——不会出现那种刷新一下又回来了的诡异 bug。5. 部署与生产环境从本地能跑到线上能扛5.1 部署方案选型t3code 的部署我先后试过几种方案。最简单的是 Vercel对 Next.js 的适配几乎是零改造环境变量、按需构建、边缘网络全给你安排好了。如果你用的数据库是 PostgreSQL 提供商且连接数不高Vercel 云数据库是一个起点不错的选择。但如果你对部署环境有私有化要求或者后端不仅仅跑 Next.js那 Docker 一台云主机是更稳的路径。我在 t3code 里写过一个 Dockerfile核心就是多阶段构建先用 node 镜像装依赖并构建最后用一个极简的 runtime 镜像跑next start。这里要特别留意 Prisma 的构建产物运行时镜像里必须包含生成后的 Prisma Client否则启动会报找不到prisma/client的错。5.2 环境变量与配置隔离T3 Stack 的模板自带.env和.env.example机制但我在多个项目里发现一个共性问题DATABASE_URL这种关键变量大家喜欢直接放在.env里然后一路复制最终一个项目的数据库连接串出现在四五个地方出了事根本没法查。我在 t3code 里做了两件事第一所有环境变量只在env.js或启动入口统一读取和校验第二按照部署环境维护三个文件.env.development、.env.staging、.env.production再写脚本按环境加载。这样每个环境用哪个数据库、哪个密钥一目了然且本地开发不会被生产库误连。5.3 日志、监控与运行观测本地开发可以用控制台日志定位问题上线后就必须把日志给聚合起来。我在 t3code 里接了一层简单的结构化日志每个 tRPC 请求进来时输出请求路径、耗时、状态码、用户 ID。不要小看这个它帮我定位了至少三次线上问题比如某个查询在高峰期耗时暴涨靠的就是这些日志。除此之外Prisma 有一个log配置可以输出慢查询。我把warn级别的日志单独拉出来超过 500ms 的查询也会被记录。这样一个简单的观测体系让这套技术栈在生产环境里不至于成为黑盒。6. 常见问题速查表你大概率也会遇到的坑我把 t3code 开发过程中最常踩的坑整理成一张速查表每个问题都附了解决思路方便你直接对着排查。问题现象根本原因解决思路调用 useQuery 返回 undefined 但接口有数据客户端组件没有加 use client在组件文件顶部加指令确认 hooks 在客户端环境运行mutation 成功但页面数据没变React Query 缓存未失效在 onSuccess 里调用 refetch或配置全局 invalidate 策略Prisma 查询报 P2022 错误表结构或字段与 schema 不一致运行prisma migrate dev同步迁移检查 DATABASE_URL生产环境运行找不到 Prisma ClientDocker 镜像未复制生成后的客户端多阶段构建中单独复制node_modules/.prisma或直接在运行时执行 generatetRPC 错误信息不具体未使用 TRPCError 显式抛错在每个有业务异常可能的 procedure 中抛 TRPCError并包含可读 messageApp Router 页面刷新后报 Hydration failed客户端和服务端渲染结果不一致检查是否在渲染中用了随机值、时间戳或浏览器 API并隔离在 useEffect 中执行查询列表数据量增大后首屏明显变慢没有使用分页全量加载tRPC 的 query 里增加 take / skip配合 cursor 分页更平滑Tailwind 类名在动态拼接后不生效动态字符串未被静态扫描避免拼接完整类名使用 safelist 或完整类名的三元表达式写法连接数据库偶尔报连接数超限serverless 环境下连接池未复用用连接池方案或单例 Prisma Client避免每次请求新建连接这张表不能替代你对每个问题的深入排查但它能帮你把 80% 的低级问题快速定位掉。我自己在 t3code 迭代过程中翻这些记录的频率比翻官方文档还高。7. 维护 t3code 大半年后我的一点真实体会技术方案选型这件事很多时候不是选最炫的而是选那个能让团队安心改代码的。T3 Stack 带给我最大的改变是从前端到数据库的每一层类型系统都像一张无形的电网你碰错了地方它会立刻报警而不是等你上线后由用户来碰。T3 Stack 并不完美tRPC 在极大规模接口场景下不如 REST 灵活Next.js 的版本迭代也会带来不少升级成本。但如果你做的正是单应用全栈型项目这套技术栈的开发体验和类型安全感确实值得亲身体验一次。t3code 这个项目让我把类型安全不是口号而是工程纪律这句话验证了个透。
阅读完成 · 觉得有帮助?
咨询建站