☰
t3code:终端三步代码生成器,告别样板代码的交互式模板渲染实战
2026/10/28 15:40:02 网站建设 项目流程

写 t3code 这个工具的过程,其实挺偶然的。我平时写业务代码,最烦的不是逻辑复杂,而是那些长得一模一样的样板文件——新建一个模块要写 route、写 service、写 model、写单元测试,光复制粘贴再改名字就得花十几分钟。t3code 是我花了一个周末搞出来的终端小工具,核心就做一件事:在终端里通过几条交互式命令,把一套模板渲染成你想要的代码文件。它不替代脚手架,也不碰构建流程,就是老老实实解决“往已有项目里加新文件”这个场景。这篇文章把我从需求分析到架构设计、再到踩坑修复的完整过程拆开讲,适合想写 CLI 工具、或者正在被重复性样板代码折磨的朋友参考。

1. 先说我为什么放着现成脚手架不用,要自己写个 t3code

1.1 每天在重复的样板代码

我在团队里主要负责后端服务,用的技术栈比较固定。业务模块的代码结构几乎没有变化:一个 controller 负责接收请求和参数校验,一个 service 写核心逻辑,一个 model 定义数据结构和数据库交互,再加一个测试文件。每个新模块都得把这四类文件建一遍,文件里的基础代码结构也长得差不多。

刚开始我用复制粘贴,后来发现复制粘贴有个致命问题:改了 A 文件的类名,很容易忘了改 B 文件的引用。等到编译报错或者测试失败才想起来。这种问题表面上只是手误,实际上暴露的是工作流里缺了一个自动化环节——人的大脑不该用来记这些机械的替换规则。

后来我用过 IDE 的代码片段功能,比如 VS Code 的 user snippets。它在单文件场景下很好用,但一旦模板跨多个文件、涉及相对路径、还要根据项目风格生成不同命名风格时,就力不从心了。snippet 没有“项目上下文”的概念,它不知道你当前的项目是 TypeScript 还是 JavaScript,不知道你的路由前缀是/api/v1还是/api/v2,更不可能帮你决定生成的目录该放在哪个位置。

1.2 现成脚手架解决不了的问题

市面上有很多成熟的脚手架工具,比如创建整个项目的create-xxx-app。这类工具擅长的是“从零到一”——给你一个完整的项目骨架,带着完善的构建配置、目录结构、示例代码。但真实开发里,把模板代码往已有项目里加的情况远多于从零初始化一个新项目。

我试过把整个项目拆成微前端或者多包仓库,也没能躲开这个问题:每个新增的业务子模块,依然要建一堆样板文件。我也考虑过用 monorepo 里公用的 generator 脚本,但团队里有不同语言习惯的同事,让他们为了加几个文件先学一套 generator API,门槛太高。

还有一个被很多人忽略的点:脚手架生成的是一次性产物。一旦项目开始演进,脚手架模板和实际代码就脱节了。下一次新模块继续生成老样式的代码,你会发现新旧代码风格不一致——有人手写了异常处理,有人忘记加日志,长期下来代码库会越来越乱。我的目标是让“生成代码”这个动作不只是初始化,而是变成日常开发里随时可用的一个命令,模板跟业务一起演进,代码风格自然就统一了。

1.3 t3code 给自己的定位

所以我把需求收敛成三件事:第一,工具要具备交互式收集参数的能力,而不是让我在命令行里手写一堆 flag;第二,模板要跟代码放在一起维护,不能是工具内置死的一堆字符串;第三,执行过程要尽可能短,三步以内出结果。

“t3code”这个名字里的 t3 其实就是“terminal three-step”的缩写,我习惯叫它“终端三步代码生成器”。第一步,选择一个模板;第二步,按提示填入参数;第三步,确认落盘位置,生成文件。整个过程在终端里完成,不依赖任何 GUI。这个工具不挑语言和框架,模板是什么它就是什么,本质上它只是一个“模板渲染器 + 交互式参数收集器”。

工具写完后我在团队里小范围推广,第二周就有同事反馈说想要“往已有文件里追加代码块”的功能,而不是每次都新建文件。这个需求后来我用“片段模板”实现了,这是后话,但在当时,它让我意识到模板生成工具的正确抽象层级:不要把自己限定在“文件级生成”,往下拆到“代码块级生成”往往更实用。

2. 技术选型与目录设计:一个终端工具该有的样子

2.1 为什么是 Node.js 而不是 Go 或 Python

确定要做这个工具时,我第一个面对的问题是选什么语言实现。候选有三个:Node.js、Go、Python。我知道有人会说这类工具用 Go 写性能更好、分发起来是单一二进制文件,确实有道理。但我的使用场景是开发者在终端里运行,瓶颈根本不在性能,而在于生态和安装门槛。

Node.js 有几个点非常契合:第一,npm 上可以直接依赖prompts、commander这类交互和命令行解析库,省掉大量底层工作;第二,JavaScript/TypeScript 开发者读模板代码、写模板本身,心智负担最低,团队内部如果想扩展模板,不需要额外学一门语言;第三,模板渲染引擎 Nunjucks 和 EJS 在 Node 生态里非常成熟。

Go 和 Python 我也简单评估过。Go 写 CLI 确实舒服,交叉编译很容易,但模板语法这块相对简陋,团队里愿意为了一个内部工具去写 Go 模板的人不多。Python 的生态也够好,但分发依赖 Python 环境,哪怕用 PyInstaller 打包,体积和兼容性也不如 Node.js 直接npm i -g来得顺。我的结论是:工具是给人用的,在内部工具这个场景里,降低贡献门槛比提高运行性能更关键。

2.2 整体架构与核心模块

t3code 的架构不复杂,我把它拆成四个模块:命令行入口、模板加载器、交互收集器、渲染落盘器。它们的调用关系是单向的,入口接收命令参数,模板加载器根据命令找到对应模板目录并读取配置,交互收集器根据配置里的变量声明逐个询问用户,最后渲染落盘器把用户填写的变量注入模板文件并写进目标目录。

这里有一个很关键的设计决策:模板目录本身就是一个普通文件夹,不是数据库,也不是压缩包。每个模板由template目录和meta.json组成,template里放的是真实的代码骨架文件,meta.json声明了需要用户填写哪些变量、默认值是什么、要往哪个子目录落盘。这样做的最大好处是,模板可以被直接放进 git 仓库,走代码评审流程,任何人都能提 PR 修改模板。

2.3 最小目录结构

项目本身的目录结构是下面这样的:

t3code/ ├── bin/ │ └── t3code.js # 可执行入口 ├── src/ │ ├── cli.js # 命令解析 │ ├── loader.js # 模板加载 │ ├── collect.js # 交互收集 │ ├── render.js # 渲染引擎封装 │ ├── writer.js # 文件落盘 │ └── utils/ │ └── path.js # 路径处理 ├── templates/ # 默认内置模板 │ ├── api-module/ │ │ ├── meta.json │ │ └── template/ │ │ ├── controller.ts.tpl │ │ ├── service.ts.tpl │ │ ├── model.ts.tpl │ │ └── test.ts.tpl │ └── code-snippet/ │ ├── meta.json │ └── template/ │ └── snippet.ts.tpl ├── package.json └── README.md

入口文件只做一件事,就是调用cli.js里解析好的命令。我特意没有把逻辑堆在入口文件里,因为终端工具越到后期功能越杂,入口保持干净,后续加子命令、加参数时不会改到核心逻辑。

3. 三步生成一条链:交互、渲染、落盘是怎么串起来的

3.1 第一步:把意图问清楚

交互式收集是整个工具里最影响体验的部分。命令行工具最常见的错误是让用户一次性在命令后面写一堆参数,类似t3code create --type=api --name=user --with-test=false。这种设计在参数少时没问题,参数一多就完蛋——用户根本记不住有哪些 flag,还要小心拼写错误。

t3code 的做法是从模板的meta.json里读取变量声明,然后生成交互问题。meta.json里的变量声明长这样:

{ "name": "api-module", "description": "Generate API controller, service, model", "variables": [ { "name": "moduleName", "message": "What is the module name?", "validate": "^[a-z][a-z0-9]*$" }, { "name": "needTest", "message": "Generate test file?", "type": "confirm", "default": true } ], "targetDir": "src/modules/{{moduleName}}", "files": [ "controller.ts.tpl", "service.ts.tpl", "model.ts.tpl" ] }

变量还支持动态生成。比如moduleName是用户填的,但className可以由moduleName自动推导出来:把user-profile转成UserProfile。这个逻辑我放在变量声明里的computed字段:

{ "name": "className", "computed": "{{moduleName | pascalCase}}" }

开发者不用写 JS 代码,只需要声明这个字段依赖哪个变量、套什么过滤器。模板渲染引擎会自动按依赖顺序求值。这样做的用意很明显:减轻用户输入负担,同时保证命名一致性,避免同一个名字在 controller 里是一个写法、在 model 里是另一个写法。

3.2 第二步:模板渲染的取舍

模板引擎我最后选了 Nunjucks,而不是 EJS 或者 Handlebars。原因有几个:Nunjucks 支持继承和宏,虽然代码生成场景用不上继承,但宏很有用——比如不同模板里都要生成版权头注释,可以抽成一个宏;而且 Nunjucks 的过滤器系统非常干净,自定义一个pascalCase过滤器只需要几行代码。

关键细节在于模板文件的命名和内容。模板文件在模板目录里统一用.tpl后缀,渲染的时候输出文件去掉.tpl后缀。这样做是为了避免把模板语法本身混进真实代码文件里,防止 IDE 对{{ }}语法报错,也方便语言语法高亮。比如controller.ts.tpl渲染成controller.ts,这个命名约定从一开始就定下来了,一直沿用到现在。

实际渲染代码不必调用底层模板 API 太多,核心就两段逻辑:加载模板字符串,传入变量对象,得到输出字符串。真正容易踩坑的是“变量缺失”的问题——模板里写了一个变量,但meta.json里没声明,运行时渲染出来空字符串,代码文件生成出来才发现。我在渲染前做了一次校验,遍历模板文本里的变量引用,再跟模板声明的变量表比对,缺了就提前报错。

3.3 第三步:安全落盘

落盘看起来只是把字符串写到文件里,但要注意三个问题:路径安全、覆盖策略、目录创建。

路径安全需要防的是模板里的targetDir出现绝对路径或者../跳出项目目录。虽然模板是可信的,但用户可能从网上下载第三方模板,一个恶意模板往系统目录里写文件就很危险。我加了路径规范化校验:目标路径必须位于当前项目的cwd之下,否则直接拒绝执行。

覆盖策略上用两层检查。如果目标文件已存在,先看模板配置里是否允许覆盖;允许的话,默认还是会询问用户,只有加上--force才会跳过询问直接覆盖。对于像外部 API 定义这类多人共用的文件,误覆盖的代价非常高,宁可多一步确认也不要闷头覆盖。

目录创建就相对简单了,递归创建目标目录。但这里我踩过一个坑:不同操作系统对路径分隔符的处理不一样,模板里如果用硬编码的/生成路径,在 Windows 上会出问题。这个问题后面专门用一节来写,因为它的表现很隐蔽,不仔细看根本发现不了。

4. 模板才是灵魂:变量注入与自定义模板的完整实现

4.1 模板仓库的目录约定

一个生成工具好不好用,一半取决于模板设计得怎么样。t3code 的模板设计核心在于目录约定和meta.json的配合。模板目录下的template/里放所有待渲染文件,支持子目录嵌套,比如:

api-module/ ├── meta.json └── template/ ├── controller.ts.tpl ├── service.ts.tpl └── __tests__/ └── service.test.ts.tpl

其中__tests__/子目录里的文件渲染后,也会保持同样的相对路径结构落盘。这样设计很直接,目录结构一目了然。但只有这个还不够,因为很多项目要求生成的文件带上模块名,比如user.controller.ts。所以我在文件名里也支持变量占位:

template/ {{moduleName}}.controller.ts.tpl {{moduleName}}.service.ts.tpl

渲染的时候,文件名里的{{moduleName}}会被替换成实际值,再根据meta.json里的targetDir决定具体写到哪个目录。文件名占位符是模板系统里最容易被忽略、但实际使用频率最高的特性。

4.2 变量声明与参数校验

变量声明本身不难,难的是校验规则的设计。我调研了几个现成工具的做法,发现大多数人只是让用户填字符串,不校验内容,生成了错误代码后由 IDE 报错。这个体验非常差。我的方案是给每个变量声明支持三种校验手段:正则校验、枚举值校验、自定义 JS 校验函数。

正则校验适合模块名、包名、类名这种有明确规范的字段。比如模块名只允许小写字母和数字,正则是^[a-z][a-z0-9]*$;枚举值校验适合“数据库类型”这种有固定选项的场景,用户从列表里选一个,而不是自己敲。自定义 JS 校验函数则是最后兜底的:有些项目要求新生成的目录不能重复,这需要访问文件系统,正则和枚举都做不到。

这里我给自定义校验函数留了口子:用户可以在模板目录里放一个hooks.js,导出名为validate的函数。工具加载模板时会自动检查这个文件是否存在,存在就注册到渲染流程里。虽然这让模板不只是静态文件了,但实际使用下来,这种灵活性对复杂项目很有必要。

4.3 条件、循环与嵌套模板

条件渲染我用 Nunjucks 原生的if语法。比如needTest变量为 false 时,不创建测试文件,模板配置里可以这样写:

{ "files": [ "controller.ts.tpl", "service.ts.tpl", "model.ts.tpl", { "file": "test.ts.tpl", "when": "{{needTest}}" } ] }

循环渲染适用于“生成多个同类文件”。比如一个模块可能对应多个数据模型,在模板里没法预先知道数量,我允许变量类型为list,用户交互时填一个逗号分隔的字符串,工具转成数组传入模板。模板里用 Nunjucks 的for循环逐个输出。

嵌套模板这个功能是后期加的。有位同事在做微服务拆分,希望生成一个服务时把配套的 Dockerfile 片段也带出来。我实现了include_template指令:模板 A 可以通过特殊注释引入模板 B,两个模板共享同一份变量上下文。用注释语法包裹而不是模板引擎的 include,主要是为了兼容 IDE 语法高亮。比如在service.ts.tpl里写:

// include: dockerfile-fragment

工具解析时发现这行注释,会在渲染完成后把dockerfile-fragment模板渲染出来的内容插入到这个位置。这个做法很土,但非常有效,因为它让模板之间的组合变得极其直观。

4.4 远程模板拉取与更新

内置模板只是起步,真正让工具被同事认可的是支持从 git 仓库拉取模板。我把模板源配置放在用户主目录下的.t3coderc文件里:

{ "registry": { "type": "git", "url": "https://example.com/templates.git", "branch": "main" } }

执行t3code update时,工具会把远程模板仓库克隆到一个本地缓存目录,然后这个缓存目录就变成了模板搜索路径之一。拉取不是每次执行都发生,而是以本地缓存的存在为准,所以离线也可以用。模板仓库本身跟普通 git 仓库一样,团队里任何人改了模板提交后,其他人执行一次update就能同步。

这里有个细节:由于模板仓库跟代码仓库是独立的,模板的演进节奏应该更快,不要被业务代码的发布节奏拖住。我在团队里定的规矩是,模板仓库小步快跑,任何人不满意模板都可以直接改,只要不破坏现有变量声明,基本不会有兼容问题。

5. 实战检验:用 t3code 拉出一个可运行的 API 服务骨架

5.1 准备一个最小 API 模板

理论讲再多,不如跑一次。我们用一个最小的api-module模板来做实测。这个模板包含一个 controller、一个 service、一个 model 和可选测试文件。模板内容保持精简,重点展示变量注入和文件落盘。

controller.ts.tpl是这样的:

import { Router } from 'express'; import { {{className}}Service } from './{{moduleName}}.service'; const router = Router(); const service = new {{className}}Service(); router.get('/{{moduleName}}', async (req, res) => { const data = await service.list(); res.json(data); }); router.post('/{{moduleName}}', async (req, res) => { const data = await service.create(req.body); res.status(201).json(data); }); export default router;

service.ts.tpl是这样:

import { {{className}}Model } from './{{moduleName}}.model'; export class {{className}}Service { async list() { return {{className}}Model.findAll(); } async create(payload: any) { return {{className}}Model.create(payload); } }

两个模板都引用了className和moduleName,这些变量没有让用户直接填,而是通过computed由moduleName自动推导。整个模板只需用户输入模块名,其他命名全部自动保持一致。

5.2 实际命令行操作输出

环境准备妥帖后,执行命令:

t3code generate api-module

终端会依次出现这样的交互:

? What is the module name? user-profile ? Generate test file? (Y/n) yes ? Target directory is src/modules/user-profile, continue? yes

确认之后,工具开始渲染并写文件。这个过程没有进度条,终端里只打印每个写入的文件路径:

created src/modules/user-profile/user-profile.controller.ts created src/modules/user-profile/user-profile.service.ts created src/modules/user-profile/user-profile.model.ts created src/modules/user-profile/__tests__/user-profile.test.ts done.

从输入模块名到所有文件落地,耗时基本上在 1 秒以内。这个速度对于绝大多数场景都够用,因为它的主要开销只是模板加载和渲染,不存在网络请求。

5.3 生成结果与可用性评估

我们来检查一下生成的代码是否可直接运行。打开user-profile.controller.ts,类名、变量名、路径引用都生成正确。再看user-profile.model.ts里的模型定义,也跟预设一致。测试文件导入的模块路径也没有出现../错位。

为了严谨,我直接在项目里跑了一次编译和测试,全部通过。这说明整个生成链路的变量解析、路径计算、文件落盘逻辑是可靠的。这组模板在团队里已经用了两个多月,服务端新模块的样板代码全部由 t3code 生成,新模块的初始提交几乎没有出现过“少了某个文件”“路径写错了”这类问题。

5.4 生成工具的性能优化方向

说到性能,可能有人觉得渲染模板能有多慢?但在一个模板包含几十个文件、且有大量条件渲染和循环时,频繁的模板加载还是不划算。我做了两个小优化:一是模板加载缓存,同一轮生成中,同一个模板文件只读一次;二是把 Nunjucks 的环境初始化提前到进程启动阶段,而不是每次生成时才创建环境。这两个改动让 30 个文件规模的模板生成耗时从 800ms 降到 200ms 左右。

另一个跟性能不太相关但更实用的优化是“预览模式”。执行t3code generate --dry-run时可以只打印将要生成的文件列表和渲染后的内容片段,不实际写入磁盘。这个模式特别适合调试模板,因为模板改来改去经常会有语法问题,直接落盘再清掉太麻烦。

6. 开发中踩过的坑和现在的优化方向

6.1 Windows 路径分隔符的教训

这个坑在第一个同事用 Windows 跑工具时立刻暴露了。模板里的 targetDir 写的是src/modules/{{moduleName}},在 macOS 和 Linux 上没问题,到了 Windows 上,生成的目录层级却乱了,局部路径变成src\modules\user-profile和src\modules\user-profile\another混着出现。

根因很简单:我用了简单的字符串拼接来构建路径,没有用path.join,也没有在渲染文件名时做统一的分隔符转换。修复方法也不复杂,所有路径计算统一走path模块,渲染后的相对路径统一转成path.sep。这个坑提醒我一件事:终端工具天然是跨平台的,路径处理不是“测试环境没问题”就行,必须从一开始就强制定标准。

6.2 模板语法与框架语法的冲突

第二个坑是渲染语法跟实际代码语法冲突。我们的前端项目用 Vue,模板文件里到处是{{ }}插值表达式。直接把 Vue 文件当作 Nunjucks 模板渲染时,所有的 Vue 插值都被 Nunjucks 当成模板变量尝试解析,结果全部报错。

解决方案有两个层面。第一个层面是让 Nunjucks 自定义定界符,比如把模板标签改成[[ ]]和[[ ]]。这样 Vue 的{{ }}原样保留,只有[[moduleName]]会被渲染。第二个层面是,在确实需要混用的时候,用 raw 标签把 Vue 插值包起来:

{% raw %} <div>{{ count }}</div> {% endraw %}

这个坑的意义在于:做通用模板工具时,你不能假设模板内容只包含你自己的语法。顺应宿主语言的习惯,用宿主语言不冲突的定界符,是规避这类问题最好的手段。

6.3 编码问题与重复执行

编码问题出在模板文件里的中文注释和中文默认值上。有个同事的模板里写了中文版权头,在 Windows 的默认编码下,渲染出来的文件中文全变成乱码。我的修复方案是强制统一用 UTF-8 读写,不依赖系统默认编码。

重复执行的问题是另一个容易被忽视的点。同一个模板执行两遍,会把所有文件重新生成一遍,controller 里用户手动加的代码可能就被覆盖了。当前版本的做法是,落盘之前检查目标文件是否已存在,已存在的情况下,用交互方式询问是否跳过、覆盖或合并。合并这个选项现在还没做得特别好,只支持“在文件末尾追加”,更智能的按锚点合并还在计划中。

6.4 关于后续优化的想法

做工具的乐趣在于它自己会不断长大。我目前对 t3code 最想补的功能有三个方向:第一是“项目上下文感知”——读项目的 package.json 或者 tsconfig,自动推断语言类型、框架类型、命名风格,用户连模板里的一部分变量都不用填;第二是“模板变量组”的概念,有些业务场景是一次要生成一组文件,比如“订单模块”和“用户模块”虽然文件结构相似但业务含义不同,现在需要分别选择模板,以后可以支持一个组合模板一次生成;第三是把交互界面从终端延伸到浏览器——t3code ui可以启动一个本地页面可视化选择模板、实时预览渲染结果,这对不习惯终端交互的同事会更友好。

回到最开始的问题,这个工具到底解决了什么?往大了说是提升开发效率、统一团队代码风格,往小了说其实就是让我自己少干点复制粘贴的活。实际用下来,我最满意的一点是模板和代码仓库分离之后,团队的技术规范可以沉淀在模板里,新人入职第一次提交的代码风格就能跟老员工保持一致。工具本身不难,难的是持续往里面填内容、跟着项目的演化修修补补。如果看完这篇你也想动手做一个类似的工具,我的建议很简单:先别想大而全,挑一个你自己每周都会遇到的重复操作,把它做成模板,一行命令解决,然后再慢慢完善。

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

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

立即咨询