做了这么多年 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-app@latest 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 schema:
export 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.infer<typeof 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 挂一组 allowedRoles:
import { 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 这套工程实践真正值钱的地方。