☰
t3code:基于 T3 技术栈的自动化代码生成器,让 CRUD 开发效率翻倍
2026/10/28 22:35:00 网站建设 项目流程

t3code 这个名字,第一次见的人大概会以为是个三线工具库,但实际上它是一套跑在 Node.js 上的 T3 技术栈业务代码生成器。你只需要把一张业务表、一个页面需求、一组接口约定写成 schema,它就能在几秒内把数据库模型、API route、类型定义、页面骨架、表单组件全部铺开,而且生成出来的代码是类型安全、可直接编译的,不是那种只能当参考的玩具代码。

说句实话,这玩意最爽的地方不是“敲命令生成文件”这个动作本身,而是它把团队里那套一直靠嘴传、靠记忆维护的编码规范,硬生生塞进了一组模板和配置里。新人来了不再需要翻十几篇文档才知道“router 文件放哪、DTO 怎么命名、字段类型怎么映射”,他只需要跑一条命令,生成器会把该有的结构、命名、类型、校验全部按团队规范排好。本文适合正在折腾 T3 Stack(TypeScript + Tailwind + TRPC + Next.js)、想提升 CRUD 开发效率、或者想自建一套代码生成工作流的前端/全栈开发者阅读。

1. 项目定位与生成器设计思路

1.1 t3code 解决什么问题:把五处重复劳动变成一次生成

先算一笔账。以前我在项目里加一个“用户管理”模块,至少要改五个地方:第一,数据库 schema 里加一张表;第二,服务端写一套 CRUD 的 TRPC router;第三,写输入输出 DTO,还得配 Zod 校验;第四,前端建页面,表格、筛选、弹窗表单;第五,类型定义要在共享类型文件里同步一份。五个地方分布在五个目录,用的是四种命名风格,数据库里叫user_accounts,API 里叫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/ # 默认生成落地目录,一般会 gitignore

plopfile.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_profiles,API route 可能叫user.getProfile,组件文件可能叫UserProfileModal.tsx。手写的时候总要靠脑子转一道弯,一急就容易写漏。

t3code 的做法是建立一层统一的命名服务,把所有转换函数集中在一个 helper 里。我维护了一份命名转换表,基本上覆盖了生成路径里 90% 的场景:

输入 / 场景命名函数输出示例
模块名(原始输入)camelCaseuserProfile
目录名kebabCaseuser-profile
Prisma / 类型名称pascalCaseUserProfile
Router 方法名camelCase+pluraluserProfile.list
文件名后缀固定 +kebabCaseuser-profile.router.ts
数据库表名snakeCase+pluraluser_profiles

这套转换体系最大的价值是消除不确定性。任何地方需要命名,都从同一个源头推导,而不是在不同模板里各写各的逻辑。我甚至会把helpers/naming.js单独拎出来写单元测试,因为这里的 bug 影响面是全方位的,一个函数写错,所有产物全歪。

2.4 参数校验与字段类型映射:一份 schema 控制前后端

字段类型映射是整个生成器最体现工程价值的地方。一张表里写着string,到了 Prisma 就要变成String,到了 Zod 就要变成z.string(),到了前端输入框类型就要变成text。这四套体系各有各的写法,最容易不一致的就是这里:后端校验允许空字符串,前端却默认必填;数据库字段是DateTime,Zod 却只校验了普通字符串。

我在t3code.config.js里维护一张字段映射表,生成器在渲染模板之前会先把 schema 里的type转成需要的形态:

schema typePrisma 类型Zod 校验React 输入控件
stringStringz.string()<Input type="text">
emailStringz.string().email()<Input type="email">
integerIntz.number().int()<Input type="number">
booleanBooleanz.boolean()<Checkbox>
datetimeDateTimez.date()或z.string().datetime()<DatePicker>
jsonJsonz.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。整个过程不需要改生成器内核,只是往里加“插件”。

但扩展的时候有两个红线不要碰。一个是别把太多的全局状态塞进配置中心,配置越厚,团队心智负担越重,到最后没人知道某个字段映射是从哪来的;另一个是别为了赶进度放弃模板注释和文档,模板一旦失去可读性,它就退化成一段没人敢碰的魔法字符串,整个生成器也就离报废不远了。

最后再分享一个我个人的小体会,如果你打算在自己的团队里引入这类代码生成器,千万不要一开始就追求“万能”。从一个真实项目里用得最多的两个模块开始,把它们手写的稳定版本反向提炼成模板,跑通了再逐步扩。生成器不是替你解决所有问题的银弹,它更像是把你的团队规范固化成可执行代码的一次机会,花点时间打磨模板,后续省下来的时间会远超你的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询