t3code:用schema驱动全栈代码生成,终结样板代码与类型不同步
过去大半年,我们团队一直在和"重复的样板代码"较劲。业务线越铺越宽,CRUD 接口、表单页面、状态管理、路由注册……每一块都是新的,但每一块又长得都差不多。直到有人把 t3code 这个项目丢进周会,我一开始以为是某个新出的 TypeScript 框架,仔细读了一遍文档才发现,它是一个面向全栈项目的代码生成与工程收敛工具,核心价值在于三件事:让数据模型到 API 层的类型始终同步、让路由和页面骨架按约定快速生成、让团队的代码风格通过生成结果强制收敛。
如果你和我一样,业务里有大量增删改查、表单、状态管理这类"低创意高重复"的工作,这篇文章会把 t3code 的定位、核心用法、二次开发方式以及我在真实项目里踩过的坑一次说清楚。适合正在做技术选型的后端/前端负责人,也想自己动手写代码生成器的人参考。
1. 从"复制粘贴 CRUD"到"声明式生成":t3code 解决的第一个问题
1.1 一个订单后台项目里,样板代码占了正常工作量的七成
先说一个真实的场景。去年年中我们要重做一套订单管理后台,技术栈是 React + TypeScript + Node.js,数据库用 PostgreSQL。当时我随手盘了一下需求:订单列表、订单详情、订单状态流转、客户管理、商品管理,再加几个筛选条件。看上去没有什么技术难点,但真正做起来才发现,每一个模块都要经历同样的流程:
先写数据库模型,再写 Zod 校验,再写后端路由,再写前端 API 封装,再写 React Query 的 hooks,再写页面表格和表单弹窗。一个订单模块走下来,将近两千行代码,其中真正有业务逻辑的不到三分之一,剩下的全是"照着上一个模块改个名"的胶水代码。
更难受的是类型不同步。后端模型加了字段remark,前端OrderDetail接口没跟上,编译期不报错,直到用户点开某个订单才发现字段是 undefined。这种事一个月能碰上三四次,每次都要从前端页面一路追到数据库,成本极高。
我把这个痛点抛给团队,大家的第一反应是"用现成的低代码平台",但业务方又提出了定制化表单、复杂状态流转这些需求,低代码平台反而束手束脚。就在这时候,我们开始关注基于模板的代码生成方案,t3code 就是其中比较特别的一个。
1.2 t3code 不是什么:它是增量生成器,不是脚手架
很多人第一次看到 t3code 会把它归类到"脚手架工具",和 create-react-app、vue-cli 放在一起比较。这是最大的误解。
脚手架的核心动作是一次性的:初始化项目结构,生成一堆初始文件,之后你和它就没什么关系了。而 t3code 更像是一个持续工作的"代码车间"——你维护一份 schema(模式定义),它可以根据这份 schema 反复生成、更新、同步代码。今天给订单模型加一个字段,重新跑一遍生成命令,前端类型、后端路由、数据库迁移文件会一起更新,而不是需要你手动去改五个地方。
这个差异看起来不大,但决定了工具的使用方式。脚手架工具怕的是"生成完之后我改了代码,再生成会不会覆盖?";t3code 的设计思路是"生成的文件不是神圣不可侵犯的,但修改应该走模板而不是直接改产物"。它支持把生成文件标记为只读,也支持在模板里预留手写扩展区,让生成和手写共存。
1.3 名字里的 t3:三种含义对应三条设计原则
我最初以为 t3 指的是 TypeScript + TanStack + Tailwind 这种技术栈组合,后来翻了项目文档才知道,作者给了三个解释:Type-safe、Template-driven、Transformable,也就是类型安全、模板驱动、可转换。
这三点其实概括了 t3code 的全部设计原则。类型安全是它的底线,任何生成出来的代码都应该是类型正确的;模板驱动是它的手段,所有产物都来自可维护的模板而不是写死在程序里的字符串拼接;可转换是它的扩展性,你不仅能用官方模板,还能写自己的生成器,甚至把已有的手写代码通过 codemod 方式转换成生成产物。
理解了这三个词,后面所有的功能拆解都好说了:schema 是类型安全的源头,模板是驱动生成的核心,codemod 和自定义生成器是它承接复杂场景的出口。
2. 核心能力拆解:schema、模板、doctor 三件套怎么配合
2.1 schema 是唯一事实来源:模型定义长什么样
t3code 的入口是一个.t3.ts文件,它用 TypeScript 类型来声明你的数据模型。这里有一个我特别喜欢的点:schema 本身就是一个合法的 TypeScript 模块,所以你可以获得 IDE 的自动补全和类型检查,不需要学习一套新的 DSL(领域专用语言)。
一个典型的订单模型定义大概是这样的:
// models/order.t3.ts import { t3 } from 't3code'; export const Order = t3.model('Order', { id: t3.string().id(), orderNo: t3.string().unique(), customerId: t3.string().ref('Customer'), status: t3.enum(['PENDING', 'PAID', 'SHIPPED', 'DONE']), items: t3.array(t3.ref('OrderItem')), totalAmount: t3.number().min(0), createdAt: t3.datetime().auto(), updatedAt: t3.datetime().auto(), });这个定义同时表达了数据库字段、校验规则、关联关系和时间戳逻辑。t3code 会基于它推导出 Zod schema、Prisma model、前端 TypeScript 接口、后端路由参数等一套东西。
这里需要注意一个细节:t3.string().id()和t3.string().unique()这类方法,看起来是运行时校验,其实更多是给生成器吃的"元数据"。生成器根据这些约束决定是生成 UUID 主键、唯一索引还是普通字符串,所以定义 schema 时一定要把约束写全,不要图省事全都用 string。
2.2 模板引擎与命名规则:生成器怎么把 schema 变成文件
schema 只是原料,真正决定产物的是模板。t3code 内置了一套基于 Nunjucks 风格的模板引擎,支持变量插值、条件判断、循环遍历,还额外提供了一组专门处理命名转换的过滤器。
原因是代码生成里最烦人的一件事就是命名风格不一致:数据库里叫order_no,后端里叫orderNo,前端组件叫OrderDetail,页面路由叫order-detail。手写的时候总有人混着来,而模板通过过滤器可以保证所有产物统一。比如在模板里写:
// src/api/{{kebabCase name}}Api.ts import { defineApi } from '@/lib/api-factory'; export const {{camelCase name}}Api = defineApi({ resource: '{{snakeCase name}}', operations: ['list', 'get', 'create', 'update', 'delete'], });一个pascalCase、camelCase、kebabCase、snakeCase过滤器就能把所有生成文件的名字和内部符号统一起来。这个做法的好处是,团队里无论谁来维护生成模板,产物的风格都能保持一致。
2.3 doctor 命令:类型同步和死代码检测
如果说生成是 t3code 的"正向工作",那 doctor 命令就是它的"逆向体检"。它会扫描项目里所有生成文件,做三件事:
第一,检查生成产物和当前 schema 是否匹配。如果你手动改了生成文件(强烈不建议),它会列出 diff;如果你加了字段但没重新生成,它会提示过期文件。
第二,检查类型引用是否完整。比如 schema 里customerId引用了Customer模型,但项目里根本没有定义 Customer,或者 Customer 没有 id 字段,doctor 会把这种断链报出来。
第三,扫描没有被引用的生成文件。项目迭代久了,总会有些路由或者页面已经不再使用,但文件还留在仓库里。doctor 会生成一份"疑似死代码"清单,你可以逐个确认删除。
我在团队里把 doctor 命令接进了 pre-push 钩子,任何本地提交之前都会自动执行。效果非常明显,类型不同步的问题基本从"线上发现"变成了"提交前拦截"。
3. 上手实测:从安装到第一个生成产物,我踩过的坑都在这
3.1 安装与初始化:注意 Node 版本和初始化交互
t3code 的安装本身不复杂,npm 全局安装或者用 npx 直接跑都可以:
npm install -g t3code # 或 npx t3code@latest init但有一个坑必须提醒:它要求 Node.js 版本不低于 18.17。如果你还在用 Node 16 或者更早的 LTS,安装之后执行任何命令都会直接报语法错误。我一开始就是被这个问题卡了半小时,后来查 issue 才发现是版本兼容性问题。
初始化命令会做几件事:生成t3code.config.ts配置文件、创建models/目录、把官方推荐的模板复制到项目里。整个过程是交互式的,会问你要不要接入 Prisma、要不要生成 React Query 的 hooks、要不要启用 ESLint 集成等。这里我的建议是,第一次使用尽量都选上,因为后面可以通过配置项关掉,但如果你一开始漏选了,再想补回来就得手动改配置,多一步操作。
3.2 定义一个最小 schema 并生成订单模块
我们以一个最简单的订单模块为例。在models/目录下新建一个文件,然后执行生成命令:
t3code generate默认情况下,它会根据t3code.config.ts里注册的模板,把models/下所有模型都生成一遍。生成的产物大概是这样的目录结构:
src/ generated/ api/ orderApi.ts types/ Order.ts hooks/ useOrder.ts validators/ orderSchema.ts prisma/ order.prisma每个生成文件的头部都有一行注释标记:
// @t3code-generated -- DO NOT EDIT MANUALLY // ref: models/order.t3.ts这个标记非常重要,后面排查问题的时候,所有自动化工具都能靠它识别哪些文件允许被覆盖。
3.3 生成结果怎么审查:diff 才是第一道防线
生成完之后不要急着提交代码,务必先执行t3code diff,它会显示将要被新增、修改、删除的文件清单。我看过太多人在这上面吃过亏:生成器是自动的,但模板是团队自己维护的,模板一改,产物的变化可能远超预期。
有一次我调整了模板里 import 路径的写法,本来只希望改一个文件,结果 diff 显示两百多个文件全部要重写。如果没有提前看 diff 就提交,代码评审的时候会非常痛苦。
我的习惯是:涉及模板变更时,一定先跑 diff,把文件清单截图发到群里同步一下,确认没有意外再执行写入。
4. 模板与插件机制:不想被工具框死,就必须搞懂这层
4.1 模板语法速览:变量、条件、循环、引用
t3code 的模板语法和 Nunjucks/Jinja 高度相似,如果你写过服务端渲染模板,上手几乎零成本。常用的三类结构:
{# 变量插值 #} export const {{pascalCase name}}Schema = z.object({ id: z.string().uuid(), }); {# 条件判断 #} {% if fields | hasOne('status') %} status: z.enum([...]), {% endif %} {# 循环遍历 #} {% for field in fields %} {{camelCase field.name}}: {{field.zodType}}, {% endfor %}比较特别的是它内置的hasOne、hasMany、withRelation这类过滤器,它们不是简单的字符串函数,而是会解析 schema 的 AST,让你能根据字段特征做条件渲染。比如某个模型里有枚举字段,模板里就可以选择生成对应的枚举定义,没有就不生成。
这套语法设计得非常克制,它鼓励你在模板里做"展示逻辑",而不是复杂的业务逻辑。如果某个生成逻辑特别复杂,应该写成插件,而不是在模板里堆一堆{% if %}嵌套。
4.2 自定义一个 Vue 页面生成器的完整示例
官方模板覆盖了 React 和类型定义这些基础场景,但真实项目里总有自己的需求。这里给大家看一个我写的 Vue 3 页面生成器示例,完整走一遍自定义流程。
在t3code.config.ts里注册自定义生成器:
import { defineConfig } from 't3code'; export default defineConfig({ models: './models/*.t3.ts', generators: [ { name: 'vue-page', match: (model) => !model.name.includes('Detail'), template: './templates/vue-page.hbs', output: (model) => `src/views/${kebabCase(model.name)}/index.vue`, }, ], });match函数决定这个生成器应用在哪些模型上;output决定产物路径和文件名。模板文件vue-page.hbs大致长这样:
<template> <div class="page-{{kebabCase name}}"> <el-table :data="list" v-loading="loading"> {% for field in fields %} <el-table-column prop="{{camelCase field.name}}" label="{{field.label}}" /> {% endfor %} </el-table> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { {{camelCase name}}Api } from '@/api/{{camelCase name}}Api'; const list = ref<{{pascalCase name}}[]>([]); const loading = ref(false); onMounted(async () => { loading.value = true; try { const res = await {{camelCase name}}Api.list(); list.value = res.data; } finally { loading.value = false; } }); </script>这个自定义生成器跑起来之后,新建一个模型再执行t3code generate,Vue 页面、API 封装、TypeScript 类型一次性全部生成。团队里新来的同事不用纠结"列表页怎么写",统一跑生成器就完了。
4.3 模板调试:dry-run、快照目录与局部生成
自定义模板最怕的就是写错了不知道。t3code 提供三个调试手段:
第一个是--dry-run,只计算产物不写入文件,适合快速看模板输出的内容;第二个是快照模式,第一次生成时会保存一份.t3snapshot,之后任何一次生成都能和快照对比,方便排查"哪次改坏了";第三个是--filter参数,只对某个模型执行生成,不用每次都全量跑。
我在调模板时的流程是:先用--filter Order --dry-run看输出,确认没问题后撤掉--dry-run生成,再跑t3code diff确认变更范围,最后用快照对比一下历史产物,整个链路非常顺。
5. 和 Plop、Hygen 放在一起比,才看出 t3code 的设计取舍
5.1 传统生成器解决的是"创建"问题,t3code 解决的是"同步"问题
既然聊到代码生成,就绕不开 Plop 和 Hygen 这两个老牌工具。过去我在小项目里常用 Plop,它的思路是:你定义一些模板,然后手敲命令把模板渲染成新文件。它非常灵活,但也非常"一次性"——生成完之后,这些文件和模板再也没有关系了。如果模板升级了,已有文件不会跟着变。
Hygen 稍微进了一步,它把生成动作拆成很多小的 build 块,可以增量执行,但仍然没有"模型——产物"这种强对应关系。命令怎么写、文件生成到哪,每一步都靠你手动组织。
t3code 把重心放在了"同步"上:schema 是源头,文件是结果,只要你改 schema 并重新生成,所有产物就会跟着变。这个思路更接近 Prisma 的 migration 工作流,而不是传统的脚手架工作流。
5.2 一组直观的对比
| 维度 | Plop | Hygen | t3code |
|---|---|---|---|
| 核心抽象 | prompt + 模板 | 可组合的 builder | schema + 模板 + 同步器 |
| 类型安全 | 弱,模板里基本靠字符串 | 弱 | 强,schema 本身就是类型 |
| 增量更新 | 不支持,重复生成会覆盖 | 部分支持 | 原生支持,含 diff 和快照 |
| 死代码检测 | 无 | 无 | 有 |
| 学习成本 | 低 | 中 | 中高 |
| 适用场景 | 临时创建单个文件 | 批量生成文件 | 长期维护、需要类型同步的项目 |
我自己用下来的体会是:如果你只是偶尔创建一个组件,Plop 完全够用,没必要上 t3code;但如果你是做一个会持续迭代半年的业务系统,字段会加、关联会变、页面会增删,t3code 这种"以 schema 为唯一事实来源"的模式省心的不是一点半点。
5.3 迁移过程中的真实成本
当然,t3code 也不是免费午餐。迁移到它的第一周,我们团队所有人都在骂:模板要调、命名要统一、历史代码要逐步改造,最烦的是早期生成的代码偶尔会有格式问题,得手动修完再在模板里补规则。
这个成本必须提前评估。我的建议是不要做"一刀切"迁移,而是新模块用 t3code,旧模块保持手写。等团队摸熟了模板语法,再慢慢把高频模块改造成生成产物。
6. 三个真实故障的完整排查链路:生成器不是银弹
代码生成工具最大的幻觉是"生成出来的代码一定是对的"。我在实际使用的第三周就连续踩了三个坑,每一个都值得单独拿出来说。
6.1 故障一:生成的路由和手写路由互相覆盖
现象:某次重新生成之后,线上路由 404。排查发现,模板里定义的输出路径src/router/routes.ts和手写路由文件是同一个文件。生成器执行的时候直接把手写文件覆盖了,而手写部分里包含的商品详情路由就丢了。
排查过程:先看t3code diff发现routes.ts被标记为 modified,再打开文件看到头部有@t3code-generated标记,确认它是生成产物。但问题在于这个文件同时也在手写维护。
修复方案:把路由拆成两个文件。自动部分生成到src/router/generated.ts,手写部分保持在src/router/custom.ts,然后在routes.ts里手动合并。核心原则是:生成器和手写代码绝对不能共享同一个文件,除非模板里明确设计了可编辑区域。
6.2 故障二:重复执行生成导致文件重复内容爆炸
现象:同一个模型连续跑了两次t3code generate,第二次生成的接口文件里多了一倍的代码,像是把上一次的内容又拼接了一遍。
排查过程:打开重复的文件,发现里面有两份几乎一模一样的export const createOrderApi,说明生成器的"幂等性"被破坏了。进一步查模板,发现问题出在一个{% for %}循环里,我把模型列表和字段列表搞混了,导致每个字段都重复渲染了整个 API 定义。
修复方案:修复模板循环层级,并立刻建立了一个约定——所有模板必须经过幂等性测试:同一个 schema 连续生成两次,产物必须完全一致。这个测试在后来的 CI 里固定下来了。
6.3 故障三:模板里的条件分支写错,产物静默缺失
现象:某天我发现生成的表单组件里没有提交按钮,但模板文件里明明写了按钮。
排查过程:看模板里按钮是通过{% if options.submitButton %}包起来的,而options在 schema 的配置里没传,条件不成立,按钮就静默消失了。没有报错,没有任何提示,如果不是我肉眼发现,这个问题会直接带到生产环境。
修复方案:把条件分支改成默认 true,并给模板加了注释提醒。更重要的是,team 约定生成完成之后必须抽查产物,不能只看"命令执行成功"就完事。
这三个坑给我的共同教训是:生成器虽然自动,但模板的作者是人,人会写错。所以在团队里,我坚持做到三件事:CI 里跑 diff-check,不通过不允许合并;生成代码必须走 code review;任何模板改动必须配 Changelog。
6.4 兜底手段:冻结标记、代码审查、CI 校验
除了上述三点,t3code 还支持给某个生成文件加freeze标记,标记之后生成器会跳过它,除非你显式传--force。这个机制非常适合处理那些"模板暂时改不动,但产物又要小范围手调"的临时场景。
我的建议是,冻结标记是最后手段,不要滥用。冻结的文件多了,schema 和产物之间的一致性就会失去保障。实在要冻结,也要在代码评审里给一个明确的备注,说明冻结原因和预计解冻时间。
7. 团队落地时最容易被忽视的四个约定
工具本身是死的,怎么用是活的。t3code 在我们团队稳定跑了两个月之后,我总结出四个落地约定,每一个都是靠踩坑换来的。
7.1 schema 变更必须走 MR
schema 是生成器的唯一事实来源,它的变更会像涟漪一样扩散到所有产物。所以 schema 的修改必须单独提 MR(合并请求),不能混在业务改动里。评审的人要重点看字段类型和约束,因为这些直接影响生成出来的校验规则和数据库结构。我们甚至规定 PR 描述里必须附带t3code diff --name-only的输出,让所有人知道这次变更影响面有多大。
7.2 生成文件与手写文件的边界要画清楚
团队里最容易发生的冲突是:一个人改了生成文件,另一个人跑了生成器把他改动覆盖了。我们约定,带有@t3code-generated标记的文件一律不允许手写,需要改产物就改模板。如果模板表达不了,就在模板里设计// @t3code-manual-start和// @t3code-manual-end这样可编辑区域,生成器只会保留这两块之间的内容。
7.3 CI 里跑 diff-check
我们已经把t3code diff --check配进了 CI,任何生成产物和 schema 不一致的提交都会被拦下来。这个检查本质上是强制大家"改 schema 就重新生成,改模板就更新产物",听起来像废话,但如果没有自动化约束,人一定会偷懒。
7.4 定期清理过期模板
业务在变,模板也在变。有些模板对应的场景已经不存在了,但还留在templates/目录里,会误导团队里新来的同事。我每隔两个迭代会做一次模板 review,把没人用的模板删掉,把用得很别扭的模板重构。这个做法让 t3code 没有变成一个新的技术债来源。
最后说点个人体会。t3code 不是一个拿来即用的低代码工具,它需要你花时间去理解模板、维护 schema,甚至写自定义生成器。但它带来的收益也足够明显:我们团队的新模块开发速度比原来快了差不多两倍,类型不同步的问题基本消失,代码评审的重点也从"格式对不对"变成了"业务逻辑对不对"。如果你所在的团队也被大量样板代码折磨,不妨先拿一个小模块试一下,跑通一个生成链路再决定要不要全面推广。