资讯动态

t3code:让T3 Stack模型定义成为唯一事实来源,全链路代码自动生成

发布时间:2026/10/27 6:48:12 来源:尧图企业网站定制
很多团队并不是缺代码量而是缺“改一处全链路跟着变”的确定性。作为一个在 T3 Stack 项目里写过几年业务代码的人我太清楚这种不确定性带来的痛苦了一套 CRUD 模块看起来没几个文件但 Zod schema、tRPC router、React Query hooks 各自维护一份相似的字段列表改一个字段名就要在 IDE 里同时开四五个文件做手工同步。t3code 这个名字说白了就是 T3 场景下的代码生成器它的目标很单纯——让模型定义变成唯一事实来源其余代码全部自动产出。这篇笔记会把 t3code 从定位、架构到核心实现和落地一年攒下的实战经验完整展开适合正在被类型重复维护折磨、又不想引入重型代码生成平台的团队作参考。1. 为什么放着现成脚手架不用非要自己写代码生成器先交代背景。我所在的小组长期维护一个基于 T3 StackNext.js TypeScript Tailwind tRPC的内部中后台系统业务模型大概有三十多张表。刚开始大家都很乐观每加一个功能模块就手写一套 Zod schema、tRPC router、React Query hooks。前两三个模块还能忍到第十个模块的时候一个字段改名的连锁反应就能让人改到半夜——Zod 里的字段改了router 里的输入校验忘了同步前端调用方的类型还停留在旧版本最后只能靠运行时 console 里的红色报错一个个定位。这就是我写 t3code 的直接动机既然这些代码在结构上是高度重复的为什么不让机器来生成人只负责维护一份字段定义1.1 手写样板代码的最大问题类型在漂移“类型漂移”这个词听起来有点玄乎其实就是定义、校验、调用三层不一致。比如你在数据库迁移里给一个表加了 avatar_url 字段接下来要动的地方包括基础 schema、输入校验 schema、接口路由的入参类型、前端的 API 封装、页面里的表单初始值。五六个文件靠人肉同步一定会有遗漏。最坑的是 TypeScript 本身并不会帮你发现所有错位因为 tRPC 的类型推导链很长只要某一层用了 any 或者类型断言错位就会潜伏到运行时才爆发。t3code 的思路就是把这些同步动作从“人肉维护”变成“一处修改全部重新生成”让类型漂移根本没有立足点。1.2 现有工具的局限与 t3code 的切入位置市面上并不缺代码生成器。ORM 层有 Prisma 的 migrate接口层有 OpenAPI Generator前端有 plop 之类的模板脚手架。问题在于它们是“各自为战”的数据结构归 Prisma 管接口文档归 OpenAPI 管前端脚手架只管创建文件没有一条线把它们串起来形成闭环。工具解决环节明显短板Prisma Migrate数据库结构同步只管库表出不来接口和前端类型OpenAPI Generator接口文档转客户端前置条件太重小模块用它杀鸡用牛刀Plop 脚手架创建文件骨架没有模型语义只能做字符串替换t3code模型定义到全链路生成只服务 T3 场景不做通用平台t3code 的定位很窄也很具体只服务 T3 场景下的 CRUD 模块输入是一份 JSON Schema 加上少量配置输出直接落到输入校验 schema、路由和前端查询 hooks 三个位置。不碰 UI 组件不接数据库不做业务逻辑就是专注解决“一套模型定义 → 全链路类型安全”这一件事。同时也得诚实说一句如果你的项目还停留在单页面、没接入 tRPC 或类似端到端类型方案t3code 的价值会大打折扣。它解决的是一个特定技术栈下的特定痛点不是万金油。2. t3code 的整体架构模板、配置、产物三分法先说架构设计上的一个原则生成器不是编译器不应该尝试理解业务语义它只需要把输入数据灌进模板按规则输出文件。因此我把 t3code 拆成三个清晰的部分数据层模型定义、模板层生成规则、输出层落盘策略。这三个角色互不越界出问题的时候只需要在对应层排查。2.1 生成流程与目录设计整个生成流程可以简化为一条五步流水线。第一步读取项目根目录下的 t3code.config.ts第二步根据配置定位模型文件目录用内置解析器把 JSON Schema 变成内部数据结构 ModelDefinition第三步加载 templates 目录下的模板文件第四步做渲染前的数据整形包括字段排序、命名约定转换、导入路径计算第五步把渲染结果写到目标目录并在控制台打印变更文件清仓。目录设计上我建议把生成器本身和生成的代码分开放避免 git 历史混杂。实操中我的结构是这样t3code/ cli.ts core/ parse.ts render.ts write.ts templates/ zod.eta trpc.eta hooks.eta 业务仓库/ t3code.config.ts models/ coupon.json src/ server/db/schema/ # 生成产物 server/api/routers/ # 生成产物 client/hooks/ # 生成产物t3code 本身是独立小包可以在多个项目间复用每个业务项目里只保留一份 t3code.config.ts 和 models/*.json。业务项目不需要完整复制模板目录只需要覆盖需要定制的部分即可。业务侧只管“定义什么”生成器负责“怎么生成”这样升级模板不会污染业务仓库里对应的业务文件。2.2 模板引擎选型为什么用 Eta 而不是 Handlebars这是我最早做的技术决策之一。Handlebars 入门最快但它的 helpers 体系在复杂条件渲染时会变得非常别扭循环里要用各种注册 helper 才能实现字段级别的 if/else 嵌套。Eta 的语法和 EJS 类似支持原生 JS 表达式循环和条件判断写起来和写 TypeScript 差不多调试难度低很多。另外一个实际考量是性能。Eta 的编译模型是先把模板转成可执行函数这样很方便做缓存——我只会在配置变化时重新编译模板其余渲染都是直接执行缓存函数生成几十个文件的耗时可以压到毫秒级。在写文件前再统一跑一次 Prettier保证产物风格和仓库一致。3. 核心实现拆解从 JSON Schema 到类型安全代码现在看代码骨架。为了让读者真正能复现我按模块讲核心实现。整体实现用 TypeScript 编写底层的 Node.js API 足够完成所有工作不需要额外引入太重的基础框架。3.1 CLI 入口与参数解析我用的是 Node.js 原生能力CLI 入口就是一个可执行的 ts 文件。参数解析没有引额外依赖直接读 process.argv支持两个参数models 目录地址和 --watch 模式。--watch 很有用开发时改一次模型文件就自动重新生成极大缩短反馈循环。import { generate, watchModels } from ./core/pipeline.js; const args process.argv.slice(2); const modelsDir args[0] ?? ./models; const shouldWatch args.includes(--watch); if (shouldWatch) { watchModels(modelsDir); } else { const result await generate(modelsDir); console.log(generated ${result.created} files, ${result.skipped} unchanged); }监听模式我直接用 fs.watch 配合防抖没有引第三方文件监听库。实现里加了 150 毫秒的去抖否则保存一次文件会触发多次生成IDE 和 git 面板里会闪得很难受。3.2 数据模型解析器解析器是全项目的核心它负责把 JSON Schema 转成内部 ModelDefinition。我给 ModelDefinition 设计了五个字段name模型名、tableName映射的表名方便输出到数据库模型注释、fields字段数组、relations关系数组、indexes索引配置。type FieldType STRING | NUMBER | BOOLEAN | ENUM | REF; interface FieldDef { name: string; type: FieldType; optional: boolean; defaultValue?: string; tags: string[]; comment?: string; } interface ModelDefinition { name: string; tableName: string; fields: FieldDef[]; relations: Array{ field: string; target: string; kind: one | many }; indexes: string[]; }字段数组里的 type 不直接存储 TypeScript 类型字符串而是存一个语义类型枚举。这一步很多人会偷懒直接写死类型但这是我建议保留的关键设计同一个语义类型在输入校验里和在前端 hook 里的输出可能不一样。比如枚举类型在 Zod 里对应 z.enum在 React Query 的参数类型里就是联合类型在初始化表单数据时又可能需要默认值。把语义层剥出来模板才能灵活做区分渲染。3.3 渲染器与文件输出渲染器做三件事计算 import 路径、转换命名风格PascalCase 和 camelCase 互转、把数据灌进 Eta 模板执行渲染。然后在写文件前做一次关键检查对比当前磁盘上的文件和生成内容是否相同相同就跳过写入。async function writeIfChanged(targetPath: string, rendered: string) { const current await safeRead(targetPath); if (current rendered) { return; // 无变化不落盘 } await writeFile(targetPath, rendered); return targetPath; }这个“空写入”优化看起来不起眼但实际受益非常大。大多数文件生成器都是无脑覆盖写文件哪怕内容一模一样也会触发 IDE 重新索引git 里出现一大堆无意义的 modified 记录。t3code 会在内存里比对字符串无变化就不落盘。这样跑完一次生成git 面板里清清楚楚地告诉你“这次改动到底动了哪几个文件”。写完文件后我还会统一调用项目级 Prettier 对输出做格式化。格式化这一步必须做因为模板渲染出来的代码最常见的毛病就是缩进混乱尤其是嵌套 if 内部的三元表达式。不格式化的话生成结果和手写代码风格差别太大代码评审的时候会被挑战得很惨。4. 一次完整的生成实战tRPC Zod React Query理论讲完了来跑一个真实例子。假设我们要给内部运营后台新增一个“优惠券”功能模型定义如下。4.1 定义模型与生成配置先写 models/coupon.json只包含最核心的字段。整个文件就是一份普通 JSON不需要额外语法目的就是让非前端同事也能看懂字段结构。{ name: Coupon, tableName: coupons, fields: [ { name: id, type: STRING, tags: [id] }, { name: name, type: STRING, tags: [query] }, { name: code, type: STRING, tags: [unique] }, { name: discount, type: NUMBER }, { name: status, type: ENUM, defaultValue: ACTIVE } ] }为了体现字段标记的作用我给 name 字段加了 query 标记给 code 字段加了 unique 标记给 status 字段加了枚举类型。这三个标记会分别影响生成的路由接口的搜索参数、输入校验的唯一性规则、前端的枚举类型定义。t3code.config.ts 里指定模型目录、输出目录和模板开关类型是严格 TypeScript 接口避免拼错属性名。这里我最推荐的配置项是 templateOverrides它允许业务项目只覆盖某一个模板文件而不是把整包模板复制进来升级成本会低很多。4.2 生成的校验、路由与前端调用代码跑完生成之后重点看三个产物。输入校验 schema 会按字段语义拆输入输出两段status 对应 enum 字段直接生成枚举校验code 因为有唯一性标记会自动套上正则和白名单长度限制。输出对象则会把数据库侧常见的日期字段转成对应的日期类型处理前端拿到手就是格式化好的对象。接口路由部分最麻烦的是参数一致性问题。生成出来的 router 代码里获取列表的入参是一个结构化对象包含分页字段和可选的关键词字段。关键词因为 query 标记被解析成可选模糊匹配字段生成时自动拼接 contains 查询条件status 则是对应枚举类型的可选筛选。这一层代码如果手写最容易出的错是把筛选参数写进返回类型生成器可以从源头规避。前端部分生成一个查询 hooks 文件对列表和详情分别导出对应的 hook。hook 内部直接用了生成出的类型安全入参不会出现“传了个对象但类型对不上”的情况。同时我特意把生成的代码保持平铺风格不做嵌套封装目的在于让紧急改需求时可以直接在生成文件里做小范围调整而不是被迫理解一层抽象封装。4.3 验证生成结果的编译与运行效果跑完生成并不是直接收工。我的验证三步法看 git diff确认只改动计划内的文件跑 tsc --noEmit任何 TypeScript 错误都会在这一步暴露启动 dev server在浏览器里走一遍列表页的搜索、分页、新建、编辑流程。一个经验是验证不能只看前端页面要看后端校验的输出版本。有时候前端渲染正常但保存时报校验错误原因就是输入校验和数据库模型在字段可空性上不一致。所以我在演示项目里专门加了一条“故意删掉可选字段”的对照用例用来说明 t3code 的输出是可读、可 diff、可回滚的——和手写代码在观感上没有区别但省掉了同步成本。5. 落地一年后总结的几个关键教训工具写出来不代表就能用一年时间里我踩了不少坑挑三个最有价值的记录在下面。5.1 生成的代码必须有可编辑边界不能当黑盒第一次把 t3code 接入团队时大家最直接的疑问是生成了之后手写的定制逻辑怎么办很多生成器项目就死在这里——它给你一个黑盒产物然后你就再也改不动了任何定制都要迁回生成器成本极高。我的答案是给每个生成文件加一段固定注释// generated by t3code并且约定所有手写改动必须落在文件底部的// custom-start和// custom-end两块区间里。重新生成时t3code 会把这区间之间的内容原样保留其余部分全部覆盖。这个“保护区”机制大概只花了几十行代码却解决了生成器最常见的人机冲突问题。5.2 格式化、检查与 CI 必须自动锁死生成器最怕的其实不是生成错误而是团队里有人手动改了生成结果导致下次重新生成的时候 diff 一大片。所以我强制做了两件事。第一生成结果在写盘之前统一跑 Prettier按仓库规则格式化第二配置发生变更后在 CI 里跑一条t3code generate --check指令检测当前代码和最新模型定义是否一致不一致就红掉。这条 check 命令本质上做的是“影子生成”在内存里跑一遍完整生成流程然后把结果和磁盘上的文件列表做哈希对比。只要有人在没跑生成器的情况下提交了手改文件CI 立刻报错。等于把“生成结果必须可复现”这个原则用 CI 硬性锁死了。5.3 保持模板简单把复杂逻辑留在宿主工程第三个教训是不要追求模板的过度抽象。加新功能时需要想清楚哪些逻辑属于生成器、哪些属于业务代码。我的判断标准很简单如果一段逻辑在超过 80% 的模块里都相同放在模板里如果只有少数模块需要宁可让它散落在手写代码里也别放进模板。因为模板复杂度上去之后排错成本会急剧上升渲染报错要看模板的执行栈模板执行栈里全是生成器内部转译出来的 compiled function和业务代码的堆栈完全对不上调试效率非常低。所以 t3code 的模板文件始终坚持控制行数宁可多拆几个小模板也不写一个“万能模板”。我在这个项目里最大的收获是代码生成器不是银弹它的边界必须非常清晰。t3code 只解决“模型定义到类型安全代码”这一小段问题一旦超出边界就不该用生成器去处理。把这条边界守住它带给团队的省力效果是实打实的。

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

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

免费获取报价 →
↑