1. 项目缘起:create-t3-app 很好用,但业务代码还是得自己搓
接触过 T3 Stack 的朋友应该都有体会,create-t3-app把一个 Next.js + TypeScript + Tailwind + tRPC + Prisma 的骨架项目拉到本地只需要一分钟,体验确实流畅。但真正开始写业务的时候,问题就来了:每接一个新需求,我都要重复做同一套事情——先写 Prisma model,再写 Zod schema,然后手写 tRPC router 的 CRUD,再去前端补 React Query 的 hook,最后对着表单页面敲一遍字段名。
一个简单的"文章管理"功能,光这五步就得花掉差不多一个小时。更烦的是,这些代码七成是样板,字段名、类型定义、路由路径来回复制粘贴,稍微不留神就出现 Prisma 字段和 Zod schema 对不上的低级错误。我最早试着用片段片段的手抄方式硬扛,后来用代码片段插件,再后来用自己攒的模板文件,但始终没有一个工具能把"数据模型"这一个输入,自动变成"数据库层 + API 层 + 前端数据层"三端一致的结果。
t3code 就是冲着这个痛点去的。它本质上是跑在项目里的一个代码生成器,核心思路是:你定义一个业务模型,它根据约定好的模板,把 Prisma schema、Zod 校验、tRPC router、React Query hooks 一次性生成出来,并且保证各层之间的字段和类型完全对齐。开发阶段跑一遍 t3code,等于把整套 CRUD 的骨架和业务代码一次写完,剩下的只是往生成的文件里填真正的业务逻辑。
这篇文章把我从零开发 t3code 的完整过程写清楚,包括架构取舍、生成器设计、踩过的坑和发布 npm 包前后的工程化改造。如果你是做 Node.js CLI 工具、代码生成器,或者只是嫌 T3 项目重复劳动太多,这篇应该能给你不少可落地的参考。
2. t3code 的架构取舍:模板即数据,生成器即函数
工具的目标定了之后,第一件事不是急着写代码,而是想清楚架构。我一开始的冲动是写一堆字符串拼接函数,Perl 式地往外吐代码——写到第三个生成器就会发现,字符串拼接的维护成本高得吓人,改一个公共字段要在好几个函数里同步改。所以我换了个思路:把模板和逻辑彻底分开。
2.1 三层结构:CLI 入口、模板注册表、生成器管线
t3code 最终跑起来是这样的流程:用户在终端执行t3code generate post,CLI 层接收命令参数,从模板注册表里找到post对应的模板定义,然后依次调用 Prisma 生成器、Zod 生成器、tRPC 生成器和前端 hook 生成器。每个生成器只做一件事:读入模型定义,渲染自己那份模板,把结果写到指定路径。
模板注册表是整个工具的"数据库"。它用一份 JSON 清单描述每一个业务模型,包括模型名、字段列表、字段类型、关联关系,以及哪些功能模块需要生成(比如是否要软删除、是否需要分页查询)。开销是建模的时候要把模型定义写清楚,但换来的是生成器完全不需要关心业务语义,拿到什么数据结构就渲染什么代码。
{ "name": "Post", "tableName": "posts", "fields": [ { "name": "title", "type": "String", "required": true, "unique": false }, { "name": "content", "type": "String", "required": false, "isText": true }, { "name": "published", "type": "Boolean", "required": true, "default": false }, { "name": "author", "type": "User", "relation": "many-to-one" } ], "features": ["pagination", "softDelete", "auditLog"] }2.2 模板引擎选型:为什么选 Handlebars 而不是 EJS
模板层我对比过 Handlebars 和 EJS。EJS 学起来更简单,<% %>直接写 JavaScript,灵活到近乎没有限制,但正是这种灵活性让我把它排除了——模板里可以写任何逻辑,意味着代码生成逻辑会被悄悄塞到模板里,模板越来越像程序,越来越难测试和维护。
Handlebars 的哲学是"逻辑无关模板",模板里只有{{#each}}、{{#if}}这类声明式指令,复杂计算必须通过预处理的上下文数据来完成。这就逼着我把所有派生逻辑放在生成器的 TypeScript 代码里,模板只做占位符替换。以 tRPC router 生成为例,模板里只需要遍历输入文件里的 input 数组,而 input 数组是生成器根据 Zod schema 的字段类型提前算好的,模板本身干干净净。
2.3 幂等性与 Dry-Run 模式
代码生成器最容易让人不放心的地方是覆盖已有文件。我第一版跑了几次之后就发现,同一份模板渲染出来的结果虽然相同,但手改过的业务文件一旦被重新生成,改动直接没了。这个问题靠两个机制解决:第一,生成器只负责创建和更新"标注了生成标记"的文件,标记放在文件头部注释里,像// @generated by t3code,手改过的文件如果后续需要升级,会得到一份.diff文件而不是被直接覆盖;第二,所有生成操作默认走 dry-run,终端里先展示将要创建/修改的文件列表和差异摘要,加上--write参数才会真正落盘。
幂等性还有一层含义:同一个模型连续跑两次 t3code,结果必须完全一致。我用--diff参数配合 git 来验证,跑完第二次生成,git status应该没有任何新的变更。这套机制后来成了 CI 里最重要的回归测试。
3. 核心功能拆解:一个模型定义如何贯穿 Prisma、tRPC 与前端查询
架构定下来之后,最关键的就是把"模型定义 → 各层代码"的映射规则设计好。这是 t3code 的灵魂,也是我花时间最多的地方。下面拿真实的Post模型走一遍完整流程,你能直接看到工具到底替你干了多少事。
3.1 Prisma 层:从 JSON 字段到 Schema 文件
模型定义里title是String、content是String且isText为真,生成器对 Prisma 的映射规则是这样的:普通字符串映射为String,isText标记的字段映射为@db.Text,布尔字段加@default(false),多对一关系生成authorId外键列加@relation。生成结果会追加到schema.prisma的对应位置,同时生成器检查模型名是否已经存在,避免重复追加。
不光是字段,索引和查询能力也从features里推导。pagination功能给模型加上复合索引条件;softDelete功能自动生成deletedAt DateTime?字段,并且所有默认查询条件里带上deletedAt: null。这些逻辑如果手写,每次都要小心别漏掉,但让生成器来做就是一行特性声明的事。
3.2 Zod 层:校验规则从模型定义派生
Zod schema 看起来简单,实际上是最容易和 Prisma 脱节的一层。字段名拼错、类型不一致、必填项漏掉,这些问题在运行时才会暴露,而那时往往已经写了几百行业务代码。t3code 的做法是:Zod schema 的字段和类型完全从同一个模型定义派生,Prisma 里required: true的字段在 Zod 里就是z.string(),required: false就是z.string().optional(),unique字段在创建和更新时分别验证是否重复。
比较有意思的是content这种长文本字段。它的isText标记不仅影响数据库列类型,还影响 Zod 的校验——标题字段限制了 100 个字符以内,正文不做长度限制。这种"一个字段标记多处联动"的设计,让模型定义成为唯一的事实来源,从根上防止了三端不一致。
3.3 tRPC 层:CRUD 路由和权限钩子的自动编排
tRPC router 的生成可以说是整个工具里最复杂的部分。一个标准 CRUD 需要create、getAll、getById、update、delete五个 procedure,每个都要处理输入校验、数据库访问、错误处理和返回值类型。
我在设计上做了一个取舍:CRUD 的基础逻辑由生成器产出,但权限校验留下protectedProcedure和publicProcedure的选择标记,默认走protectedProcedure,具体业务上要做哪些权限判断,由开发者在生成代码里补。生成器产出的代码里,每个 procedure 都调用独立的 service 函数,而不是把数据库逻辑全堆在 router 文件里,这样后续调整业务规则时可以直接改 service,不用碰生成的文件头部的保护标记。
3.4 前端数据层:React Query hooks 和 API 调用
前端生成的不是 UI 组件(UI 的可变性和主观性太强,硬生成出来的东西我都不爱用),而是数据访问层——针对每个 tRPC procedure 生成对应的 React Query hook:useCreatePost、usePosts、usePost、useUpdatePost、useDeletePost。hook 的入参类型和返回值类型直接从 tRPC router 的类型里推导,前端代码里 import 类型就能享受完整的 TypeScript 提示。
useDeletePost默认带上了 mutation 后的缓存失效处理:删除成功后调用queryClient.invalidateQueries(['post'])刷新列表缓存。这个细节看起来小,但手写时最容易忘,忘了之后列表不会自动刷新,用户删完一条数据还得手动刷新页面,体验很差。
4. 踩坑实录:模板渲染、路径解析与依赖版本锁定的三重波折
工具能跑通肯定不是一帆风顺的。从第一个能生成文件的版本到能放心在真实项目里用,我踩了三个印象特别深的坑,每个都值得单独拿出来说,因为这些问题在写代码生成器时几乎必然遇到。
4.1 Handlebars 局部模板的递归渲染问题
t3code 的模板不是单文件,而是拆成了公共片段——比如字段列表、输入类型定义、分页参数这几个片段会在多个生成器里复用。我最初用 Handlebars 的{{> partial}}机制组织这些片段,第二个版本引入了嵌套 partial 的渲染,直接炸出一个诡异的现象:内层 partial 里的变量访问不到外层上下文,渲染结果直接变成空字符串。
查了半天定位到原因:Handlebars 的 partial 默认继承当前上下文,但嵌套 partial 在传递参数时如果用了this引用而没显式传对象,子 partial 拿到的上下文会丢失父级的数据。解决办法是把所有需要的数据在生成器阶段预处理,渲染时只传一个扁平的根上下文对象:
const context = { modelName: 'Post', fields: deriveFields(model), // 已经在 TypeScript 层算好 pagination: model.features.includes('pagination'), softDelete: model.features.includes('softDelete'), // 不要把嵌套对象塞进 partial,让它自己用顶层字段 };4.2 路径别名与相对导入的拉锯战
生成的文件在项目里的位置不同,import 相对路径的深度就不一样。比如 tRPC router 放在src/server/api/routers/,而 Zod schema 放在src/server/schema/,router 文件里 import schema 就得写../schema/PostSchema。刚开始我的生成器按固定相对路径计算,结果遇到项目结构调整(比如用户把 router 目录挪了位置),生成的 import 全部失效。
后来我干脆不用相对路径,统一用@/别名。这样生成出来的 import 是@/server/schema/PostSchema,不管文件放在哪个目录层级都能正确解析。代价是要求使用方在tsconfig.json里配置好 paths 别名,但 T3 项目默认就有这个配置,所以这个方案对目标用户完全透明。我在文档里也明确写了这一点:t3code 生成代码默认只保证在配置了@/别名的项目里直接可用。
4.3 Prisma 版本漂移:schema 语法不是一成不变的
这是最隐蔽的一个坑。Prisma 4 时代@db.Text和@default("")的写法没问题,但升级到 Prisma 5 后,某些字段类型的默认值语法有了更严格的约束,生成出来的 schema 文件在prisma migrate时直接报错。更麻烦的是,不同用户本地装的 Prisma 版本不一样,生成结果必须兼容他们当前的版本。
我的解决思路是动态探测:t3code 初始化时读取项目里安装的 Prisma 版本,根据大版本号选择对应的 schema 模板(Prisma 4 和 Prisma 5 各维护一份差异逻辑,公共字段仍然走同一套定义)。同时在package.json里通过engines字段声明了 Prisma 的兼容范围,并在生成前做一次版本检查,版本不匹配时给出明确提示而不是默默生成一份会报错的文件。这个探测逻辑后来扩展到了 Node 版本和 Next.js 版本的兼容性检查,成了 t3code 的标配前置流程。
4.4 非交互式环境的命令卡死
最后一个坑不发生在生成逻辑上,而是发生在交互式命令行。t3code 在本地开发时用prompts库做交互式参数选择(比如选择字段类型、选择要生成的功能模块),交互体验很顺畅,但在 CI 脚本和远程容器里一跑就卡住——终端没有 TTY,prompts 库等待输入永远等不到。
处理方式是对所有交互流程都提供非交互入口:--yes参数跳过所有交互,参数全部从命令行 flag 或项目里的t3code.config.ts配置读取。这个改造让 t3code 既能本地交互式操作,也能无缝集成到 CI 流程里,比如提交代码前自动跑一遍生成器检查是否有未同步的模型变更。
5. 工程化收尾:测试策略、CI 矩阵与 npm 发布流程
功能开发告一段落后,我把 t3code 当作一个正经的开源项目来做工程化。代码生成器的测试比普通库要麻烦,因为它产出的是一整个项目,测不好就是给自己埋雷。
5.1 测试的分层设计:单元测试只管模板,集成测试直接造真实项目
单元测试覆盖的是每个生成器:输入特定的模型定义,断言输出文件的字符串包含关键代码段。这类测试跑得快,适合在开发阶段频繁运行。但我很清楚,字符串断言只能证明"该有的内容出现了",证明不了"生成的项目真的能跑"。
所以我把更重的成本花在了集成测试上。每次跑集成测试,CI 会在临时目录里执行create-t3-app创建一个全新的 T3 项目,然后运行 t3code 生成一个包含多种字段类型和关联关系的完整模型,接着依次执行prisma migrate、tsc --noEmit、next build。只有这三步全部通过,才说明生成结果真正可用。这个测试链路的坏处是慢——一次完整构建要两三分钟,好处是它能捕获绝大多数实际使用中才会暴露的问题;我前面说的 Prisma 版本漂移就是通过这条链路提前发现的。
5.2 CI 矩阵的配置思路
t3code 的 CI 我在 GitHub Actions 上配了三个维度的矩阵:Node 版本跑 18、20、22,系统跑 Ubuntu 和 macOS(Windows 用户量小,暂未覆盖),Prisma 版本跑 4.x 和 5.x。三组矩阵叠加出一个不小的测试面,但跑下来大概十分钟,还能接受。矩阵的价值在于:代码生成器对运行环境敏感,单一环境验证通过完全不能说明问题,多版本矩阵是这类工具最低成本的可靠性保障。
5.3 发布流程:从本地手动发包到 changesets 自动化
第一版发布我直接npm publish,后来发现版本管理和 changelog 全靠手动记,很快就混乱了。我的做法是引入 changesets:开发者提交 PR 时带上.changeset文件描述变更类型和说明,合并时由 GitHub Action 自动发版本更新、更新 changelog、发布到 npm。这里有个细节值得提——发包前我在 npm 上配置了 provenance 声明,发布时会带上构建来源信息,对开源工具的信任构建很有帮助。
发布到 npm 之外,我还写了一份快速上手文档,里面放了一个完整可运行的示例仓库,用户可以直接复制仓库跑一遍生成流程再回到自己的项目里操作。文档里强调的唯一一个约定是:t3code 生成的代码头部带@generated标记,手动修改这些文件后如果重新生成,改动可能会被覆盖,所以业务代码请写到独立的 service 层或者扩展文件里。
6. 我这段时间的实操体会与后续打算
工具做到现在,我已经在自己维护的两个 T3 项目里稳定用了三个多月,生成过二十多个业务模型。最深的感觉是:代码生成器最大的价值不是省那一点敲代码的时间,而是把"三端一致性"这个容易出错的东西变成了默认正确。以前改一个字段要记得同步改 Prisma、Zod、tRPC、前端类型,现在只改模型定义重新生成,所有关联位置一次到位,手误几乎不可能发生。
谈谈几个实操心得。生成器产出的代码一定要保留可读性,不要为了炫技生成那种让人看不懂的"魔法代码"。一个简单的检验标准是:生成出来的文件去掉生成标记后,看起来就像一个熟练工程师手写的代码,而不是机器乱吐出来的。另一个心得是模型定义文件本身就是最好的文档,新同事接手项目时看一遍models.json就能理解整个数据层的结构,比翻文档高效得多。
后续的迭代方向我已经列了几个:给模型定义增加"变更记录"支持,让用户能看到某个模型从 v1 改到 v2 时各层代码怎么变;做一个增量生成模式,监听模型文件的变更自动跑对应的生成器,不用每次手动执行;还有一个小实验是用 AST 解析代替字符串模板,对已有代码做更精准的插入和更新。这些方向都还在探索中,等我真正做完一个再回来写下一篇分享。想试用 t3code 的朋友,直接npm i -g t3code然后t3code init就能在当前 T3 项目里跑通完整链路,遇到问题欢迎来仓库提 issue,踩过的坑我已经写在文档里了,希望你能少走一段弯路。