ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从复制粘贴到一条命令:t3code代码生成工具的核心设计

从复制粘贴到一条命令:t3code代码生成工具的核心设计

我第一次认真想写 t3code,是在一个周五晚上。当时我正往第三个项目里粘贴同一个transformResponse工具函数,然后手动改包名、改接口类型、改注释里的团队名。粘贴到一半我停下来想:这种事情我至少已经干了三十次,每次都能在“复制-替换-微调”上浪费二十分钟,为什么没有一个工具能让我说一句“生成一个 xxx”,就把这一整套文件按项目规范创建好?

这个想法最终变成了 t3code——一个以配置文件为骨架、以本地模板为血肉的小型命令行代码生成工具。它不试图成为脚手架全家桶,也不打算接管整个工程,它只做好一件事:把“需要反复手写的样板文件”变成一条命令。如果你也经常在多个项目之间维护相似的工具函数、组件骨架、服务模块、测试文件,那这篇文章里的很多取舍和踩坑,应该能给你一点参考。

这算是我自己的一个实验项目,谈不上伟大。但站在现在的节点回头看,值得记录的反而不是功能本身,而是我为什么这么设计、为什么放弃那些“更强大”的方案,以及真正拿去用的时候,哪些地方会把你教做人。

1. 从一个重复写了三十遍的模板说起:t3code 想解决的痛点

1.1 我在不同项目里反复复制粘贴的那些文件

实际开发里,样板代码的重复不是那种“一行两行”级别,而是一整套文件之间的联动。比如在业务后端里新增一个对外接口,往往需要同时准备:接口定义文件、参数校验类、服务方法、路由注册代码、接口文档片段、对应的单元测试骨架。在前端项目里新建一个列表页面,通常也需要路由、页面组件、请求函数、类型声明、空状态组件,以及占位文案。

这些东西单独看都很简单,可它们散落在不同目录,遵循着不同命名规范。靠人工复制,最容易出的问题不是少一个文件,而是复制过来的文件里留着上一个项目的“旧命名”,比如函数名末尾的V2、某个模块特有的拦截器、团队 A 约定的注释头。等到代码评审时被揪出来,又要一个个改。更麻烦的是,不同项目对“接口服务文件”的目录约定还不一样:有的放services/,有的放api/,有的继续沿用utils/request.ts里的集中导出。

所以我想要的工具,不是再帮我粘一次代码,而是把“这一整套文件的生成规则”固化到配置文件里。项目用什么目录、什么命名、什么注释头,都写进.config.ts,工具按照规则把模板渲染到目标位置。这样我既能保留每个项目的差异,又不用每次重新发明一遍。

1.2 与其继续复制,不如写个工具

当时我也不是没想过用现成方案。Plop、Hygen、甚至各种 create-xx 脚手架,每个都试过。但它们要么需要你学习一套重型的模板语法,要么把生成逻辑强行绑定在某种框架上。我只是想要一个能在十分钟内跑起来、不引入一堆依赖、模板内容和我平时写的代码几乎一致的小工具。

于是 t3code 的定位在第一个版本就定了:配置驱动,模板优先,命令简单。它不负责“帮你决定项目结构”,它只负责“按照你已经定好的结构,把文件生出来”。任何一个能写 TypeScript 的人,十分钟内就能读懂配置;任何一个用过 handlebars 的人,也能马上看懂模板。

我把版本号定在了 0.1.0,然后在周末写了一个最简原型。当时只支持两种操作:init初始化配置目录,generate按模板生成文件。没想到这个最简原型,后来陪我跑了整整两个项目,才逐渐补齐后面的功能。

1.3 取名 t3code 的小故事

为什么叫 t3code?其实最初我叫它 tiny-template-maker,太拗口了。后来想到项目里横跨 TypeScript、Template、Tool 三个维度,就缩写成 t3。也有人说我蹭 T3 Stack 的热度,但我解释过很多次:那套 Next.js + tRPC + Tailwind 的组合我平时并不用,这个 t3 只是我自己索引目录时起的代号。名字这东西,到最后就是一个自己能记住的字符,重要的是别人能通过名字大概猜到它是“三要素驱动的代码工具”。当然,你把它理解成 TypeScript Template Tool 也完全没问题。

2. 核心设计:配置驱动、按需生成,模板和你平时写的代码没有区别

2.1 一个典型的 t3code.config.ts 长什么样

t3code 的所有行为都从一个配置文件开始。为了保持“类型安全”这个习惯,我用 TypeScript 写配置文件,而不是 JSON。这样在写模板路径时能拿到 IDE 补全,配置出错时也能第一时间看到类型报错。一个典型的配置长这样:

import { defineConfig } from 't3code'; export default defineConfig({ templatesDir: 'templates', outputRoot: 'src', templates: [ { name: 'module', description: '生成一个 api 服务模块', arguments: { moduleName: { type: 'string', required: true }, version: { type: 'choice', choices: ['v1', 'v2'], default: 'v1' }, }, files: [ { source: 'module/service.ts.hbs', target: 'services/{{ moduleName }}/index.ts', }, { source: 'module/types.ts.hbs', target: 'services/{{ moduleName }}/types.ts', }, { source: 'module/__tests__/service.test.ts.hbs', target: 'services/{{ moduleName }}/__tests__/service.test.ts', }, ], }, ], });

这个配置文件要表达的东西很清楚:用户运行t3code generate module --moduleName=user --version=v2时,工具依次读取三个模板文件,把{{ moduleName }}和{{ version }}替换成实际值,再按照target的路径写入新文件。所有目标路径里的目录如果不存在,就自动创建。

选择 TypeScript 配置还有另一个理由:可以在配置里写逻辑。比如某些模板参数的值需要根据当前项目是否拥有某个目录来自动推导,这时可以在beforeGenerate钩子里动态改配置,而不是给工具加一堆 if/else。这个设计在后期帮我省了很多事。

2.2 模板语法:我只保留了必须的那几个占位符

一开始我也犹豫过,要不要直接用 Handlebars,因为它功能太丰富了:循环、条件、嵌套 partial、helper 函数……几乎可以把它当成一门小语言。但后来我意识到,对于“生成代码模板”这个场景,绝大多数模板都是“静态代码 + 少量变量”,真正的循环和条件非常有限。引入完整模板引擎,反而会降低模板的可读性,也会让编辑器里的语法高亮变得混乱。

所以 t3code 的模板语法只保留了三种东西:{{ variable }}做变量替换,{{#if condition}}...{{/if}}做条件分支,{{#each items}}...{{/each}}做简单循环。我用了 Handlebars 的内核,但对外暴露的语法只占它的一小部分。这样做的直接好处是,模板看起来仍然是“正常的代码”,而不是混着代码的怪异 DSL。

下面是一个真实用过的接口类型模板:

// services/{{ moduleName }}/types.ts export interface {{ moduleName }}Response { code: number; message: string; data: {{ moduleName }}Data; } export interface {{ moduleName }}Data { id: string; {{#if withName}} name: string; {{/if}} createdAt: string; updatedAt: string; }

withName这个参数来自命令行传入的--withName,取值是true或false。模板引擎渲染完之后,会得到一份和手写风格完全一致的 TypeScript 文件。因为模板里没有定制语法,直接放进代码库也不会被同事当成“奇怪的预设格式”。

2.3 变量注入与命令行动作

参数从命令行进来之后,会经过三个阶段:解析、转换、注入。解析就是把--moduleName=user拆成键值对;转换是按配置里的type定义做类型转换,比如number要做Number(),choice要校验取值是否合法;注入则是把转换后的参数对象传给模板引擎,同时挂到beforeGenerate钩子上,让配置可以再加一层处理。

比较重要的是 choice 参数的校验。早期版本没有校验,一个手误把--version=v3传给只支持 v1/v2 的模板,工具依然会生成文件,但生成出来的 import 路径可能是错的。后来我在转换阶段做了严格校验,不合法就直接报错并列出可选值。这个改动看起来不大,却把“生成完发现不对”变成了“生成之前就拦下来”,体验提升非常明显。

至于动作部分,我在命令设计上刻意保持了少而稳定:init、generate、list、diff。list用来列出当前项目可用的模板;diff用来查看生成内容与目标文件之间的差异,这也是后面会提到的覆盖保护机制的一部分。我不想做成一个拥有五十个子命令的瑞士军刀,因为那意味着用户要花更多时间去学工具本身,而不是去写代码。

3. 技术选型:为什么是 Node.js + TypeScript,而不是 Go 或 Python

3.1 和现有开发环境的贴合度

在动手之前,我做了一次很简单粗暴的对比:Go 的优点是可编译成单二进制、启动快,Python 胜在脚本直觉,而 Node.js 最大的优势是“它本来就长在大部分前端和全栈工程师的日常环境里”。t3code 的用户就是那群写 TypeScript 的人,他们电脑上大概率已经装了 Node,也熟悉npm、pnpm、yarn这些包管理器。

如果我去写一个 Go 实现的版本,分发确实会更干净,但用户需要额外安装一个二进制,还得自己处理 PATH,这本身就增加了使用门槛。对于这种“小到不能再小的开发者工具”,安装成本对用户心智的影响比性能大得多。再说generate这种场景,大概只需要几十毫秒到一两百毫秒,启动慢个 300 毫秒根本没人感知,真正让人不耐烦的是“装不上、跑不起来、路径不对”这些环境问题。

所以我最终选择 Node.js + TypeScript。TypeScript 能给我配置文件的类型约束,Node 生态里有现成的命令行解析库、Handlebars 实现、文件监听与 diff 工具,不需要我重造轮子。

3.2 模板渲染方案的取舍:正则表达式 vs 完整模板引擎

第一版的原型只用了正则表达式替换:/{{ moduleName }}/g换成输入值。简单、粗暴、零依赖,当时我一小时就写完了。但很快发现两个问题:一是模板里出现多个同名变量要写很多次replace,二是遇到{{#if}}这种条件块时,正则根本没法优雅处理,得先写一堆状态判断。

所以第二版我引入了 Handlebars,但只用了它最核心的compile和render,没有注册花哨的 helper,也没有让它读取文件。这样既拿到了稳定的条件、循环、转义能力,又不会把模板复杂度失控。如果想彻底去掉 Handlebars 依赖,我可以自己写一个迷你解析器,但那种规模的解析器要处理转义、嵌套、空白控制,维护成本远高于一个成熟依赖。在这种关键组件上,我倾向于“用稳定的依赖,而不是自己造一个一知半解的轮子”。

反过来说,我也没有直接采用“完整模板生成器”的思路,比如用 TypeScript AST 来操作代码。AST 方案非常适合做重构、重命名、大范围代码变更,但如果只是为了生成一个新文件,AST 会引入过多的抽象层次,写模板的人还必须理解 AST 节点的结构,学习成本陡增。对业务开发团队来说,手写模板文本永远是最直观的。

3.3 分发与升级:用 npm 安装是最省心的路子

t3code 的分发方式就是npm install -g t3code或者用npx t3code。全局安装适合长期使用,npx适合偶尔试试。我没有做自动更新机制,但会在每个版本发版时通过 npm 的deprecate标记旧版本,提醒还在用旧版本的用户升级。对于这种 CLI 工具,版本兼容是一个很重要的隐性问题,尤其是配置文件语法一旦变了,老用户升级后可能直接跑不起来。

所以我在版本演进上做了一个承诺:大版本之前可以大胆改,但每改一个不兼容点,必须在CHANGELOG里用最直白的语言说明迁移路径。有些工具发布新版本时只写了一句“breaking changes”,这等于没说。我认为开发者工具的核心信誉,就是用户升级之后还能按照原来的方式继续工作,如果有变化,要给用户明确的操作步骤。

4. 从 init 到 generate:t3code 的核心流程拆解

4.1 init:初始化配置、目录与第一个模板

t3code init做三件事:创建t3code.config.ts的初始版本、创建templates目录、把一个示例模板放进去。这个命令的目的是让你不用从零开始写配置,而是基于一个可以运行的例子去改。

初始化生成的配置里,我会放一个最简单的hello模板,它的target是src/tmp/{{ name }}.txt,模板内容只有一行文本。这样用户跑完t3code generate hello --name=test后,能立刻看到文件被创建,从而建立“这套流程是可靠的”信心。

在实现上,init 要注意的一点是“不要覆盖已有配置”。如果用户在当前目录已经跑过 init,再执行一次时,工具应该提示t3code.config.ts 已存在并退出,而不是默默地把旧配置覆盖掉。这个保护逻辑被我写成了泛用的safeWriteFile函数,后续 generate 阶段覆盖文件时也会用到同一套逻辑。早期我就是因为把这两处逻辑分开写,导致 init 有保护而 generate 没有,后面踩了个不小的坑。

4.2 generate:占位符替换、文件生成与冲突处理

generate 的完整流程可以拆成五步。第一步是解析模板参数;第二步是加载这个模板对应的文件列表;第三步是逐个读取模板内容,用 Handlebars 渲染;第四步是计算目标路径;第五步是调度写入。

写入之前,最关键的判断是:目标文件是否已经存在?如果文件不存在,直接创建。如果文件存在且内容和将要写入的内容完全一致,就跳过,避免产生无意义的文件修改时间戳。如果文件存在且内容不一致,就进入冲突处理分支。默认策略是“拒绝覆盖”,并提示用户用--force覆盖或用--dry-run先看看差异。把--force和--dry-run做成一对互补选项,能在安全性和效率之间取得平衡。

代码的核心调度逻辑并不复杂,但有一个细节容易被忽略:渲染之后的文本结尾要统一为项目的换行符风格。Windows 环境容易混入\r\n,而配置里写死的脚本可能在 Linux CI 上执行时因为换行符不一致报一些奇怪错误。我在写入前会做一次换行符规范化,这个操作花了我半天才定位到问题来源。

4.3 自动接入 ESLint、Prettier:生成完马上能跑

生成文件只是第一步,如果生成出来的代码不符合项目的 lint 规范,那么在 CI 里仍然会挂掉。t3code 的generate命令在写入文件后,会自动检测当前项目是否安装并配置了 Prettier 和 ESLint。如果检测到,就自动对生成的文件执行一次prettier --write和eslint --fix。

这里有一个性能上的取舍:每个文件都起一个子进程去跑 prettier,在小项目上还好,大项目上明显能看到多出几十毫秒甚至上百毫秒。所以我把自动格式化做成了“默认开启,可配置关闭”:在t3code.config.ts里加一个format: false选项,告诉工具只生成不格式化。对大多数用户来说,默认开启省掉了“生成完手动按一次格式化快捷键”的操作,体验更好。

不过要注意,自动格式化只适合处理模板生成的、尚未进入 git 跟踪的全新文件。如果你用--force覆盖了一个已跟踪文件,再跑 prettier 可能会把原本手动调整的格式也刷一遍,产生大量无关 diff。这个问题后面我会在覆盖保护里给出更细的说明。

5. 实测中踩过的三个坑,以及完整的定位与修复过程

5.1 中文路径乱码:第一次跨平台使用就翻车

t3code 支持把参数注入到目标路径里,比如services/{{ moduleName }}/index.ts。有一次我起了个中文模块名,在 macOS 上生成完全正常,但同事在 Windows 机器上跑同一个配置,生成出来的文件名变成了一串乱码。最先看到的现象是:services/用户模块/index.ts变成了services/锟斤拷/index.ts。

排查过程中,我首先怀疑是 Node.js 在 Windows 上处理文件路径时使用了不同的编码。但看了process.argv的输出,参数本身是正确的;再看 Handlebars 渲染后的字符串,也正确;最后卡在了fs.writeFileSync那一层。进一步检查才发现,问题出在我对命令行参数的解析上:早期用了一个不成熟的自制解析函数,在 Windows 上读取参数时用了错误的 decode 方式,导致中文在进入模板之前就已经损坏。修复方案是改用成熟的commander库来做参数解析,同时在入口处强制把参数统一转为 UTF-8 字符串。

这个问题也让我意识到:跨平台兼容性必须从一开始就设计,而不是在用户踩到之后再补。一个简单的验证方法,是在 Windows、macOS、Linux 三种环境分别跑一次t3code generate,并且专门用中文和空格构造参数值。

5.2 模板互相引用导致递归爆炸:我被自己设计的循环坑了

模板里我加了一个“partial 引用”能力:一个模板文件可以通过{{> header }}引用另一个模板片段。这本意是好的,公共头注释、import 语句块可以抽出来复用。但我在实现时做了一个偷懒决定——通过文件内容里的{{> xxx }}去动态决定还要再加载哪个文件,然后递归渲染。结果当header片段自己也引用了header,或者 A 引用 B、B 引用 A 时,程序就进入了无限递归,进程内存一路飙升,直到 Node 报出Maximum call stack size exceeded。

定位过程比较曲折。因为报错发生在一个很深的递归栈里,控制台只显示了最后一段调用栈,我一开始误以为是 Handlebars 的默认配置问题。后来我在递归渲染函数里加了一个renderStack数组,每次进入时 push,退出时 pop,一旦发现当前要引用的 partial 已经在栈里,就立刻抛出一个明确错误:“检测到模板循环引用:header -> header”。这个错误信息虽然简单,但比堆栈溢出直观太多。

最终的解决方案是两层:运行时检测循环引用,同时禁止 partial 在头部引用自己。后者是一个简单的静态检查,在加载模板文件时扫描{{>标签,看看引用的目标是否是自身。现在 t3code 的模板系统只允许一层 partial 嵌套,想要多层的话需要显式在配置里声明依赖链。这个限制牺牲了一点点表达力,但换来了稳定的渲染流程。

5.3 覆盖已修改文件:引入 dry-run 和 diff 保护

前面提到,generate 在目标文件已存在且内容不一致时,默认拒绝写入。这个逻辑本身没有争议,真正难的是如何让用户知道“为什么不写”。最初版本只会在终端打一句文件已存在,跳过,用户没办法判断是不是自己参数传错了导致模板内容不匹配。

后来我加了--dry-run模式,它会把每个将要生成的文件路径打印出来,并在已有差异的文件上附一个迷你 diff。这样用户可以先跑t3code generate module --moduleName=user --dry-run,看到- 旧内容 + 新内容的对比,再决定是否加--force。

这个 diff 功能我用的是社区里的diffnpm 包,没有自己做算法。做出来的效果虽然不如 VS Code 里的三路合并那样精细,但足够在终端里快速判断“是不是只多了一个字段,还是整个文件结构和预想完全不一样”。现在的实际体验是,大部分覆盖操作都是通过 dry-run 确认后再 force 的,误覆盖的情况几乎没有再发生过。这里也想提醒一句:任何带--force的设计,都要把“确认差异”作为前置心智,别让用户养成不看差异直接 force 的习惯。

6. 和 create-xxx、Plop、Hygen 放在一起,t3code 凭什么存在

6.1 各自的侧重点与使用场景

如果你已经在用 Plop 或 Hygen,t3code 的很多功能听起来像重复造轮子。我不否认它们有重合的地带,但它们在产品取向上有明显差异:

工具核心定位模板能力适合场景
create-xxx一键创建整个项目一般是整套项目模板从零搭仓库
Plop生成指定模块代码基于 handlebars,功能完整在已有项目里生成组件/模块
Hygen代码生成工作流支持多文件、locals、依赖追求“生成即规范”的团队
t3code轻量、配置驱动、单文件模板只保留变量/条件/循环/有限嵌套个人和小团队快速落地

拿 create-xxx 举例,它本质上是“通过一个模板仓库初始化项目”,关注的是项目级别的脚手架,而不是某个模块文件的生成。一旦项目已经存在,create-xxx 基本帮不上忙。Plop 和 Hygen 其实和我更接近,它们也支持“填充模板到本地项目”的模式,但为了兼容更多场景,它们的配置项和插件机制相对更复杂,模板文件里经常要混入组件库自己的语法,学习曲线更陡。

6.2 t3code 明确的“不做”清单

为了让项目保持轻量,我在 README 里写了一个“不做清单”。第一,不做项目脚手架,因为现在各大框架都有成熟的 create 工具,重新做一个没有意义。第二,不做代码片段管理和全局快捷插入,因为那更适合交给 IDE 插件。第三,不做运行时代码生成,比如根据配置文件在启动时动态生成路由代码,那需要更严格的 AST 工具链和缓存策略。第四,不做图形界面,CLI 的可用性是这种工具的首位。

这些“不做”的约束,让我在开发新功能时有了一个判断标准:这个功能是为“生成文件”服务的,还是为了解决别的领域的问题?如果是后者,就归到另一个项目去。很多工具都是因为什么都想做,结果每个功能都很平庸。t3code 哪怕只做一个功能,也要让这个功能足够稳、足够快。

6.3 什么情况下我不建议你用 t3code

虽然我平时很乐意推荐 t3code,但有一些情况我会明确劝退。第一,如果你的团队已经在用 Hygen 并且模板体系已经积累得很完整,那么迁移到 t3code 的收益不大,没必要为了换工具而换工具。第二,如果你要生成的代码涉及复杂的类型推导、跨文件重命名,或者经常需要对现有代码做结构性修改,那更合适的工具是 Codemod / jscodeshift 这类基于 AST 的方案,t3code 这种纯文本模板无能为力。第三,如果你的项目高度动态,文件结构和命名规范每个模块都不一样,那硬套模板只会让模板配置本身成为新的维护负担。工具永远是服务于人的,别让流程反过来绑架团队。

7. 沉淀下来的经验,以及后续最想做的方向

7.1 插件系统设计的一些想法

t3code 目前还是一个“单核工具”,所有模板处理逻辑都在一个包里。后续我打算引入插件机制,让模板不仅能生成文件,还能执行一些额外的副作用。最典型的例子是:生成一个组件后自动往路由配置文件里追加一条路由记录,或者生成一个服务类之后自动跑一次测试验证编译通过。

插件设计上,我不会仿照 Lerna 那种 monorepo 式的重型插件管理,而是采用简单的“hook 函数”形式:配置里可以注册afterGenerate钩子,接收本次生成的文件清单和参数对象,然后执行自定义逻辑。比如:

afterGenerate: async ({ files, args }) => { const routeFile = path.resolve('src/router/routes.ts'); const content = await fs.readFile(routeFile, 'utf8'); await fs.writeFile(routeFile, content + `\nroute.push({ path: '/${args.moduleName}', component: () => import('@/views/${args.moduleName}') });\n`); }

这种设计的优势是足够透明,用户完全掌握逻辑;劣势是容易写出绕过依赖注入、难以测试的代码。所以我在插件文档里会一直强调:把副作用集中管理,尽量用函数返回变更数据,而不是直接在钩子里写文件。类似的洁癖也许有点教条,但对维护者来说会少很多深夜排查问题的时间。

7.2 对这类开发者工具的一些个人判断

做 t3code 这几个月,我的一个比较深的体会是:开发者工具的价值密度,不在于功能的多少,而在于“是否能融入现有工作流”。一个工具如果要求你先学一遍它的 DSL、再手动配置一堆东西,那么它在你心里的优先级一定会被排到很低。与其做十个炫酷的能力,不如把一个最常见的动作打磨得异常顺滑。

同时,我也意识到代码生成工具不是银弹。它最适合的是“结构固定、业务逻辑简单”的部分,比如组件骨架、接口服务模块、测试初始化,而不是试图自动化那些高度依赖业务判断的代码。把 t3code 用在后一种场景,最后只会把错误也批量生产出来,还不如手写。

如果你也在考虑做一个类似的内部工具,我的建议是:先把手头最重复的那个文件整理成模板,用最简单的方式跑通一次;别一上来就设计庞大的配置体系。t3code 的每一次能力扩张,几乎都是被真实项目里的痛点推着走的,而不是我事先规划出来的。这种“被需求推着走”的方式,至少保证了我不会做出一堆没人用的功能。

最后分享一个具体的小技巧:给模板文件统一加.hbs后缀,能避免编辑器把它当成普通代码文件,从而避免格式化工具在模板里的错误处理。与此同时,在项目根目录的.gitignore里把templates目录忽略掉,模板就不会被提交到代码仓库,避免模板里夹杂的示例参数污染团队主干。这个习惯虽然很简单,却是我在经历了一次“模板文件被同事误改成真实数据”之后才养成的。

返回列表