做了这么多年 TypeScript 全栈开发我越来越认同一件事项目能不能顺利落地很多时候在第一行命令之前就已经决定了。今年我把手头一个中长期产品项目正式命名成t3code用 T3 技术栈TypeScript Tailwind tRPC把前端、后端、类型契约、数据库访问全部串在一个代码库里跑。跑了大半年从零搭到上线中间踩了不少坑也沉淀出一套可以复用的工作流。这篇文章就把我在 t3code 上从初始化到实战的完整过程拆开讲讲前端、后端都沾的读者应该都能拿去参考。为什么特意起这个名字因为T3本身就代表了一种态度类型安全不是奢侈品而是默认配置。Tailwind 负责样式不越界tRPC 负责接口不被手写字符串污染TypeScript 负责让错误尽早暴露。下面我会按为什么选这套骨架、环境怎么准备、业务代码怎么长出来、踩坑怎么排查、进阶怎么提速的顺序展开尽量把当时的决策逻辑和事后复盘都写全。1. t3code 是个什么项目我为什么愿意把整个工作流押在 T3 栈上1.1 不是又一个脚手架而是一套业务落地骨架很多人听到 t3code 会以为它只是create-t3-app 生成的一个模板。实际上脚手架只是第一层皮。我真正想做的是把一套完整的业务骨架固化下来注册登录、权限模型、多租户数据隔离、后台管理界面、API 路由——这些高频需求如果每次都要重新拼项目一多就全是重复劳动。所以 t3code 在我的定义里是一个可持续演进的开工基线它包含完整的目录规范、类型约定、错误处理策略和部署脚本。新需求来了不是从头搭而是在既有骨架上加业务模块。这半年下来我的体会是选对骨架等于把团队里最容易被争论的 30% 技术细节一次性定了下来。1.2 选型逻辑TypeScript、Tailwind、tRPC 为什么能凑成铁三角先说为什么是这三个组合。TypeScript 是底座这没什么争议Tailwind 解决的是样式领域的局部性问题——每个类名都只影响自身不会像传统 CSS 那样出现全局污染tRPC 解决的是前后端通信的类型漂移问题。用一句话概括 tRPC 的价值你在后端定义一个函数前端可以直接像调用本地函数一样调用它而且入参、出参的类型完全由 TypeScript 推导不需要手写 API 文档也不需要生成 SDK。这和 REST axios 那种后端改字段前端要打开浏览器翻 Network的模式相比体验上的差距是代际的。我实际对比过一组数据同样一个用户资料更新接口REST 写法需要定义 URL 常量、request/response 接口、错误码枚举tRPC 写法只需要在 router 里加一个 procedure前端调用处的类型自动同步。代码量至少省掉 40%而且重构时安全得多。提示T3 栈不是银弹。如果你面对的是纯 C 端高并发的公开 API或者需要给第三方开放接口REST/GraphQL 仍然有存在价值。t3code 这套更适合业务系统、内部工具、中后台产品这些场景里类型安全 快速迭代的价值远大于接口标准化。2. 从零初始化 t3code环境准备和第一行代码2.1 Node 环境与包管理器这一步决定后面所有体验t3code 对 Node 版本有隐性要求。如果你用的是低版本 Node装依赖时会遇到各种原生模块编译失败尤其是几个数据库驱动和trpc/server的依赖链。我当时统一在.nvmrc里锁定了 Node 20 的 LTS 版本并要求团队必须走 nvm 切版本。包管理器我选的 pnpm。说实话npm 现在也不差但 pnpm 的硬链接机制在 monorepo 和多包场景下优势太明显了。t3code 虽然是单仓但依赖数量轻松超过 800 个pnpm 的安装速度和磁盘占用都比 npm 好一个档次。安装完 pnpm 后记得设置镜像源和全局配置pnpm config set store-dir ~/.pnpm-store pnpm config set registry https://registry.npmmirror.com这两个配置能避免大量重复下载尤其是团队里多人协作时公共 store 命中率高了pnpm install基本两秒内结束。2.2 初始化命令与目录结构t3code 的项目初始化我分了两步。第一步用官方脚手架产出基础工程第二步执行了一个自写的scripts/init-env.mjs脚本把环境变量模板、git hooks、ESLint 规则一次性铺好。pnpm create t3-applatest t3code cd t3code pnpm install pnpm run init-env初始化完成后核心目录长这样src/ server/ api/ # tRPC router 入口 db/ # Prisma schema 与数据访问 auth/ # 登录态与权限校验 app/ # Next.js App Router 页面 components/ # 业务组件 lib/ # 通用工具函数 prisma/ schema.prisma seed.ts很多新手在拿到 t3code 后会困惑代码该往哪放。我给团队定的规矩很简单能复用的逻辑进lib带 UI 的进components涉及数据读写的进server/api页面级组合进app。这个划分不是最优的但它是稳定的稳定比最优重要。2.3 第一次启动schema、路由、页面的最小闭环初始化完成不代表工程跑通真正跑通要经历一个最小闭环定义数据库模型 → 生成 Prisma Client → 暴露 tRPC 查询 → 前端页面消费。我以用户列表为例。先在prisma/schema.prisma里定义模型model User { id String id default(cuid()) email String unique name String? createdAt DateTime default(now()) }然后执行prisma db push同步到本地数据库再在 tRPC router 里注册查询import { z } from zod; import { createTRPCRouter, publicProcedure } from /server/api/trpc; export const userRouter createTRPCRouter({ list: publicProcedure .input(z.object({ page: z.number().int().default(1) })) .query(async ({ ctx }) { return ctx.db.user.findMany({ skip: (page - 1) * 20, take: 20, }); }), });前端在 Server Component 或 Client Component 里都能直接调用const users await api.user.list.query({ page: 1 });第一次把这个闭环点亮时我明显感觉到原来前后端联调最消耗精力的对字段名环节在 t3code 里被类型系统直接消灭了。改一个字段名整个代码库的类型检查立刻报警。3. 业务代码怎么长出来类型安全从数据库一路贯穿到按钮3.1 zod schema 与 tRPC router让前后端契约写在同一个文件里t3code 里最让我满意的设计是 zod schema 与 tRPC router 的深度绑定。zod 不仅做运行时校验它还充当了类型的唯一来源。举个例子用户注册时既要校验邮箱格式又要保证密码强度。我写一个公用的 zod schemaexport const registerSchema z.object({ email: z.string().email(邮箱格式不正确), password: z.string().min(8, 密码至少 8 位).regex(/[A-Z]/, 需要大写字母), inviteCode: z.string().optional(), }); export type RegisterInput z.infertypeof registerSchema;然后在 router 里直接用这个 schema 作为 procedure 的入参校验。前端调用时input 的类型天然就是RegisterInput连手写 interface 都省了。一旦后端调整字段例如把inviteCode改成必填前端调用的地方会在编译期直接报错根本轮不到运行时才发现。我特别想强调一点zod 不是给后端用的是给前后端之间的契约用的。很多项目表单验证用一套、接口校验用一套、类型定义又一套三处一旦不一致就是 bug 温床。t3code 的做法是只维护一套 schema让它同时驱动前端表单、后端校验和 TypeScript 类型。3.2 Tailwind 在 Server Component 里的实战t3code 的页面层我用的是 Next.js App Router配合 Tailwind 之后有个容易踩的地方:Server Component 里不能直接写事件处理所以交互都拆到了 Client Component。很多人一上来把所有组件都标成use client结果整个页面变成客户端渲染性能优势全丢。我定下的原则是默认 Server Component只有真正需要 state 或 event 的最小叶子节点才标use client。比如一个筛选面板是 Client Component但它的父容器和兄弟列表都保持服务端渲染。Tailwind 在这种架构下的优势是样式不跟着 JavaScript 走它生成的是纯 CSS无论组件在服务端还是客户端样式都稳定。另外Tailwind 的content扫描配置必须覆盖所有可能写类名的文件。这点我吃过亏后面第四节会专门讲。3.3 权限与状态把谁能不能看这件事也做成类型业务系统绕不开权限。t3code 里我做了一个基于中间件的权限层思路是给每个 procedure 挂一组 allowedRolesimport { z } from zod; import { protectedProcedure } from /server/api/trpc; export const adminRouter createTRPCRouter({ stats: protectedProcedure .meta({ roles: [ADMIN, OPERATOR] }) .query(async ({ ctx }) { // 查询统计数据的逻辑 }), });trpc 中间件会在执行前读取 session 里的 role不匹配就抛业务错误。前端对应的按钮或入口也用同一个角色枚举控制显隐。因为角色枚举是共享类型后端怎么判断、前端怎么显示不会出现后端放行但前端不显示或反过来前端显示了但后端拒绝的撕裂局面。这个设计的核心思想权限不只是后端拦截它也是前端交互的一部分。让两端共享同一份权限元信息比一个写在数据库、一个写在配置文件里可靠得多。4. 实战踩坑t3code 开发过程中我完整的排查链路再好的架构落地时也一定会遇到问题。这一节我记录三个真实踩过的坑重点不是结果而是我排查的完整思路方便你照着复现。4.1 场景一useQuery 不刷新的幽灵数据第一个坑出现在列表页。用户提交新增之后前端调用api.user.create.mutate()后端确实写库成功但列表页的api.user.list.useQuery()没有触发重新拉取页面还是旧数据。我当时的第一反应是缓存没有失效。tRPC 的 react-query 集成确实有这个机制mutation 成功后需要让相关 query 失效。但诡异的是同样代码在另一个页面是正常的。排查链路如下先确认 query key 是否相同。tRPC 的 query key 由路径加输入生成list和list({ page: 1 })是不同的 key。我在调试工具里看到失效代码传的是整个{ page: 1 }而列表页实际使用的 key 因为某个参数序列化问题变成了{}两边对不上。定位到参数序列化问题出在useQuery的入参是一个包含page的响应式对象但在闭包里被提前解构导致 key 随渲染变化。修复方式统一用一个getListKey()函数生成 key并在失效时显式调用utils.user.list.invalidate({ page: currentPage })把 page 传死。这个坑的教训是tRPC 的缓存失效不要靠感觉写最好封装一个invalidateQueries工具函数统一管理路径与输入参数的生成规则。否则局部数据的 key 漂移非常隐蔽。4.2 场景二tRPC 调用报 500却被 TypeScript 完全拦不住第二个坑更隐蔽。后端一个 procedure 内部引用了另一个模块的函数该函数在某种输入下会抛出异常。TypeScript 完全检查不出来因为异常本质上是运行时行为。前端就收到一个笼统的 500没有任何业务信息。排查链路先看服务端日志发现 tRPC 的标准错误格式里message是内部错误的原始信息但默认不会返回给前端。这是 tRPC 的故意设计生产环境隐藏内部错误细节避免信息泄露。我临时给 tRPC 配置了onError回调把错误打到结构化日志里定位到是某个时间格式化函数对null调用了toISOString()。修复分两层。底层是把这个函数的入参类型改成Date而非Date | null从类型上杜绝上层是给 procedure 包裹一层自定义tryCatchProcedure统一把业务异常转换成TRPCError并附带可读的code和message。修复之后前端错误提示从Network Error变成操作过于频繁请稍后再试这种可展示文案。这个经验是别依赖 tRPC 默认的错误透传要主动设计错误契约。4.3 场景三样式丢失与 Tailwind 内容扫描遗漏第三个坑不是逻辑问题而是构建问题。某个页面在本地开发时样式正常部署到生产环境后部分样式缺失。排查链路先确认是不是缓存问题清掉 CDN 缓存后依然缺失排除。检查 Tailwind 编译产物发现缺失的类名根本没有出现在生成的 CSS 里。定位到是content配置只扫描了src/**/*.{ts,tsx}但那个页面用到的样式类名写在了一个.ts文件导出的字符串常量里这个文件路径是shared/目录不在扫描范围内。修复把content扩成[./src/**/*.{ts,tsx}, ./shared/**/*.{ts,tsx}]重新构建样式恢复。这个坑的规律是任何以字符串形式存在的类名都可能逃过扫描。Tailwind 官方建议所有类名都应该是完整静态的不推荐动态拼接。我后来干脆加了 ESLint 规则禁止在代码里动态拼接类名必须用clsx配合显式条件。5. 让 t3code 真正快的几个进阶习惯5.1 别名、ESLint 与提交前检查的配合项目一旦变大最影响速度的不是代码执行而是改代码时的心智负担。t3code 里我做了三件事来减负。一是路径别名。所有模块引用走/前缀不再出现../../../这样移动文件时省去大量引用修改。二是 ESLint 规则的定制。除了默认的eslint-config-next我加了几个团队约定禁止any、禁止未经处理的 promise、强制显式返回类型。前两条能拦下大部分运行时隐患第三条牺牲一点书写自由换来的是跨文件跳转时 IDE 提示的准确度。三是在lint-staged里配合 husky提交前自动跑类型检查和 ESLint。这一步不是摆设它把类型失败挡在 commit 之前而不是 CI 跑一半才发现。pnpm exec lint-staged我在package.json里配置了{ lint-staged: { *.{ts,tsx}: [eslint --fix, tsc --noEmit] } }实测下来团队的错误提交率降了一大截。而且因为tsc --noEmit只检查暂存文件速度可接受。5.2 环境变量与多环境部署边界t3code 从开发到上线的环境变量管理我踩过最简单也最痛的坑。一开始大家各写各的.env.local字段经常对不上前端拿不到DATABASE_URL直接白屏。我后来做了一套约定环境文件用途是否入库.env.example字段模板与注释是.env.development本地开发否.env.production生产构建否由 CI 注入核心原则是入库的只有模板和注释真实密钥一律通过 CI/CD 的 secrets 注入本地开发统一从团队密码管理工具里导出。另外Next.js 的NEXT_PUBLIC_前缀变量会打进浏览器包t3code 里我规定这类变量只用于非敏感配置比如后端地址、埋点 ID。带密钥的变量绝对不允许出现在NEXT_PUBLIC_下这条用 ESLint 的自定义规则做了硬约束——虽然写起来麻烦但能避免手滑把密钥打到前端。5.3 后续扩展我打算怎么继续演进t3code 目前还在持续迭代我最近在做三件事。第一是把 tRPC 的 router 按领域拆分更细让每个领域一个文件避免单个 router 膨胀到上千行。第二是引入更严格的 zod 错误信息标准化把校验失败的字段名和原因统一成前端表单组件可以直接消费的格式减少表单报错的胶水代码。第三是把权限模型从角色枚举升级成策略模式支持数据级权限比如操作员只能看到自己创建的订单。这一步会重构 procedure 的 meta 定义但因为有类型系统兜底我预计改动风险可控。如果你也想在自己项目里复刻 t3code 这套思路我的建议很直接不要照搬目录结构先抓住三个核心机制——类型统一由 schema 驱动、缓存失效集中管理、错误契约显式设计。这三件事做好了框架选什么反而不那么重要。最后分享一个小习惯我每次在 t3code 里新增一个业务模块都会在前两天故意把几个边缘输入喂进去比如空列表、超大分页值、重复提交。这些测试往往能逼出真正的设计缺陷。与其等用户来报障不如让类型系统帮你在开发期就把这些问题兜住。这才是 t3code 这套工程实践真正值钱的地方。