大概半年前,我给自己定了个小目标:把整天复制粘贴“用户管理模块”“订单列表页”这类重复代码的时间省下来,拿去好好做业务设计。折腾了一段时间后,我做出来一个内部代号叫t3code的代码生成流水线,简单说,它是一套“三段式”的全栈代码生成工具:先根据数据模型生成后端领域代码,再生成客户端类型与请求层,最后生成可直接改写的页面骨架。文章发出来后,一直有朋友问里面的模板规则、工具链选型和坑点,所以我把这套设计思路和实操过程完整整理出来,想自己搭类似工具的朋友可以直接参考。
我做这块的定位很明确:不是要做那种一键生成整个项目的重型脚手架,而是解决日常开发里最高频的“重复模块”问题。它适合被 CRUD、样板代码、字段漂移折磨过的全栈开发者,也适合想给团队沉淀一套统一代码规范的工程负责人。下面我会先把它的设计思路讲透,再完整走一遍从模型定义到页面生成的实操流程,最后附上我踩过的一些坑。
1. t3code 的定位与设计思路
1.1 名字里的三个边界:Type-checked、Template-driven、Toolchain-integrated
t3code 这个名字,核心不是“第三代代码”这种噱头,而是我给它划定的三个设计边界:类型安全(Type-checked)、模板驱动(Template-driven)、工具链一体化(Toolchain-integrated)。
这三个词决定了整个工具的走向。类型安全指的是,所有生成出来的代码,从数据模型到前端调用,一路都有完整的 TypeScript 类型贯穿,字段改名、删除、类型变更会直接编译报错,而不是运行时才炸。模板驱动指的是,生成逻辑全部基于模板文件而非靠代码里字符串拼接,你想改输出风格,直接改模板。工具链一体化则是指,它不是一个孤立的 CLI,而是跟 Git hooks、CI 检查、代码格式化器、数据库迁移流程全部打通。
我见过不少团队自己写的代码生成脚本,最后都变成了“一次性脚本”:改了一个需求,脚本生成的代码没法维护,只能删掉重来。t3code 的设计从一开始就避开这个坑:它只负责生成“骨架”和“约定层”,凡是需要人工发挥的地方,全部留出手改的入口。
1.2 从痛点反推方案:不是不想写代码,是重复代码不值得写
我先说说为什么要花几周时间做这样一个工具。当时团队接了一个后台管理系统,二十多个模块,每个模块几乎都是同一个套路:建表、写模型、写接口、写前端列表页、写表单页、配菜单和权限。我跟搭档算了一笔账,一个标准模块从零手写,最顺利也要两到三天,其中至少六成时间花在复制上一段代码然后改字段名上。
复制粘贴真正的痛点不是慢,而是容易漏。字段少一两个还好说,最怕的是改了表结构之后,前端类型没同步、校验规则还是旧的、查询参数缺了一个。这些问题排查起来特别费劲,因为错误往往散布在模型、DTO、接口层、页面组件里,靠人肉找和改,一次两次还行,模块一多是灾难。
市面上的脚手架我基本都试过,很多确实能生成一套完整可运行的项目,但一旦项目已经存在,只想往里面加一个新模块,这些工具箱就显得太重了。有些要求你必须接受它的目录结构,有些生成的代码封装黑盒,换业务场景的时候反而比手写还难改。我需要的是一个轻量、可渐进落地、生成的代码“一次性交付但永远可维护”的工具,这就是 t3code 的出发点。
2. 核心细节与玩法拆解
2.1 三段生成:从数据模型到页面,每段负责一层的约定
t3code 的生成过程分成三阶段,我给它们起了名字:domain(领域层)、client(适配层)、ui(展示层)。每段都在前一段的产物上继续加工,但又保持着独立的模板和产物目录。
第一段是 domain。输入是一份数据模型描述文件(后面会详细说格式),输出包括:数据库迁移文件、实体/模型定义、Repository 接口、DTO 和基础 CRUD 的服务端接口代码。这段最关键,因为后续所有层都依赖这里的字段、类型和关系定义,一处错了下面全错。
第二段是 client。它扫描第一段生成的服务端类型定义,自动生成前端的 API 请求函数、TypeScript 类型、状态管理 store、表单校验规则的初始版本。很多人会忽略这一层,但我认为这恰恰是 t3code 最保值的地方——字段的增删改,在编译期就能通过类型缺口暴露出来了。
第三段是 ui。它根据模型描述生成列表页、创建/编辑表单页、详情页的基础布局,同时自动注册路由和菜单配置。生成的页面不是花哨的高级组件,而是干净、语义化、可直接手写改写的模板代码。三段流水线跑完,一个模块的基础能力就齐了,剩下的是业务特有的校验、交互和样式调整。
2.2 为什么是模板引擎,而不是“代码字符串拼接”
一开始我也写过字符串拼接的生成脚本,"const " + fieldName + " = ref(" + ... + ")"这种,写完不到一周就失控了:改一下输出缩进、换个依赖注入风格,脚本里全是补丁。字符串拼接最大的问题是无法处理“结构嵌套”。你生成的不只是单行代码,而是带多层缩进、条件分支、循环结构的代码块,用字符串拼接来维护这种结构,等同于自己在手动写编译器的一部分。
后来我把所有输出改成了模板文件,用模板引擎渲染生成。每个产物文件对应一个模板文件,模板里通过变量插值和条件循环控制输出结构。举个例子,UI 层的列表页模板只描述表格列怎么渲染、搜索条件怎么布局,具体运行时会传入模型字段数组。这样,任何风格层面的调整,只需要改模板,不用动生成器逻辑。模板文件放在项目目录下的.t3/templates里,跟着仓库走,版本控制也方便。
这里有个容易走偏的地方:模板引擎不是越复杂越好。我选的工具只要支持变量、循环、条件、过滤器就够,不引入太花哨的宏或继承机制,否则模板本身会变成新的学习成本和维护负担。
2.3 约定大于配置:自动生成区与手改区必须隔离
如果生成的代码完全不让人改,那这个工具就是个玩具;如果生成的代码和手改代码混在一起,那下一次生成就会把你改的全冲掉。t3code 用了一个很朴素的约定:每个生成文件内部划分两个区域,用注释标记分隔。
比如一个 list page 文件长这样:
// ===== AUTO-GENERATED: do not modify this block ===== import { defineComponent } from 'vue' import { useTable } from '@/hooks/table' // ===== END AUTO-GENERATED ===== // 手写扩展区:自定义列渲染、按钮事件都在这里写自动区只放由模板控制的 import 和初始化逻辑,手写区放业务扩展。重新生成时,工具会根据文件中的标记,只覆盖自动区,手写区代码保留下来。这个设计说起来简单,实现时要注意细节:标记必须足够独特,避免和业务代码的注释冲突;标记内不要写“文件名”这种生成后可能变化的信息,否则文件改名后无法匹配旧标记。
这个“隔离带”约定也是整个工具能落地的核心。没有这个约定,团队成员永远不敢放心地重新生成,最后又会回到手工维护的节奏。
2.4 最小可用配置,长什么样
工具跑起来需要一份模型描述文件和一份全局配置。全局配置定义项目类型(这里我以常见的 Vue 3 + TypeScript + Node.js 服务端为例)、路径约定、数据库方言、代码风格等。模型描述文件则定义一个模块的完整业务结构。
一点额外的心得:模型描述文件的格式,我坚持用 YAML 而不是 JSON。原因很直白,YAML 支持注释,字段说明可以直接写在模型旁边。变量命名、备注、校验规则这些信息,留在数据文件里比留在团队成员脑子里靠谱得多。
一个最小的模型描述文件大概长这样:
model: User table: sys_user fields: - name: id type: int primary: true autoIncrement: true - name: username type: varchar length: 64 validate: required, min: 4, max: 64 - name: email type: varchar length: 128 validate: email optional: true - name: status type: int default: 1 relations: - type: hasMany model: Order foreignKey: userId这个文件就是 t3code 的唯一数据源。生成时工具按照三段流程依次渲染对应模板,最终输出物作为一个新的 git 提交对象,不会自动 commit,方便你 review 和调整。
3. 实操:从零生成一个用户管理模块
3.1 初始化项目与工具链
这个部分按实际操作顺序来。假设你已经有一个现成的全栈项目,下面我演示如何接入 t3code。如果是空目录,流程也基本一致。
首先用包管理器安装工具链:
pnpm add -D @t3code/cli @t3code/templates然后初始化配置:
npx t3code initinit 命令会询问几个问题:前端框架、UI 组件库、路由模式、服务端框架等。生成后的配置文件在项目根目录,名为t3code.config.ts。这个文件的作用是把约定固定下来,团队所有成员执行t3code gen时拿到的是同一套产物标准。
初始化完成后,项目结构里会新增一个.t3/目录,里面是模板文件、全局配置和一份 README 说明。.t3/是默认目录,也可以改成configs/或tools/,不确定的话建议保持默认,少一个约定就少一份心理负担。建议把生成的.t3/目录纳入版本控制,因为它本身就是项目规范的一部分,不是一个需要忽略的构建产物。
3.2 定义数据模型:user.t3.yaml
模型文件放在.t3/models/目录下,命名规则是模块名.t3.yaml。这里以用户管理模块为例,我创建一个user.t3.yaml。
model: User table: sys_user caption: 用户管理 fields: - name: id type: int primary: true autoIncrement: true comment: 主键 - name: username type: varchar length: 64 validate: required, min: 4, max: 64 comment: 登录用户名 - name: nickname type: varchar length: 64 optional: true comment: 展示昵称 - name: email type: varchar length: 128 validate: email optional: true comment: 邮箱 - name: status type: int default: 1 remark: 1-启用 2-禁用 - name: lastLoginAt type: datetime optional: true comment: 最后登录时间 relations: - type: hasMany model: Order foreignKey: userId字段列表不用写得太满,把系统关心的主键、唯一约束、常用检索字段和关联关系写清楚就够了。业务备注和校验规则尽量写进来,这些都是后续生成表单、校验函数、列表筛选条件的“原材料”。备注的质量,直接决定生成出来的代码质量,这一点后面还会说。
3.3 执行生成命令
模型文件就绪后,执行生成:
npx t3code gen user也可以不加模块名,执行npx t3code gen --all批量生成所有模型对应的模块。生成过程会输出一份清单,告诉你每个阶段生产了哪些文件、有没有跳过没有变化的文件。
第一次跑生成后,我建议手动检查一下产物目录结构。以常见的 src 目录举例,生成完的模块长这样:
src/ ├── server/ │ ├── entities/User.ts │ ├── repositories/UserRepository.ts │ ├── dtos/user.dto.ts │ ├── controllers/user.controller.ts │ └── migrations/xxxx_create_user.ts ├── web/ │ ├── api/user.ts │ ├── types/user.ts │ ├── stores/user.store.ts │ └── views/user/ │ ├── UserListPage.vue │ ├── UserEditModal.vue │ └── components/UserSearchForm.vue ├── router/user.ts └── menu/user.ts这不是说所有项目都应该用这个目录结构,而是强调 t3code 生成的代码一定是有序的、可预测的。团队成员不需要问“用户模块的 API 请求放在哪个文件”,默认约定就在那里。
如果你用的是 FastAPI、Go 或者其他技术栈,目录结构会相应变化,但三段分层的思想是一样的。模板里的路径变量只需要按各自框架调整,不必改动生成器核心。
3.4 联调验证:类型安全怎么落地
生成完代码,直接编译一下:
pnpm typecheck这里要重点说下 t3code 的“类型安全”为什么能落地,而不是一句口号。生成器在 client 阶段读取服务端实体定义,再把它们翻译为前端 TypeScript 类型。如果后续修改了模型的字段名,重新生成后,前端代码里用了旧字段名的地方会立即报类型错误,早于任何运行时报错。
举个例子,我把user.t3.yaml里的nickname字段改成displayName,重新执行t3code gen user后,编译提示:
src/web/components/UserSearchForm.vue:12:9 - error TS2339: Property 'nickname' does not exist on type 'UserDTO'这种报错对团队来说就是最好的“待办清单”:所有还在使用旧字段名的代码,一个个改掉就行。如果字段只是新增,没有删除,类型报错会少很多,因为新增字段不会让旧代码立刻失效,但还是建议在搜索表单和详情展示里补上对应的展示逻辑。
跑一下接口联调也有一个技巧:生成后别急着写业务,先把基础 CRUD 请求过一遍。重点测分页参数、筛选条件、排序字段是否能正确传递。这些通常是模板里最容易出问题的地方,因为不同后端的查询参数风格差异不小。确认基础链路通了之后,再叠加业务逻辑,排查范围会小很多。
4. 常见问题与排错技巧实录
4.1 生成后编译报错:类型对不上,先查模型字段再查模板
我用下来遇到最多的问题,是在更新模型字段后重新生成,结果编译报错。这里要区分两种情况:一种是报错信息指向业务代码,说明业务代码用了旧字段;另一种是报错指向生成的代码本身,说明模板或生成逻辑有问题。
第一种情况比较正常,按编译错误提示挨个改业务代码就行。第二种情况就需要排查模型定义是否合理了。常见的原因包括:字段类型写成了数据库方言不支持的字符串、关联关系里的 foreignKey 名称拼错、YAML 里字段名和类型名大小写不一致。排查方法很简单,单开一个分支,只保留最简单的模型(两三个字段),重新生成看是否还报错,用于快速二分定位。
4.2 手写代码被覆盖:约定隔离标记,不要自己发明
如果你在手写扩展区之外加了业务代码,比如在自动生成区里强行插入了一个函数,那么下次重新生成时,这些代码会被模板重新渲染而丢得一干二净。这不是工具设计问题,是没遵守约定。
隔离区的注释标记是工具能否安全重放的前提。我在团队里推行一条硬性规则:凡是自动生成的代码,一看注释标记就明白,不要在自动区写任何业务代码。如果你确实需要改写自动区的逻辑,正确做法是修改模板,让模板输出更贴合你的需求,而不是每次生成后手动改。把“改生成结果”的习惯换成“改生成源头”,工具才越用越顺手。
我把这个规则写进了 t3code 的模板注释模板里,每次生成的代码都会带一行提示:
// ===== AUTO-GENERATED BLOCK: 请不要修改 =====完整的注释还有版本说明,方便后续排查。这行注释看着不起眼,实际上有效避免了团队里一半以上的覆盖事故。
4.3 复杂业务关系:嵌套关系不能无脑生成
模型支持 hasMany、belongsTo、manyToMany 这类关系,但我不建议把所有关系都自动化生成。真正适合自动生成的是“外键归属”这种直观关系,比如订单归属某个用户。很多对多关系、树形结构这种复杂场景,生成的代码往往需要大量业务定制,自动化反而增加了理解成本。
我的做法是在模型描述里加了一个generate: false的选项。如果一个关系标注了不参与生成,工具只会生成与之关联的基础类型定义,不会生成对应的页面和 API。这样既保留了关系在数据模型层的完整性,又避免了把业务复杂度错误地摊给模板。
4.4 生成内容不对,但模板看起来没问题
有一类隐蔽问题:模板是正确的,但渲染出来的代码不符合预期。多半是因为模型描述文件里的字段命名不规范,比如混用了下划线、驼峰、短横线。比如模型里写last_login_at和lastLoginAt、lastLogin三处不一致,生成后再去调整命名就容易漏。
我在模型规范里强制要求:字段名一律使用小驼峰,数据库列名单独用column字段映射。也就是说,模型里的lastLoginAt在数据库中对应last_login_at,生成的 TypeScript 代码里只用小驼峰。这样避免了大量命名风格不一致带来的混乱,也让模板只需守一套命名规则。
4.5 应该何时重新生成,何时手工维护
最后一个问题,也是用这类工具最容易纠结的问题:模板只改一行的话,要不要全量重跑?
我的个人判断标准是:如果你要修改的只是一个文件中的一个点,而且不改业务结构,手工改更省事;如果这个点会在多个模块反复出现,那就值得去改模板,让所有模块统一更新。业务结构发生变化,比如新增字段、改关系、改校验规则,则重新生成是推荐的路径,因为生成器能同步更新所有相关层。
下面把高频问题整理成一个速查表:
| 问题描述 | 根本原因 | 推荐处理 |
|---|---|---|
| 手写代码被覆盖 | 代码写在自动区 | 移到手写区或改模板 |
| 生成代码类型报错 | 模型字段名/类型不匹配 | 比照数据库方言修正模型 |
| 某些模块不需要生成页面 | 关系或模型不适合自动化 | 给模型加 generate: false |
| 运行旧模板生成新代码 | 模板更新但模型未兼容 | 先跑 model lint 检查模型 |
| 生成的风格不符合团队规范 | 模板输出风格待调整 | 修改 .t3/templates 对应文件 |
| 批量生成部分失败 | 某个模型文件格式错误 | 单独校验该模型,查看详细日志 |
5. 落地效果、推广教训与一点体会
t3code 在我们团队跑通后,一个标准模块从设计到页面可用的时间,从两到三天压到了半天以内。更重要的变化不是速度,而是质量的稳定性:字段漏改、类型不同步这类低级错误几乎消失了,因为生成器强制把约定固化到了每个文件里。
但我也要说句实话,工具不是银弹。t3code 能顺利跑起来,前提是团队接受“模型描述文件是唯一数据源”这个观念转变。以前改需求是直接改页面,现在要先改模型文件再重新生成。这个转变需要一点点磨合期,但一旦习惯,你会发现自己对字段、校验、关系的理解比原来清楚得多,因为所有信息都被迫沉淀成了一份清晰的结构化文档。
最后分享一个我在具体实践中养成的习惯:给每个模型文件写caption和工作流标签。我可以快速筛选出“哪些模块是核心领域、哪些只是报表展示、哪些数据来自外部系统”。这些标签不会直接影响生成代码,但它们帮助团队在讨论一个新的页面需求时,能够快速共识:这个模块应该走生成流程,还是走纯手工扩展流程。边界清楚了,t3code 才能真正成为一个项目的工程契约,而不只是又一个炫技脚本。