凌晨一点,我盯着屏幕上第 N 份从旧项目里复制过来的表单页,Ctrl+H 替换完字段名,正想长舒一口气,却发现漏改了一个地方,编译直接报错。那一刻我意识到:作为一个靠“Ctrl+C / Ctrl+V”行走江湖的程序员,我真正缺的不是手速,而是一个能把我从样板代码地狱里捞上来的工具。
于是 t3code 就这么诞生了。它是我花了一周业余时间写出来的一款命令行代码生成器:你告诉它“我要一个用户模块”,它会先问你几个问题(模块名叫什么、要不要路由、要不要状态管理、用哪种接口风格),然后按团队规范把整套目录、页面、API 层、类型定义一口气生成出来,落地就是能编译、能跑的代码。名字里的 t3,是我自己都没想太多取的代号——这已经是第三版重写了,第一版用 Python 写过脚本,第二版用 Node 写了一半,第三版换成 TypeScript 全量重构。如果你也经常被重复代码折磨、维护过一堆没人愿意看的内部脚手架,这篇文章应该能帮到你——我会把 t3code 从设计动机到核心实现,再到我踩进去爬出来的几个深坑,完整拆开讲一遍。
1. 为什么会有 t3code:被样板代码逼出来的轮子
1.1 我盘点过自己最耗时的三类重复劳动
动手写工具前,我先把日常开发里最浪费时间的事情列了个清单。结果挺惊人的,每周光是“重复劳动”就要占掉将近三分之一的工作时间,而且这些活儿完全不需要思考:
- 新业务模块的初始化:建目录、建路由文件、写页面壳子、挂菜单配置,一套流程下来少说半小时,多的时候能折腾两小时,因为不同项目的目录规范还不一样。
- 接口对接层:后端接口一多,前端就得手动写 service 方法、定义请求参数类型、写 loading 状态、处理错误返回。每个接口大概要重复 50 到 80 行几乎没区别的代码。
- 状态管理样板:每加一个数据维度,就要手动写 action、reducer、types、selector,四件套缺一不可,复制粘贴的时候最怕贴错文件。
最开始我靠代码片段工具来救急,但片段只能解决“单文件里插入一段代码”,解决不了“多个文件按规则联动生成”的问题。我希望达到的效果是:运行一条命令,整个模块的文件树自动长出来,并且命名、路径、导入导出全部符合项目约定。
1.2 为什么没直接选现成的脚手架工具
其实“代码生成”这个方向早就有很多成熟工具了,Yeoman、plop、hygen 都用过。我逐个试了一遍,各有各的好,但也各有各的别扭:
- Yeoman:功能是真强大,生态也老,但 generator 需要写一套比较重的 Node 代码,团队想维护一个内部 generator,学习成本不低,很多同事看一眼文档就跑了。
- plop:轻量,把模板和配置放在一起,跑起来很顺,但它的定位偏“单文件微生成”,做多文件联动、目录重构的时候还是得写不少定制逻辑。
- hygen:处理“在指定位置插入模板”很优雅,但对“根据用户回答决定整棵目录结构”这种场景不够直观。
这些工具都验证了一件事:代码生成器本质上就是“配置 + 模板 + 上下文变量”三件事。我真正需要的不是一个通用平台,而是一个能贴合自己团队规范、心智负担足够低的小工具。与其在大而全的工具里做二次开发,不如花点时间写一把趁手的专用小刀。
1.3 给 t3code 定边界:做什么,不做什么
工具重构到第三版,我最大的心得是:想清楚“不做什么”,比想清楚“做什么”更重要。t3code 明确划了几条边界:
- 做“文件级别的生成”:根据模板目录结构,按规则渲染并写出一批文件;不做 AST 级别的精准代码注入。
- 做“约定优于配置”:默认按团队目录规范生成,异常情况通过配置覆盖,但永远不会承诺可视化拖拽。
- 做“生成前的交互”:必要的变量通过问答收集,不搞暗黑魔法,所有生成内容必须能看懂、能改。
- 不做低代码平台:t3code 只负责把重复劳动自动化,业务逻辑永远由开发者自己写。
这条边界让我在架构设计时省了很多心。后面所有的实现细节,都围绕这四个字展开:窄而深。
2. t3code 的整体架构:先定数据流,再写一行代码
2.1 一次生成动作的完整数据流
第三版我学乖了,没一上来就写代码,先把整个处理流程在草稿纸上画清楚。一次完整的 t3code 运行链路是这样的:
- 用户在终端输入命令,例如
t3code gen user。 - 工具加载配置文件(项目根目录下的
.t3coderc.ts),合并内置默认配置。 - 解析命令行参数,把“positional 参数”和“选项参数”变成初始变量集合。
- 根据配置文件中定义的 prompts,逐个询问用户缺失的变量(如模块名、目标语言、是否包含 CRUD)。
- 所有变量收集完毕后,形成一个不可变的 renderContext 对象。
- 遍历模板清单,逐个读取模板文件,用 renderContext 渲染出文本内容。
- 根据目标输出路径做冲突检测,决定 overwrite、skip 或 rename。
- 写入文件后,如果配置了 afterScript,依次执行格式化( prettier)、类型检查( tsc --noEmit)或 lint。
这条链路看起来平平无奇,但它解决了两个关键问题:第一,变量收集和渲染彻底分离,模板里永远不用写“判断用户是否回答了某个问题”的逻辑;第二,每一步都有明确的数据结构输入和输出,排查问题的时候可以顺着链路一路打日志。我把每一层的数据都定义成了 TypeScript 类型,比如RenderContext、TemplateItem、PromptDef,写起来非常顺手。
2.2 配置文件:用 TypeScript 写配置,而不是手搓 JSON
第一版用的是 JSON 配置,结果遇到函数、正则表达式、甚至多行描述时非常痛苦。后来改成了.t3coderc.ts,因为配置本身就需要灵活性——比如根据平台判断默认目录。配置文件核心结构长这样:
import { defineConfig } from "t3code"; export default defineConfig({ prompts: [ { name: "moduleName", type: "input", message: "模块名称(小驼峰,如 userProfile)", validate: (v: string) => /^[a-z][A-Za-z0-9]*$/.test(v), }, { name: "needService", type: "confirm", message: "是否同时生成 API 服务层?", initial: true, }, ], templates: [ { source: "templates/react-module/**/*", target: "src/features/{{camelCase moduleName}}/**/*", when: (ctx) => ctx.projectType === "react", }, { source: "templates/vanilla-module/**/*", target: "src/modules/{{camelCase moduleName}}/**/*", when: (ctx) => ctx.projectType === "vanilla", }, ], after: ["prettier --write {target}", "tsc --noEmit"], });这个配置有三个细节值得展开。第一个是templates数组的顺序:越靠前的模板优先级越高,同名文件冲突时默认以先匹配到的为准。第二个是source和target都支持 glob 模式,最终的文件名和路径由变量渲染出来,这样模块名一变,整条路径链路的命名都跟着变,不会出现文件夹叫userProfile、文件名却是UserProfile.tsx这种不一致。第三个是when函数:基于上一问的答案做条件分支。实际运行中我问完前两问,第三问就已经定好了,交互体验非常顺。
2.3 模板系统选型:为什么我用 EJS 而不是手写字符串拼接
模板引擎是代码生成器的灵魂。我评估过 Handlebars、EJS、Nunjucks 三个选项,最后选的是 EJS。没有别的原因,纯粹因为它“足够笨”:里面可以直接写 JavaScript 表达式,团队里任何写过几年代码的人都能秒上手。
有人可能会问:生成器渲染的是源代码,不是 HTML,为什么不用更简单的字符串模板?答案是“字符串拼接”根本撑不住复杂场景。一个真实模块至少包含路由文件、页面组件、API 层、类型定义四个文件,每个文件里都有大量条件判断和循环。比如 API 层要根据用户勾选的 CRUD 方法动态生成函数列表,页面要按字段配置循环输出表单控件。这种复杂度下,模板引擎的if/for/管道函数就是必需品。
用 EJS 写模板会有一个很直接的收益:你能在模板里使用任意自定义 helper 函数。我在 t3code 里内置了pascalCase、camelCase、kebabCase、pluralize这几个命名助手,模板里写import {{pascalCase moduleName}} from './{{kebabCase moduleName}}'这种代码时,可读性非常高。
3. 核心模块的实战拆解:从读取配置到文件落盘
3.1 配置解析与交互问答:把“人话”变成 renderContext
交互提问用的是一个在社区里很成熟的包:prompts。它支持 input、confirm、select、multiselect 等常用题型,正好覆盖我的需求。这里有个经验:所有 Prompts 的name必须和配置里templates用到的变量名严格对应,我会在启动阶段做一次静态校验,直接把不一致的变量名当错误抛出来,而不是让用户生成完才发现“怎么输出的是空字符串”。
问答结束后,我会把命令行参数、prompts 回答、内置默认值合并成一个最终的 renderContext 对象。这个对象的合并顺序非常重要:默认值 < 配置文件 < 命令行参数 < 交互式回答。命令行参数是优先级最高的“提前回答”,当用户用--name=user --yes跳过交互时,t3code 直接进入非交互模式,这在 CI/CD 流水线里特别好用。
3.2 模板渲染:每个文件都是一次独立的“编译”
拿到 renderContext 之后,就可以开始逐文件渲染了。我用 glob 把模板目录下的所有文件读出来,对每个文件做这样几件事:
- 读文件内容为字符串。
- 用 EJS 的
render函数渲染内容,传入 renderContext 和 helpers。 - 对文件的“相对路径”也做一次模板插值,因为路径里可能含有模块名。
- 把渲染后的文本写入对应的目标路径。
这个流程看起来简单,但有个很关键的优化:渲染文件时,我会在文件尾部追加一段固定格式的“生成标记注释”,比如:
// Generated by t3code, do not edit manually.下面的内容有双重作用。对于人来说,它让后来人一眼知道这个文件是生成出来的,改坏了别慌,重跑命令就行;对于工具本身来说,如果你开启了“追加模式”,t3code 会优先寻找这段标记并替换标记之间的内容。这样生成器就不只能“一次生成一整个文件”,还能“精准更新文件中的某个区块”。比如修改器名后,我只想更新 API 层文件里被标记包围的那段代码,其他手写的业务逻辑原样保留,这个能力在实际使用中非常实用。
3.3 文件冲突处理:宁可停下来问,也不直接覆盖
写文件最怕误覆盖。我一开始做过一次蠢事:在旧项目里跑新配置,直接把同事改过的路由文件覆盖掉了。从那以后,t3code 的写盘逻辑我重写得非常保守:
- 如果目标文件不存在:直接创建。
- 如果目标文件已存在且不在“生成标记”模式:提示用户选择
overwrite、skip或review。 review模式会列出旧文件和新文件的内容差异,让用户自己判断。- 只要没有显式传
--force,默认行为永远是“跳过已存在文件”。
这个设计在自动化和安全性之间取了个平衡。CI 环境里用--yes --force一把梭,日常开发里则用默认的安全模式,防止手滑把改动覆盖了。
3.4 命令行参数设计:尽量少记,尽量可猜
CLI 参数我用的是commander,但参数设计更有讲究。我的原则是:位置参数只放一个“动作对象”,所有可选行为都用带--的选项表达。常用命令长这样:
t3code gen user t3code gen user --with-service --with-model t3code gen order --type=form --fields=name,phone,email t3code list t3code validate t3code --version--dry-run是我个人最常用的参数,它会把“将要生成哪些文件、每个文件的内容预览”打印出来,但不真正写盘。在对模板做改动时,我通常会先跑一遍 dry-run 确认目录结构,再实际生成。这个习惯帮我避免了很多“模板写错、一整批文件全部生成歪掉”的惨剧。
4. 我在 t3code 里踩过的三个典型坑
4.1 模板语法和源码里的%短句冲突
第一个坑是我自己给自己挖的。因为用了 EJS,模板里<% %>是控制流符号。但问题是,前端代码里经常会用到对象字面量、求模运算、甚至 JSX 里的{}表达式,如果模板里恰好有一行:
const progress = `${completed}%`;EJS 会把%>当成自己的闭合标签吞掉,轻则渲染结果错乱,重则整个模板直接报错。我第一次遇到时对着屏幕愣了半天,后来查了 EJS 文档才想起有转义写法:想输出字面量<%,得写<%%;想输出%>,得写%%>。模板里出现百分比场景时,我一开始都改成<%%来转义。这个解法虽然能跑,但在代码里留了一堆奇奇怪怪的符号,后来我干脆给 t3code 加了一个“严格模板校验”,渲染前先检查模板里有没有不配对的 EJS 标签,直接把问题拦截在生成阶段。
4.2 Windows 路径分隔符把配置坑惨了
第二个坑典型到让人哭笑不得。t3code 的模板目标路径支持变量渲染,我最初用的是path.join来拼接目标路径。在 Windows 上它会产出反斜杠\,而我们的代码库统一用正斜杠/写 import 路径。结果在 Windows 上生成出来的文件,里面的相对路径导入全带着反斜杠,一到 Linux CI 上就报模块找不到。
修复方案其实很简单:所有写入配置文件、模板路径、生成文件内 import 的路径统统使用正斜杠,只在真正调用文件系统 API 的时候才用系统分隔符。我写了一个toPosixPath()helper,专门处理这层转换。这个坑让我养成了个习惯:涉及路径的公共库工具类,一定要在 Windows 和 Linux 都跑一遍测试用例。
4.3 打包工具把 EJS 整个打进了 CLI 产物
第三个坑发生在用 esbuild 打包 t3code 时。t3code 本身是 TypeScript 写的 CLI,发布时应该打包成单个可执行文件。但 EJS 内部有个特性:它会用require()动态加载模板文件。esbuild 在做依赖分析时没办法完全静态识别,于是它做了个“保守的打包策略”,把 EJS 及其依赖的一大堆代码全部打进产物里。结果 CLI 文件从几百 KB 直接膨胀到好几 MB,启动速度肉眼可见地变慢。
排查完原因其实不复杂:EJS 在运行时有可能加载自定义的include文件,所以打包器必须保留完整的动态加载能力。我的解决办法是用 esbuild 的external选项把ejs和 template 相关的包排除出打包范围,让它们在运行时从 node_modules 里加载。代价是分发时不能只发一个纯单文件,但换来的是启动速度和产物体量的大幅优化。对 CLI 工具来说,我认为这是值得的取舍。
5. 让 t3code 真正可用的收尾工作
5.1 生成后的验证机制:能跑只是起点,能编译才是底线
模板写多了你会发现一个问题:模板本身也要“保鲜”。团队依赖升级、目录规范调整、包名变更,都会让旧模板生成出来的代码瞬间过时。我在 t3code 里加入了validate命令,它会扫描所有模板样例,在临时目录里生成一份“测试产物”,然后依次执行tsc --noEmit和eslint。任何模板导致的类型错误、lint 错误都会在提交模板变动前暴露出来。
这个机制相当于给模板上了“单元测试”。一开始觉得繁琐,运行时间也比预期长,但坚持用下来之后,团队模板的质量稳定了很多,再也没出现过“模板更新把线上生成搞崩”的事故。
5.2 插件化思路:从个人工具到团队共享
t3code 刚开始只有 React 模块模板,属于自产自用。后来同事听说了,也开始用,但他们的项目结构和规范跟我这边不一样,硬套模板比手写还慢。于是我做了一个很轻量的“模板包”机制:模板根目录下放一个template.config.ts,里面声明这个模板包适用于什么项目、需要哪些 prompts、生成到哪个目录。t3code 支持从远程 Git 仓库拉取模板包,并在本地做版本缓存。
日常维护的流程变成:修改模板 → 提交到模板仓库 → 团队成员执行t3code update拉取最新模板 → 重新生成。这样每个团队都能维护自己的“规范副本”,而 t3code 只负责做好渲染和落地。这种“工具薄、规范厚”的分工,我觉得是这类项目最终该走的方向。
5.3 后续可以扩展的方向
写到现在,t3code 已经稳定使用了一个季度,产生的文件数量估算在三千以上。但我知道它离“完美”还很远,有三个方向是我明确想继续做的:
- 引入大模型辅助理解需求:比如用户输入“做一个订单列表页,带搜索和分页”,t3code 尝试把这句话转成 prompts 的预填写答案,减少手工输入。
- 支持“增量生成”:不只是整文件替换,而是对 AST 级别的精准操作做更深入支持,把标记生成模式升级成更完整的可逆操作。
- 编辑器插件:把命令行能力封装成 VS Code 右键菜单里的“用 t3code 生成模块”,降低团队成员的使用门槛。
最后再分享一个我在多次重构里沉淀下来的小技巧:写这类代码生成工具,模板就是产品,模板的可读性比工具的代码质量更重要。我在模板里写注释的密度,比在业务代码里高得多,因为模板是团队所有人都会看的。工具可以把边界收窄,但模板值得花最多的心思。
现在每次执行t3code gen的时候,我盯着终端里刷出来的文件结构列表,还是会想起那个半夜复制粘贴的瞬间。从一个治标不治本的脚本,到一套带验证、带模板管理、能安全落地的生成体系,中间跨过的其实不是多少行代码,而是想清楚了自己到底在为什么问题买单。如果你也有这种反复出现的重复劳动,别急着继续复制粘贴——花点时间把你最痛的场景做成一个小工具,哪怕它只解决一件事,也一定会值回票价。