资讯动态

T3 Stack实战复盘:TypeScript全栈项目t3code的选型、坑与工程经验

发布时间:2026/10/26 23:23:18 来源:尧图企业网站定制
最近整理代码仓库时翻到了去年那个代号为 t3code 的项目突然觉得很有必要把这十几个月的实践完整复盘一下。这个项目不是那种三天搞定的 Demo而是从需求梳理、技术选型到部署上线全程走了一遍的 TypeScript 全栈工程。如果你正在纠结前后端到底怎么组织才能少写重复代码、类型安全到底能不能从前端一路贯穿到数据库那这篇复盘应该能给你不少实际参考。我会把整个项目过程中踩过的坑、做过的技术取舍、以及最终沉淀下来的工程经验都摊开来讲不绕弯子。1. 项目代号 t3code 的来源不是装酷是对技术栈的三个执念1.1 从 T3 Stack 说起t3code 这个代号外行人看着像随机字符串但经历过那段时间的 TypeScript 开发者应该一眼就能认出来——它来自开源社区的 T3 Stack。T3 并不是什么新框架而是一组技术栈的组合约定TypeScript、Tailwind CSS、tRPC首字母都是 T凑成了三个 T的组合。T3 Stack 在 Next.js 社区里被大量讨论配套的脚手架 create-t3-app 可以让开发者在几分钟内拉起一个前后端类型安全的全栈项目骨架。当时我接手这个内部工具项目时其实是有选择余地的不一定非要踩 T3 Stack 的坑。团队里有人提议用传统的前后端分离方案Vue 或 React 写前端Node 或 Java 写后端中间走 REST API。这样不是不行但一想到要维护两套类型定义、两套校验逻辑、两套文档我头都大了。刚好手头这个项目规模不大不存在极端性能要求也没有多端并发接入的诉求于是 I 决定了就用 TypeScript 走到底用 t3code 作为代号它代表的是全程类型安全的代码实践。1.2 这个项目实际要解决什么问题t3code 本身是一个团队内部的记录协作工具。说白了就是让团队在一个空间里创建文档、打标签、按人归档。功能听起来不起眼但恰恰因为不起眼才更适合做全栈类型安全的试验田。它既有数据库读写又有用户相关操作又有列表分页筛选足够覆盖一个典型业务系统的核心链路但又不会像电商、支付那样复杂到让人分心。这个项目的定位决定了它的技术选型不用追求最强性能或最全面能力而是追求开发效率最高、改起来最不容易出错。换句话说t3code 的需求模型天然吻合 T3 Stack 的舒适区一个 Web 应用一个前端入口一个配套 API 层不太需要开放给第三方调用。这篇文章后面讲的所有决策都是围绕这个定位展开的。明白了这一点你就不会把 t3code 的选型思路硬套到所有项目上。2. 选型决策复盘为什么 tRPC 赢了 RESTPrisma 为什么必须要有2.1 从传真机到直接打电话的距离在 t3code 立项之前我经历了很长一段时间的 REST API 开发对这种模式的痛点印象非常深刻。你写前端的时候要去查后端接口文档写完后端的时候要去同步字段变更通知两边的类型定义经常出现前端已经改了后端还没更新的错位。这种开发体验就像在传真机时代发商务文件发之前要确认格式发完还要打电话确认对方收到没。tRPC 的思路完全不同。它不引入复杂的接口描述语言不需要生成代码不需要维护契约文件。你在服务端定义一个查询过程procedure前端直接在同一个 TypeScript 项目里调用它类型从数据库模型一路推断到组件 props压根就没有中间商。我用一个不太精确但很贴切的比喻REST 像在不同机构之间递表格tRPC 像同事之间直接在办公室喊一嗓子数据结构对不对TypeScript 编译器当场就能告诉你。当然tRPC 不是银弹。它要求前后端必须共享 TypeScript 类型也就是说你的服务端和前端最好在同一个仓库甚至同一个项目里。t3code 恰好是纯内部工具没有对外接口的诉求所以这个约束完全不是问题反而省掉了一大堆接口文档和联调成本。2.2 为什么选 Prisma 而不选 TypeORM 或 Drizzle数据访问层我几乎没纠结。t3code 比较早期开始时就用了 Prisma看重的是三个点schema 文件单一化、迁移流程成熟、客户端类型推断准确。Prisma 的 schema.prisma 文件集中定义了全部数据模型加一张表、改一个字段都很直观配合 migrate 命令几乎可以让数据库结构跟着代码走。而 TypeORM 用装饰器定义实体类型上和装饰器之间的耦合经常把新手搞晕Schema 变更的历史追溯也不够直观。Drizzle 是后起之秀语法上离 SQL 更近性能表现也不错。但我选型的时候它生态还没现在那么完善加上团队里有人对 SQL 原生写法的舒适度不高最终选择了 Prisma。事后回头看这个选择t3code 的数据模型只有四张表Prisma 的 CURD API 简单到不需要额外封装省下来的时间远超想象。2.3 目录结构里的边界划分T3 Stack 脚手架默认给出的目录是严格分层的src/server 和 src/app 之间的边界非常清晰。server 下面放 router、db、auth 相关的东西app 下面放页面和前端组件。刚开始我嫌这个结构约束太多恨不得自己重新设计但后来发现正是这种看似死板的约定避免了项目中期常见的前端目录里混进后端逻辑的乱象。我在 t3code 里又加了一层自己的约定src/server/api/routers 下的每个文件只对应一个业务域比如 entries.ts、spaces.ts、tags.ts每个 router 内只放与这个域相关的 procedure绝不放跨域函数。这个约定在后面加功能时起了大作用新增一个回收站需求时我只需要在 entries router 里加几个 procedure完全不需要动其他文件。3. 从零拉活脚手架初始化到第一版数据模型落库3.1 初始化项目的实测流程t3code 这个项目不是从零手写配置的直接用官方脚手架命令起步这个没什么丢人的反而能避免大量版本兼容问题。在 Node 环境准备好之后执行npx create-t3-applatest t3code脚手架交互式询问要启用哪些选项时我把 Next.js App Router、TypeScript、tRPC、Prisma、Tailwind CSS、zod 全部勾上。这样拿到的初始代码里路由、trpc 客户端、服务端入口、数据库连接都已经调通我一个下午就能开始写业务代码而不是花三天配 eslint、配路径别名、调 tsconfig。这里面有个很容易忽略的细节create-t3-app 生成的初始化代码默认用的是最新的 Next.js 版本。不同大版本之间目录约定和 API 写法有差异比如 Pages Router 和 App Router 的 tRPC 接入方式就完全不同。t3code 当时锁定的版本组合和网上很多两年前的教程对不上。所以如果你现在拿我当时的代码去跑大概率会碰到构建报错。遇到这种情况第一反应不要怀疑自己先看依赖版本顺着脚手架自动生成的代码往下走最终拿到的必然是正确路径。3.2 数据库模型的设计四张表怎么串起来t3code 的核心数据模型我定为 User、Space、Entry、Tag 四张表。User 就是用户Space 是空间Entry 是用户提交的记录条目Tag 是条目标签。关系上一个用户可以加入多个空间所以有中间表 UsersOnSpaces一个空间下有多个条目一个条目可以打多个标签。Prisma schema 里这段定义核心关系时我觉得最花时间的是中间表。不用 Prisma 的情侣关系写法implicit many-to-many而是显式写了一个 UsersOnSpaces 模型因为将来大概率要在用户-空间关系上存角色字段比如 ADMIN、MEMBER。如果是隐式关系后面想加字段就必须大改 schema迁移成本高。对应的 schema 片段大致是这样的model UsersOnSpaces { user User relation(fields: [userId], references: [id]) space Space relation(fields: [spaceId], references: [id]) role String default(MEMBER) userId String spaceId String id([userId, spaceId]) }这样的设计让 t3code 在支持空间角色时完全没有返工。无限感慨前期多花十分钟考虑关系未来的扩展方向后面能省下半天改表时间。3.3 第一次 migrate 和本地调试模型定好之后执行npx prisma migrate dev --name init本地 PostgreSQL 会按照 schema 生成对应的表结构并同时生成 Prisma Client。这一步完成后TypeScript 代码里 import { prisma } from ~/server/db 就能拿到带完整类型提示的数据访问对象。第一次跑 npm run dev看到浏览器里渲染出脚手架默认页面再随便写一个 query procedure 从数据库读数据确认没有类型报错这时候项目才算真正活了。这里多说一句很多新手第一次跑 migrate 会碰到数据库连接失败基本都是 DATABASE_URL 没配置或者配置错了。create-t3-app 会在根目录生成一个 .env 文件把里面 DATABASE_URL 改为实际的连接串即可注意不要在字符串里写特殊字符时不转义URL 编码问题很容易让人排查半小时。4. 核心链路tRPC procedure 从服务端定义到前端消费4.1 先定义 router再想页面在 t3code 里我养成了一个习惯写页面之前先定义好 router 层。因为 tRPC 的类型是从服务端过程声明推向前端的服务端定义清楚了前端拿到的是什么数据结构就一清二楚几乎没有先写页面再补接口的空间。以条目列表接口为例在 src/server/api/routers/entries.ts 里定义一个 queryimport { z } from zod; import { createTRPCRouter, publicProcedure } from ~/server/api/trpc; export const entriesRouter createTRPCRouter({ listBySpace: publicProcedure .input(z.object({ spaceId: z.string(), cursor: z.string().optional() })) .query(async ({ ctx, input }) { const entries await ctx.prisma.entry.findMany({ where: { spaceId: input.spaceId }, take: 20, skip: input.cursor ? 1 : 0, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { createdAt: desc }, include: { tags: true }, }); return { entries, nextCursor: entries.length 20 ? entries[entries.length - 1].id : null }; }), });我选了 cursor 分页而不是 page 分页。原因很简单cursor 分页在数据持续增长时不会出现插入一条新数据导致下一页重复的问题而且 Prisma 对 cursor 有原生支持。虽然写起来比 page/pageSize 麻烦一点点但 t3code 作为一个记录工具列表数据是高频核心场景值得一开始就做对。4.2 写保护逻辑从 publicProcedure 到 protectedProcedure开发初期所有 procedure 用的是 publicProcedure登录什么的后续再加上。但是上了用户体系后发现不可能让未登录用户乱拉条目列表于是我用 middleware 封装了一个 protectedProcedureimport { initTRPC, TRPCError } from trpc/server; import { type Context } from ../context; const t initTRPC.contextContext().create(); export const protectedProcedure t.procedure.use((opts) { if (!opts.ctx.session?.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.session.user } }); });这样在 entriesRouter 里只需把 publicProcedure 换成 protectedProcedure所有查询和变更就自动带上了必须登录的语义。代码切片极其干净。特别注意tRPC 的错误码 UNAUTHORIZED 对应 HTTP 401前端收到后可以进行统一的跳转登录逻辑不需要每个 mutation 单独处理。4.3 前端调用的真实体验React Query 藏起来了tRPC 最大的爽感在前端调用环节。只要在根组件包了一层 withTRPC子组件里直接用const { data, refetch } api.entries.listBySpace.useQuery({ spaceId: xxx }); const createEntry api.entries.create.useMutation({ onSuccess: () { utils.entries.listBySpace.invalidate(); }, });背后实际用的是 React Query但 tRPC 把 queryKey 的生成规则全部封装掉了你不需要自己给缓存命名也不需要手动拼接参数当 key。invalidate() 会精准地把相关 query 标记为失效并在后台重新拉取。对长期写 REST React Query 的人来说这个体验是颠覆性的少写了几十行 queryKey 定义和失效逻辑并且永远不怕 key 拼错导致缓存不刷新。我在 t3code 开发中还测试了一个很有意思的场景在服务端 procedure 的返回值里加一个字段前端 TypeScript 代码立刻会报错因为用到该返回值的组件没有处理这个字段。这种编译期强制校验在两套体系里可能需要联调时才能发现在 tRPC 里第一天就规避了。5. 部署与联调我在 t3code 上线路上踩到的 5 个坑5.1 环境变量在构建时无故丢失项目部署到服务器时第一次构建就翻车了。本地跑没问题一上 CI 就报错错误信息指向 Prisma Client 初始化失败本质上是 DATABASE_URL 没有被注入到构建环境。排查链路是这样的先确认构建机能不能连数据库然后把 .env 的加载机制打开看发现 create-t3-app 默认依赖 Next.js 的 env 加载而 CI 环境如果不显式把 DATABASE_URL 放进环境变量列表构建阶段就读取不到。解决办法简单粗暴在 CI 的 env 配置里显式声明 DATABASE_URL并且在构建命令前加一步 npx prisma generate确保 Prisma Client 是在最新 schema 下生成的。t3code 之后的所有构建都稳定了。5.2 tRPC 类型递归导致的构建内存溢出第二个坑比较隐蔽。t3code 加了标签功能后Entry 的 include 中包含了 tags而 Tag 的返回结构又关联了 entries如果不在 router 返回时对数据结构做裁剪TypeScript 会拼命推断一个递归类型在构建阶段直接把内存吃爆表现为 next build 进程崩溃日志提示 JavaScript heap out of memory。解决方式不是调大内存而是规范返回结构。我给每个 query 都定义了明确的返回形状比如 listBySpace 只返回条目和标签 ID 列表详情页 peek 再返回完整标签对象。这类问题在团队里如果没人踩过一次很容易在某个深夜让你抓耳挠腮。5.3 数据库迁移文件的分支冲突t3code 是我一个人在维护但中途开了一个 feature 分支同时本地和生产环境各生成了不同的 migration 文件。合并分支时发现两组迁移有重复的表变更差点把数据结构搞乱。Prisma 虽然会记住哪条迁移已经应用到数据库但迁移文件本身有冲突时不会自动合并需要手动整理 migration 目录。我的做法是本地分支的迁移全部回退统一以主分支的迁移为基础在 feature 上仅添加新的增量迁移。后来在多人项目里也沿用了这个约定。如果你们团队也广泛使用 Prisma建议在开启分支前先在主分支上拉一次基准迁移避免两个人同时改 schema。5.4 生产环境里 Next.js 的 Server 端 socket 连接数t3code 上线后迎来了一批真实用户数据库连接池被打满的前兆出现了。Prisma 默认连接池大小是 num_cpus * 2 1我们在两台小规格服务器上部署连接数其实并不高但用户量上来后某些长查询卡住了连接池。最终在 DATABASE_URL 里显式加了 connection_limit 参数把连接池限制到 10再配合 SQL 层的索引优化问题解决。千万别以为 Prisma 的池子会自动扩展数据库侧允许的连接上限才是硬约束。5.5 localStorage 与 SSR 水合不一致前端写了个暗色主题偏好存储结果 Next.js 服务端渲染和客户端水合时读到的值不一样页面闪烁React 告警。这个问题和 tRPC 无关但它是所有 Next.js 项目绕不过的坑。我的解法是在组件挂载后再读取偏好初始渲染统一用默认值。t3code 里所有涉及 window.localStorage 的地方都遵循了这个规则后面再没有闪屏问题。6. 写代码之外的收获t3code 沉淀出的四条工程经验6.1 端到端类型安全的真实收益不在开发期而在重构期t3code 做到一半时我重构了 Space 的角色权限模型。如果是传统前后端分离架构这类重构至少需要同步改数据库、改服务端接口、改前端调用、改文档少说也要一两天。但在 tRPC Prisma 的链路里改 Prisma schema跑 migrate然后跟着 TypeScript 报错一路改下来一个下午就能完成而且改完不担心漏掉哪个调用点。代码里的类型单方向流动schema → Prisma Client → tRPC procedure 入参/出参 → 前端 hook。任何一层的字段变更会被 TypeScript 编译器带到所有依赖它的地方。这种感觉就像你在地铁换乘站里看到完整的地图导航知道自己从哪进、从哪出不会走错也不会有大概能到但不知道在哪站下的风险。6.2 什么时候不适合这套方案实话说如果你做的是开放平台 API需要第三方开发者对接或者需要给移动端多个平台共用一套接口那么 tRPC 基本不合适。第三方拿不到你的 TypeScript 类型trade-off 就改变了移动端要接入也得复刻类型定义等于恩准了最麻烦的环节。t3code 是纯 Web 内部应用所以才能吃到这波红利。另外如果你的项目是典型的高并发低延迟场景比如实时推荐、网关服务tRPC 的开销虽然不大但相比直接暴露 proto / JSON 接口也不占优。这里没有孰优孰劣只有适合不适合。6.3 脚手架、模板和团队协作的关系t3code 这个名字在我这里其实还代表另一种心态能用可靠的模板绝不自造轮子。很多人对脚手架有偏见觉得用了模板就是没有技术含量但我恰恰认为好的脚手架是把最佳实践固化下来让你把精力留给业务。create-t3-app 把路由、权限、数据库、认证这些容易出错的粘合代码全部搞定项目从零到可运行只花了几个小时这笔账怎么算都划算。当然脚手架也会锁住技术方向团队要愿意跟着约定的目录和代码风格走。如果你期望的是每个文件结构都自己捏那 T3 Stack 反而会让你觉得束缚。关键是在效率和自由之间做好取舍。6.4 给项目留一份架构说明文档这是我在 t3code 结束时最后做的事也是我认为全项目最重要的一件额外工作。我在根目录写了一份 ARCHITECTURE.md里面只记录三件事技术栈版本清单、数据模型关系图用文字描述不依赖外部工具、以及添加一个新业务域需要动哪些文件的实操步骤。三个月后自己再回来看依然能快速定位任何一个功能在哪个文件里。这个文档不包含复杂的架构理论完全是给未来的那位开发者看的地图。7. 写在最后如果你也想起一个类似的项目代号别把时间浪费在纠结要不要用最新技术上。t3code 从立项到跑通核心流程最耗时间的不是框架配置而是理解业务需求的边界。技术选型的每一步都服务于同一个目标减少类型信息的重复搬运减少前后端之间的翻译层。如果你打算在团队内部尝试全栈 TypeScript我强烈建议你直接找一个业务简单的内部工具项目练手走一遍 t3code 经历过的从 schema 到部署的完整链路你会对类型安全这四个字有完全不同的体会。最后分享一个具体的技巧在 t3code 里任何 procedure 返回的对象我都习惯在 router 层做一次显式的字段裁剪而不是把 Prisma 返回的完整对象直接往外抛。这会让前端的类型提示更干净也避免把数据库中不该暴露的内部字段读到浏览器端。这个习惯很小但长期维护的价值非常大不信你试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑