t3code:基于 T3 技术栈的自动化代码生成器,让 CRUD 开发效率翻倍 t3code 这个名字第一次见的人大概会以为是个三线工具库但实际上它是一套跑在 Node.js 上的 T3 技术栈业务代码生成器。你只需要把一张业务表、一个页面需求、一组接口约定写成 schema它就能在几秒内把数据库模型、API route、类型定义、页面骨架、表单组件全部铺开而且生成出来的代码是类型安全、可直接编译的不是那种只能当参考的玩具代码。说句实话这玩意最爽的地方不是“敲命令生成文件”这个动作本身而是它把团队里那套一直靠嘴传、靠记忆维护的编码规范硬生生塞进了一组模板和配置里。新人来了不再需要翻十几篇文档才知道“router 文件放哪、DTO 怎么命名、字段类型怎么映射”他只需要跑一条命令生成器会把该有的结构、命名、类型、校验全部按团队规范排好。本文适合正在折腾 T3 StackTypeScript Tailwind TRPC Next.js、想提升 CRUD 开发效率、或者想自建一套代码生成工作流的前端/全栈开发者阅读。1. 项目定位与生成器设计思路1.1 t3code 解决什么问题把五处重复劳动变成一次生成先算一笔账。以前我在项目里加一个“用户管理”模块至少要改五个地方第一数据库 schema 里加一张表第二服务端写一套 CRUD 的 TRPC router第三写输入输出 DTO还得配 Zod 校验第四前端建页面表格、筛选、弹窗表单第五类型定义要在共享类型文件里同步一份。五个地方分布在五个目录用的是四种命名风格数据库里叫user_accountsAPI 里叫user.list前端组件叫UserTable类型叫IUserItem。这还只是一个模块如果一次要加五六个业务模块整个人就是在机械劳动里反复横跳。t3code 的思路就是把这些重复且规律性强的劳动抽成模板。数据库模型、Router 方法、Zod 校验、React 组件、共享类型全部由同一个 schema 驱动生成。只要你把字段列表、模块名、是否需要页面这些信息写清楚剩下的命名转换、目录落位、类型推导、校验规则都由生成器兜底。它不是帮你写业务逻辑而是把业务逻辑外面的“包装层”全部标准化让你把精力真正留给那些不重复、需要思考的部分。1.2 为什么选“模板 配置 脚本”而不是图形化低代码平台老实说我早期也试过用现成的低代码平台搭表单一顿拖拽界面上看着挺唬人但到了真正要接进项目的时候就开始别扭了。平台生成的是它自己的运行时依赖你想要一个 TRPC 的tRPC上下文传参不行。你想把生成的代码放进你们已有的 monorepo 目录结构也不行。平台是封闭花园它能给你很多但你要想按自己团队的规矩来就得付出额外成本。所以 t3code 走了“模板 配置 脚本”的路子本质就是一套本地跑的代码生成管线底层文件读写逻辑自己控制。这个选择的好处非常明显模板文件就是普通的 TypeScript 文本可以直接放进 Git 里做 diff生成器本身是 Node.js 脚本没有额外服务依赖团队想加一条规则直接改模板再重新生成就是而不是去平台后台翻半天设置项。说白了生成器是透明而且可控的你用起来心里有底。1.3 三条设计原则约定优于配置、幂等生成、模板即文档我后面维护这套生成器时给自己定了三条铁律建议想自建生成器的朋友也参考一下。第一条是约定优于配置。一个模块的目录位置、文件名命名、导出方式在模板里写死不要给用户太多选择。选择多了生成器就复杂复杂度一上来就没人愿意维护。比如我们规定所有业务模块都放src/modules/模块名/下API router 统一叫xxx.router.ts页面统一叫xxx-page.tsx新同事拿到手不需要问“这个文件该放哪”看一眼目录结构就懂了。第二条是幂等生成。同一份 schema不管执行多少次生成命令产出的结果都应该是一致的反复执行不能产生副作用。这一点对 CI 和团队协作尤其重要不然你没法判断“生成结果漂移”到底是人为改动还是脚本不稳定。实现上我主要靠两点文件名完全由命名函数推导不掺随机值写文件之前先检查目标是否存在存在就跳过或者明确报错绝不静默覆盖。第三条是模板即文档。在生成产物的头部固定加一段注释标明“此文件由 t3code 生成请勿手动修改”并且把模板里的关键逻辑写到注释里。这样谁看到产物里某个奇怪写法都能顺着注释找到模板源文件也就能理解生成器的行为逻辑。模板不只是给机器读的代码它同时也是团队规范的最鲜活文档。2. 核心细节解析模板、配置与命名体系2.1 目录结构每个文件夹负责什么一个完整的 t3code 生成器项目我建议至少保留下面这几个目录各司其职才不会乱。t3code/ ├── package.json # 安装 plop、ejs、prettier 等依赖 ├── plopfile.js # 生成器的入口配置定义生成命令 ├── t3code.config.js # 全局配置输出目录、命名规则、类型映射 ├── schemas/ # 业务 schema一个 json 对应一个模块 │ └── user.schema.json ├── templates/ # 模板文件产物内容的来源 │ ├── db-model.prisma.hbs │ ├── api-router.ts.hbs │ ├── api-input.ts.hbs │ ├── component-form.tsx.hbs │ └── page-list.tsx.hbs ├── helpers/ # Handlebars 自定义辅助函数 │ └── naming.js └── output/ # 默认生成落地目录一般会 gitignoreplopfile.js是生成器的核心调度器它会读取 schema然后决定调用templates/里哪些模板、把产物写到哪个路径。t3code.config.js里存放全局默认值比如你的输出根目录、默认是否生成页面、字段类型映射表。schemas/是使用方唯一需要关心的目录业务同学在这个目录里加一个 JSON 文件就等于提出一次生成需求。templates/则是整套体系里需要花最多时间打磨的部分它决定了生成出来的代码质量上限。2.2 模板引擎选型为什么是 Plop Handlebars生成器底层的模板引擎我选的是 Plop它内部使用 Handlebars 语法。选它的理由有三个第一Plop 自带文件动作系统add动作负责创建新文件addMany可以批量创建并且会自动检查文件冲突第二它支持 prompt也可以直接接收外部传入的数据对象既能交互式使用也能被 schema 驱动第三生态成熟辅助函数可以直接复用change-case那一套命名转换。模板里最常用的语法就那几个。{{ kebabCase moduleName }}负责把userProfile转成user-profile用在文件名里{{#each fields}}用来遍历字段列表生成一行行定义{{#if required}}做条件判断控制某个字段是否加校验。我直接贴一段实际用过的 Prisma 模型模板片段看完你就明白这套语法有多直观model {{pascalCase moduleName}} { id String id default(cuid()) {{#each fields}} {{this.name}} {{this.dbType}}{{#if this.required}} default({{this.defaultValue}}){{/if}} {{/each}} createdAt DateTime default(now()) updatedAt DateTime updatedAt }这一段模板跑出来的效果就是每一行字段名、数据库类型、默认值都由 schema 里的fields数组驱动。你不需要在模板里写任何一种业务判断只要把“骨架”描述清楚。当初我为了省事试过直接用纯字符串拼接的方式生成文件写了几次就放弃了那个可读性和可维护性比 Handlebars 差太多。2.3 命名转换体系文件、类型、路由为什么能自动对齐生成器最容易被低估的部分其实是命名转换。团队规范之所以难落实就是因为同一件事有太多叫法。比如模块名叫userProfile数据库表可能叫user_profilesAPI route 可能叫user.getProfile组件文件可能叫UserProfileModal.tsx。手写的时候总要靠脑子转一道弯一急就容易写漏。t3code 的做法是建立一层统一的命名服务把所有转换函数集中在一个 helper 里。我维护了一份命名转换表基本上覆盖了生成路径里 90% 的场景输入 / 场景命名函数输出示例模块名原始输入camelCaseuserProfile目录名kebabCaseuser-profilePrisma / 类型名称pascalCaseUserProfileRouter 方法名camelCasepluraluserProfile.list文件名后缀固定 kebabCaseuser-profile.router.ts数据库表名snakeCasepluraluser_profiles这套转换体系最大的价值是消除不确定性。任何地方需要命名都从同一个源头推导而不是在不同模板里各写各的逻辑。我甚至会把helpers/naming.js单独拎出来写单元测试因为这里的 bug 影响面是全方位的一个函数写错所有产物全歪。2.4 参数校验与字段类型映射一份 schema 控制前后端字段类型映射是整个生成器最体现工程价值的地方。一张表里写着string到了 Prisma 就要变成String到了 Zod 就要变成z.string()到了前端输入框类型就要变成text。这四套体系各有各的写法最容易不一致的就是这里后端校验允许空字符串前端却默认必填数据库字段是DateTimeZod 却只校验了普通字符串。我在t3code.config.js里维护一张字段映射表生成器在渲染模板之前会先把 schema 里的type转成需要的形态schema typePrisma 类型Zod 校验React 输入控件stringStringz.string()Input typetextemailStringz.string().email()Input typeemailintegerIntz.number().int()Input typenumberbooleanBooleanz.boolean()CheckboxdatetimeDateTimez.date()或z.string().datetime()DatePickerjsonJsonz.record(z.unknown())TextArea这张表要放在配置中心而不是散落在各个模板里因为在写多个模板的时候对同一个字段类型的处理应该保持一致避免出现 Prisma 里叫DateTime、Zod 里却当成普通字符串来校验的尴尬。如果你实际使用中遇到类型差异需要优先排查映射表而不是觉得是模板写错了。3. 实操过程从 schema 到完整功能落地3.1 初始化生成器项目5 分钟跑起来先说最基础的初始化。生成器本身就是一个标准 Node.js 项目依赖极少核心就两个plop用于任务编排change-case用于命名转换。安装命令如下mkdir t3code cd t3code npm init -y npm install plop change-case然后在package.json里加一条脚本让团队成员可以直接通过npm run generate触发生成。要生成哪一个模块统一由命令行参数指定比如npm run generate -- user在plopfile.js里我会对接入参数做一次预检把schemas/user.schema.json读进来然后传给 Plop 的setGenerator。这里不建议把 schema 路径写死在代码里而是用参数动态拼接因为团队模块会越来越多写死就只能靠复制粘贴改文件非常容易出错。3.2 编写第一个业务 schema以用户模块为例接下来是最重要的一步写 schema。我习惯把 schema 设计得尽量“声明式”只描述业务事实不包含任何模板逻辑。下面是一个用户模块的例子包含了基本信息和一个可选的扩展信息字段{ module: userProfile, description: 用户资料管理, fields: [ { name: email, type: email, required: true, unique: true }, { name: nickname, type: string, required: true }, { name: age, type: integer }, { name: bio, type: string, optional: true } ], withApi: true, withPage: true }注意我特意区分了required和optional两个字段。required表示这个字段在大多数情况下必须有值生成 Zod 校验时会用z.string().min(1)这类非空约束optional则用于允许可空字段。这里面有个坑有些人习惯把所有字段都标成 required结果生成出来的create表单全是必填项用户根本不买账。实际上应该对业务里确实“创建时必须提供”的字段标 required比如 email、nickname而像 bio 这种可填可不填的一定要标 optional。schema 写好后还要在schemas/目录里维护一份字段说明注释不然后续人接手时根本不知道某个字段是不是有意设为 optional 的。JSON 本身不支持注释我会另外弄一份schema.README.md或者干脆用.jsonc格式虽然解析时需要额外处理但对团队协作来说这点麻烦可以接受。3.3 执行生成命令看输出目录发生了什么执行npm run generate -- user之后生成器会读取 schema先做参数校验然后调用模板渲染最后在output/目录落盘。正常的输出结构大概长这样output/ └── user-profile/ ├── db/ │ └── user-profile.prisma.ts ├── api/ │ ├── user-profile.router.ts │ └── user-profile.input.ts ├── components/ │ ├── user-profile-form.tsx │ └── user-profile-table.tsx └── types/ └── user-profile.ts产物里的user-profile.input.ts会自动生成 Zod 校验规则user-profile.router.ts会自动生成 tRPC 的 create / list / update / delete 方法user-profile-form.tsx会根据字段列表自动生成表单控件。我第一次看到完整产物时反而有点不适应因为以前手写需要 40 分钟的内容现在几十秒就全出来了。但这里要提醒一句生成速度快不代表你可以完全不管生成逻辑模板质量决定产物质量前期打磨模板的时间花得非常值。3.4 接入宿主项目New 项目里怎么把产物用起来生成只是第一步把生成结果接进现有的 T3 项目才能真正跑起来。以 Next.js TRPC 项目为例接的时候有几个固定动作源。首先把output/里生成的 DB 模型文件复制到宿主项目的prisma/目录下然后在schema.prisma的generator client里import这个模型文件。这里有个我踩过的坑Prisma 的多文件 model 必须用import声明并且被引入的文件是相对路径写错了会直接导致prisma generate失败。接着把生成的 router 文件放进宿主项目的src/server/api/routers/然后在根 router 里注册import { userProfileRouter } from ./routers/user-profile.router; export const appRouter createTRPCRouter({ userProfile: userProfileRouter, });TRPC 的嵌套路由尽量保持“模块名 方法名”的层级比如userProfile.list、userProfile.create这样前端调用和后端定义天然对齐。最后把生成的页面组件挂到src/app/user-profile/page.tsx即可。如果项目的路由规则不是src/app/而是pages/需要在模板里做一个分支不然产物目录会错位。3.5 自定义模板把团队规范焊死在生成器里模板一定要按自己团队技术栈定制。t3code 默认的模板可以作为骨架但如果你用的不是 Prisma而是 Drizzle或者你的后端不是 TRPC而是普通 REST API就需要改模板。改模板的入口就是一个普通的.hbs文件改起来非常自由。我举个例子假设团队里要求所有 API 返回值统一包一层{ code, data, message }那么可以在api-router.ts.hbs模板里直接写这段export const {{camelCase moduleName}}Router createTRPCRouter({ list: publicProcedure .input({{pascalCase moduleName}}ListInput) .query(async ({ ctx }) { const items await ctx.db.{{camelCase moduleName}}.findMany(); return { code: 0, data: items, message: ok }; }), // ... create / update / delete 类似 });这样所有生成的 API route 都会自带统一返回结构。你要是靠口头约定让每个开发手写这段逻辑总有人某天漏掉code: 0而模板方案里根本不存在这种“漏写”的可能。这也是我强调“模板即规范”的原因你写进模板里的就是一定会被执行到的你没写进模板里的团队里大概也不会有人自己加。4. 常见问题与排查技巧实录4.1 一套直接能抄的排查速查表用生成器时间长了我积累了一份问题速查表基本覆盖了实际踩过的坑。先直接放出来后面再展开说几个重点。现象可能原因解决方案产物文件名多出大写字母文件名里用了pascalCase而不是kebabCase文件路径统一用kebabCase可空字段被渲染成必填schema 里没写optional: true补上 optional 标记并检查映射表生成后 Prettier 报格式错模板缩进或换行不统一生成后钩子里自动跑prettier --write重复执行生成命令报“文件已存在”忘记处理幂等逻辑add动作改成skipIfExists或force前端无法调用某个 API 方法Router 对象没在根路由注册检查根 router 是否 import 并挂载Zod 校验拒绝空字符串z.string()默认接受空串但业务上不接受模板里给 required 字段加.min(1)数据库模型和类型定义不一致类型映射表某条配置写错先查配置中心再查模板具体行4.2 模板渲染变量丢失与类型不一致的坑最典型的问题就是字段可空性。你 schema 里写optional: true但模板里判断条件写的是{{#if required}}结果 option 字段在 Zod 里依然被渲染成必填。手写代码时眼睛扫一眼不出来生成代码后一眼看过去全是“必填”更发现不了。我的排查方法是在plopfile.js里生成结束后顺手打印一份“渲染摘要”把每个字段的required / optional / type打出来相当于给生成过程加了个日志。另外一个是 Handlebars 的特性访问一个不存在的变量默认渲染成空字符串而不是报错。比如模板里写了{{this.defaultValue}}但 schema 里没定义defaultValue字段被渲染成空串结果生成的 Prisma model 变成age Int连默认值都没有。这个问题非常隐蔽因为你看到的产物是“缺了一段”而不是“报错”。后来我在所有可能缺省的字段上都挂了default辅助函数比如{{default this.defaultValue null}}暴露缺失而不是悄悄吞掉。4.3 幂等性重复生成会不会毁了手改代码生成器最容易闯祸的地方就是覆盖。我用 Plop 的add动作时原本默认是“文件存在就报错”这确实安全但团队里有人改了一点产物再重新生成时就会感觉“这工具真难用”。后来我换了个策略产物里有明确标记// GENERATED BY T3CODE 的文件才可以被覆盖其他没有标记的文件保留手写内容。实现上是在模板开头就加上这个标记然后 action 里判断文件头有没有标记有才执行覆盖。更保险的方案是把所有生成的代码放在一个单独目录比如src/modules或者generated/并且告诉团队“这些目录别手改”。我见过有团队把产物和手写业务代码混在一个目录里结果生成一次手写的十几处改动全被覆盖差点引发事故。所以我的建议是宁可多做几个目录的跳转也别让手写代码和生成代码住在一个屋檐下。4.4 团队协作生成代码怎么进 Git、怎么 Code Review代码生成后很多人会纠结“这些代码要不要提交到 Git”。我的意见是必须提交。如果不提交CI 构建的时候还得跑一套本地生成逻辑产物和 schema 之间的 diff 也没法审计反过来说把它提交了每次生成后你都能通过 Git diff 清楚地看到“这次改动影响了哪些文件”这是生成器最大的红利。但提交之后要配合一条规则人工代码评审时重点看 schema 和模板的 diff生成物本身可以快速扫一眼。因为生成物是机械产物只要模板审过了产物基本不会有大问题。可以让 CI 跑一个检查执行生成命令后如果 Git 工作区出现未预期的 diff就判定构建失败。这样逼着团队把 schema 变更和产物变更保持在同一次提交里而不是生成完不提交产物、或者手改产物导致与模板漂移。5. 生成器的边界与扩展方向5.1 哪些代码适合生成哪些不适合用了很长一段时间之后我越来越清楚生成器的边界不是“所有代码都能生成”而是“高频、稳定、强模式化的代码才值得生成”。CRUD 的路由、数据库模型、表单骨架、DTO、基础类型这些高度重复而且只要有输入参数就能推导结果天然适合生成。反而是一些需要大量业务判断的东西比如复杂的权限策略、多层状态机的流转逻辑、需要结合上下文做交互的页面逻辑就不适合硬塞进模板里。强行生成不可控的复杂代码最后一定会被团队嫌弃。这不是工具的问题而是抽象时机不对。最好的做法是让模板保持“瘦”把复杂的业务逻辑留出一个可扩展的插槽比如生成一个基础的UserProfileForm同时允许你在产物里挂一个UserProfileExtraFields的自定义组件让手写代码有地方长出来。模板就像脚手架它帮你搭好结构但装修的活还是得人来干。5.2 从一处用到处处用到还要注意什么t3code 这类生成器最大的优势就是可扩展。等一个团队用得顺手了自然会产生更多需求比如要生成 Excel 导入模板、要生成定时任务的骨架、要生成消息通知的消费者。这些扩展点其实都是同一个套路加一个 schema 文件、写一组模板、在 plopfile 里注册一个新的 generator。整个过程不需要改生成器内核只是往里加“插件”。但扩展的时候有两个红线不要碰。一个是别把太多的全局状态塞进配置中心配置越厚团队心智负担越重到最后没人知道某个字段映射是从哪来的另一个是别为了赶进度放弃模板注释和文档模板一旦失去可读性它就退化成一段没人敢碰的魔法字符串整个生成器也就离报废不远了。最后再分享一个我个人的小体会如果你打算在自己的团队里引入这类代码生成器千万不要一开始就追求“万能”。从一个真实项目里用得最多的两个模块开始把它们手写的稳定版本反向提炼成模板跑通了再逐步扩。生成器不是替你解决所有问题的银弹它更像是把你的团队规范固化成可执行代码的一次机会花点时间打磨模板后续省下来的时间会远超你的投入。