t3code这个名字第一次出现在我仓库列表里的时候,我甚至没想好它是干嘛的。那会儿我刚接触T3技术栈,被它的全链路类型安全折腾得又爱又恨——爱的是写起来确实舒服,恨的是每次搭一个新项目都要重复一遍建目录、配TS、接数据库、搞CI的流程。后来我索性把这些重复动作全部抽出成一个独立项目,代号就叫t3code。简单说,它是一套基于T3技术栈的“三合一开发工具箱”:既能帮你从零生成项目骨架,也能作为类型安全的API代理层,还附带一组常用的CLI自动化脚本。
如果你正在做前端/全栈开发,手里同时管着好几个TypeScript项目,觉得每次初始化新项目、联调接口、部署上线都要反复做一堆机械操作,那这篇文章基本就是为你写的。我会把t3code为什么这样做、核心模块怎么实现、部署实测遇到哪些问题、踩完坑之后我留下哪些经验,全部拆开讲清楚。没有理论堆砌,全部是能直接抄作业的实践过程。
1. 为什么会有t3code:从每天重复的Ctrl+C/Ctrl+V说起
1.1 我到底在重复什么
先说实话,我并不是一开始就想造一个工具箱的。那时候手头同时维护五个左右的业务项目,每个项目的技术栈高度相似:Next.js做前端、tRPC做API层、Drizzle管数据库、Zod校验入参。听起来很美好对吧?但实际上,每接到一个新需求或者新项目,我都要重复做这些事:
- 手动初始化项目,装依赖,配置ESLint、Prettier、tsconfig
- 把上上个项目里已经调好的tRPC路由、数据库连接代码复制过来改名字
- 配置环境变量模板,写好开发/测试/生产三套配置
- 部署前手动检查构建产物、类型检查、lint
- 每次联调后端接口时,来回切换Postman和代码,还要自己维护一份接口文档
这些工作单独看都不难,但放在一起就是巨大的时间黑洞。我粗略算过一笔账:一个最简项目的初始化加基础联调,最快也要三四个小时,其中至少一半时间是无效重复劳动。更烦的是,不同项目之间会出现细微的配置漂移——这个项目ESLint规则和那个不一样,那个项目的Drizzle字段命名风格又和上一个不同。这些问题平时不显眼,等人多了、迭代快了就会开始恶心人。
1.2 给t3code做减法
刚立项的时候,我其实想做一个大而全的平台,功能清单能写满两页纸。后来冷静下来,给自己定了三条铁律:
- 只解决“创建-联调-部署”三段流程里的高频重复动作
- 不碰业务本身,不装饰花哨功能
- 所有能力都通过CLI或轻量服务暴露,不让它变成又一个要维护的重型系统
于是t3code被收敛成三个核心部分:一个生成项目骨架的脚手架、一个提供类型安全API代理的服务层、一组常见的自动化脚本。我给它起名的时候动过一个念头:t3code里的t3,一方面因为技术栈确实是T3那一挂的,另一方面也希望它像“Toolbox 3in1”一样,一个工具解决三类问题。前期的这个定位,直接决定了后面所有设计——凡是和“三合一”定位无关的需求,一律砍掉。
2. T3约定和选型逻辑:Type-Safe优先是如何落地的
2.1 为什么技术栈选成这样
t3code的技术选型基本遵循了T3 Stack社区的主线思路,核心原则只有一条:类型信息从数据库一路贯通到前端页面,不让任何一环断裂。
| 层级 | 选型 | 理由 |
|---|---|---|
| 应用框架 | Next.js App Router | 服务端/客户端组件天然分层,和tRPC的继承式上下文很搭 |
| 接口层 | tRPC | 过程式调用本身就是类型安全的,不用手写REST文档 |
| 校验层 | Zod | schema即类型,一个定义同时服务运行时校验和TS类型推导 |
| 数据层 | Drizzle ORM | 轻量、SQL风格直观,表结构类型可以反向生成 |
| 脚本运行时 | Bun | 启动快、内置TS支持,写自动化脚本比Node少很多仪式感 |
如果你还没用过这套组合,我建议不要一次性全部上手,否则容易晕。t3code之所以绑死这套选型,是因为只有全链路都用类型推导同源的方案,才能让“改一个数据库字段,前端立刻提示报错”这种体验成立。我自己最早是先把Zod和tRPC接好的,确认这套链路顺了,才逐步补上Drizzle和Bun的。
2.2 类型安全到底省了什么
很多第一次接触的人会问:类型安全这东西,真能提升效率吗?我的体验是:它不是单纯“让你少写类型”,而是把普通回调、接口状态、异常情况都变成编译期需要面对的问题。
举个具体场景。t3code的API代理模块里,一个入参错误通过Zod的safeParse在边界上直接拦掉,客户端拿到的永远是一个符合Schema的结构体,不用在component里做一堆“data可能是null对不对”的判断。这种代码写起来体感很顺,因为编辑器的自动补全基本不会给你弹出错误字段。类型推导在跨文件改动时的好处尤其明显——我重构一个数据库表字段,Drizzle的表类型更新之后,tRPC route、前端页面的调用处全部同步变红,改完就全绿,不会出现“线上请求在运行时才发现字段名字拼错了”的情况。
当然,T3这套也有学习门槛。比较常见的坑是:初学者会把所有东西都塞进一个router里,导致类型层面没问题、但逻辑层面耦合严重。我在t3code里强制按模块分router,每个router只暴露当前业务域需要的procedure,效果比一个大router好维护得多。
2.3 用Zod统一校验边界的细节
校验这块,纯用Zod本身不难,难点在于把校验规则放在合适的位置。t3code的做法是:在src/schemas目录下集中管理所有共享schema,服务端入口用protectedProcedure包装,客户端用useForm的本地状态配合schema做前端预校验。这样同一份规则导出了前端用的TS类型、后端路由的入参校验、以及部署时环境变量的格式校验,三处不会出现规则漂移。
这里有一个我早期很容易犯的错:环境变量的校验用的不是Zod,而是自己手写的if-else。结果就是项目越来越多之后,有些变量类型对不上根本没人发现。后来我在t3code里统一用Zod的envSchema去解析process.env,parse失败就直接终止启动并打印缺了哪个字段。这一个改动,让所有接入了t3code的项目都不再出现“远程环境少配了一个变量导致启动时报个Google都搜不到的错误”这种破事。
3. 核心模块拆解:脚手架、API代理、自动化脚本各自怎么工作
3.1 create-t3code脚手架:模板不是复制粘贴
脚手架是所有模块里最直观的一个。我用Bun写的init命令,交互式询问项目名称、包管理器、需要启用哪些模块(tRPC、Drizzle、ESLint、Playwright等),然后从templates目录里挑选对应的文件组合生成新项目。实现上看起来简单——把模板文件拷过去、替换变量就行——但这里有个关键细节:模板里的占位符替换不能只做字符串replace。
我踩过的坑是:一个Next.js模板文件里既有{{projectName}}这种业务字段,又包含大量TS泛型里的尖括号和模板字符串里的${}。如果我无脑全局替换,很容易把泛型和字符串表达式打坏。最终我改成让模板使用自定义扩展名.tpl.tsx,先只对语义占位符做精确替换,再重命名回标准扩展名,最后用Prettier统一格式化一次。这一步虽然绕,但非常值得,出来后的代码风格和手写的基本一致。
模板本身还需要按“最小可用”来维护。我见过很多脚手架项目,模板里塞满了demo代码,生成出来的项目先删半天注释和示例才能动手。t3code的模板只有三样东西:能跑通的最小路由、一条示例API、一个空白的数据库表结构。这样生成的骨架虽然看起来很素,但从零开始填业务代码时非常清爽。
3.2 API代理服务:为什么不让前端直连业务接口
第二个核心模块是类型安全的API代理层。这不是给内部业务项目用的,而是处理多端共用接口的场景。假设你的前端要同时对接自己后端和第三方开放API,第三方不会有tRPC,也不会给你TS类型。t3code通过BFF(Backend For Frontend)模式,在服务端定义一个代理router,调用方传一个带Schema的请求描述,代理层负责发起HTTP请求、解析响应、校验数据,最后把强类型结果返回给前端。
这种做法的价值在于:第三方API的字段结构只要在Schema里定义一次,后续无论哪里用它,编辑器都能给你推导出返回数据的完整结构。如果某个字段改成可选,前端调用处也会立刻类型报错,不用等到联调时才发现。实际写起来,一个代理procedure就几十行代码,但调用的业务方省掉了大量防御性判断。
需要提醒的是,BFF层一定要做好超时控制和失败降级。我在t3code的代理请求里默认加了三段式超时:尝试连接2秒、数据接收8秒、全部完成10秒,超时后返回统一的结构化错误,前端可以据此显示“稍后重试”或者缓存兜底,而不是看一个含糊的504。
3.3 CLI脚本组:把部署前检查做成一条命令
第三个模块是自动化脚本集合,这也是我日常用得最多的部分。t3code CLI里目前保留了四个命令:
t3code check:依次执行tsc类型检查、ESLint检查、Prettier格式化校验、构建产物生成t3code db:sync:对Drizzle schema做diff,输出SQL迁移文件t3code deploy:prep:检查环境变量是否完整、生成注入文件、构建Docker镜像并打版本标签t3code cozy:一条命令同时启动开发服务和数据库,类似docker compose up的替代
t3code check的实现思路是把所有检查步骤用bun run顺序调用,任何一个步骤失败就中断并打印失败摘要。以前这些命令得自己一条条记,现在一个命令搞定,最大的收益是新人入职第一天就能跑出同样的结果——环境差异被压到了最低。
4. 从开发机到生产服务器:Docker化与部署实测
4.1 镜像构建踩过的坑:缓存顺序决定了构建速度
真正把t3code接到生产部署时,我发现问题往往不在业务代码,而在Docker构建的细节上。最开始我图省事,用一个Dockerfile把依赖安装、代码构建、运行时打包全部混在一起。结果每次改动一行代码,npm install这层缓存就废了,镜像构建要跑四分钟。
后来我把构建阶段拆成builder和runner两层,并且严格控制依赖层的缓存顺序:先单独拷贝package.json和lockfile,执行依赖安装,再拷贝全部源码。这样只要不改依赖,后续每次构建都能直接命中Docker层缓存,构建时间从四分钟降到四十秒以内。这个优化不值钱,但价值立竿见影。
在runner阶段,我用的是一个基于alpine的精简镜像,只拷贝构建好的.next产物、public目录、以及node_modules里生产依赖的相关包。镜像体积从最初的1.2GB压到了不到300MB。生产环境拉镜像快很多,尤其是当你的部署目标服务器还在国内、网络带宽一般的时候,这个优化体感极其明显。
4.2 环境变量与密钥管理的实测教训
部署时还要面对一个绕不开的问题:环境变量和多环境密钥。t3code内部把环境变量分成三类:
- 本地开发变量:
.env.local,不入库 - 构建期变量:构建镜像时需要注入的
NEXT_PUBLIC_*,通过CI变量传入 - 运行期密钥:数据库密码、第三方Key,只在启动容器时通过
--env-file注入
第三类变量我明确规定了“绝不写进镜像”。有一次我把生产数据库的密码写进了构建机的一个.env文件,当时图方便就直接用它构建镜像了。后来镜像被推到镜像仓库,好在那是一个私有仓库,没有造成实际泄露,但那个密码因为“可疑暴露”被强制轮换了一次。从那以后,我在镜像里一律使用占位符环境变量,真实值只在容器运行时配置。这里给所有看到这篇的朋友一个实在建议:哪怕仓库是私有的,也永远不要把生产密钥在任何镜像层里留痕。
4.3 三环境部署对照与回滚策略
我用一套基于多阶段构建的部署脚本,把开发、预发布、生产三个环境分开跑,部署触发方式也不一样。
| 环境 | 触发方式 | 数据库迁移策略 | 失败处理 |
|---|---|---|---|
| dev | 本地CLI一键启动 | 每次直接自动同步 | 直接看日志改代码 |
| staging | push主分支后CI自动部署 | 先执行迁移再部署应用 | 自动回滚到上一版本镜像 |
| production | 手动打tag部署 | 备份后执行迁移,确认无误再切流 | 保留旧镜像,一键回退 |
回滚这里我专门做了个脚本:每次部署前会把当前正在运行的镜像tag打上rollback标记,新版本启动后如果健康检查连续三次失败,脚本自动切换到上一个rollback镜像。这套逻辑在常规Web服务上有余量,但心里踏实。健康检查我用的不是单纯TCP端口探测,而是调一个只读的/api/health接口,里面做了内存占用率和数据库连通性检查,一旦数据库连不上,健康检查立刻失败,容器编排就会介入处理。
5. 复盘清单:t3code在实战里暴露的边界和之后的扩展计划
5.1 最值得记住的三个坑
项目跑了大半年,我把最有代表性的问题单独列出来,每个都对应一段真实的排错经历。
第一个坑是脚手架生成的模板里,ESLint配置和pnpm的依赖提升行为偶尔冲突。表现就是新项目跑lint时总是报解析错误,但仓库根目录的lockfile是好的。排查了半天,发现是模板的packageManager字段写得不够明确,pnpm在某些版本下没有安装完整的eslint插件集。最后我在模板里把packageManager固定到了具体版本号,同时把eslint相关包在devDependencies里显式声明,不让它们被依赖提升掩盖掉。
第二个坑是tRPC中间件里的日志顺序问题。项目上线后我排查一个偶发的慢请求,发现日志先打印了procedure完成,随后才打印出中间件的耗时记录。这一开始疑惑了很久,后来定位到是因为我在闭包里做异步日志上报,没有await,日志排队反而打乱了顺序。修复方式很简单:中间件里统一以await方式上报,确保时间线准确。这个坑特别适合拿来给新人讲“异步日志不要乱触发”。
第三个坑和Drizzle的迁移生成有关。生产环境的数据库结构里有一些历史遗留字段,直接用db:sync根据schema做diff会生成破坏性的DROP语句,我差点把一张含数据的表字段删了。后来我在t3code里改成了迁移文件两步策略:先用db:sync --staging生成待执行的迁移SQL,人工review后再执行,绝不直接自动同步生产库。代价是每次迁移多几分钟人工确认时间,但换来的安全性是值得的。
5.2 哪些场景不适合用t3code
说到边界,我必须坦白一个结论:t3code不是万能的。如果是一个纯静态展示页面,或者一个完全不依赖类型安全的内部脚本集合,套上这套组合反而多了一堆概念负担。我甚至试过在一个快速验证用的原型项目里强行引入t3code的脚手架,结果光是让tRPC和原型里的大量临时fetch互不干扰就折腾了半天。后来我识别出一个简单的判断标准:如果项目两周内就跑完即弃,别上全套,简单Vite+React就是最优解;如果项目预期维护超过半年、多人协作、接口频繁变更,t3code的价值才完全体现出来。
另外,如果你所在的团队目前没有TypeScript的基础,全员上手这套全家桶的前两周会非常痛苦。我的建议是先让一两个成员把类型链路走通,再逐步推广。不要指望一步到位,否则排错成本会集中在少数人身上。
5.3 下一步准备做的事
后续方向上,我目前最想做两件事。第一是把t3code的脚手架扩展成支持插件式的模板市场,让不同团队能沉淀自己的项目模板,社区共享模块化经验。这个方向会把t3code从“我自己的工具箱”升级成“一个小的代码生成生态”,可信度还需要打磨。第二是把API代理模块的监控指标接进来,每次调用都自动记录入参覆盖率、错误率和Schema断言失败率。这些指标对接口治理很有用,读出来的后台数据比拍脑袋设定规范靠谱得多。
我自己的习惯是每跑完一个项目都顺手往t3code的issue里记一条后续想法,隔一个月回来翻一遍,能筛掉不少临时冲动留下的伪需求,只留下真正高频出现的痛点。这样的项目才会越用越顺手,而不是变成一个放着积灰的代码仓库。