☰
从复制粘贴到模板生成:t3code命令行代码生成器设计与实践
2026/10/26 17:23:05 网站建设 项目流程

去年年中,我们组的服务端仓库已经超过二十个,每次有人要开新项目,第一件事就是找一个差不多的老仓库,整体复制一份,然后开始机械地改项目名、改端口、改数据库连接、改README开头那几行字。这套流程看着简单,实际上极容易翻车:我就有过一次只改了项目名、忘了改seed数据的端口,结果本地启动服务,连的却是另一个项目的测试库,查了半天,数据对不上,最后才发现是复制粘贴漏改的锅。

我写t3code的动机就是这么朴素——我想把“开新项目”这件半小时起步的重复劳动,压缩到十几秒。t3code是一个命令行代码生成工具,核心思路很简单:维护一套标准模板目录,通过交互式提问收集项目名、端口、描述等变量,然后递归去向模板目录里的每个文件做渲染、重命名,最后输出一个可直接开发的新工程。这篇文章会把从动机、设计、实现到踩坑的完整过程记录下来,给同样在和复制粘贴搏斗的朋友做一个参考。无论你是后端、前端还是做客户端,只要你团队里存在“复制老项目改改名”这种仪式,都值得花点时间看看。

1. 从“开新项目半小时”到十秒钟:t3code诞生的背景

1.1 复制粘贴的代价不仅是慢,更是漏改

先说复制粘贴这套玩法的问题。看起来只是重复,实际上每个步骤都是风险点:

  • 改项目名要全局搜索替换,但总有些文件里的名字是另一种写法,比如包名用中划线、代码里的模块名用驼峰、Docker镜像名用下划线,三种命名风格对应三套替换规则。
  • 端口要改,但改漏了很要命。一个服务里端口可能出现在五六处:启动入口、健康检查、配置文件、docker-compose、CI脚本,漏掉一个就是线上事故的前奏。
  • 老项目历史包袱会跟着复制走,比如过期的依赖、废弃的环境变量、前人留在代码里的调试开关,这些垃圾文件跟着新项目一起出生,以后还得一个个清。
  • 老项目的目录结构未必是理想结构。每一次复制都是把过去的不合理继续放大,等攒到五六个新项目都带着同一套坏结构时,再想调整就要同时改五六个仓库。

这些问题不是拷贝本身多费劲,而是拷贝之后那一长串“人肉搜索—替换—核对”的操作无人监管。t3code做的不是解决“拷贝”这个动作,而是把“搜索—替换—核对”整个流程从人的手上收走,交给一套按约定工作的程序。

1.2 为什么选择“模板目录+生成器”,而不是封装函数库

一开始团队里也有人提议,与其搞代码生成器,不如抽一个公共脚手架库,新项目直接引用它。这个思路方向有道理,但落到实际操作就会遇到一个问题:公共库适合抽取“逻辑”,不适合抽取“工程形态”。工程形态包括目录结构、配置文件、Dockerfile、CI流程,这类东西放在代码库里要么变成一堆条件分支,要么变成对业务代码的侵入式约束。

模板目录加生成器的优势在于:模板就是真实可读的文件,任何人打开都能直接看懂“这个新项目会长什么样”。工程师不需要学习生成器的内部实现,想调整初始工程结构,直接改模板文件就行,改完提交,整个团队的生成结果全部更新。这比维护一段动态拼装文件的逻辑脚本直观太多。

1.3 t3code名字的来历与工具定位

名字来自“team three code generator”(三组代码生成器)的缩写,团队里顺手就叫成了t3code。t3code的定位非常明确:它是脚手架生成器,不是低代码平台。它不管你生成完工程之后怎么做业务,只管在你动手写业务之前,把那些本该统一的目录结构、构建配置、测试骨架全部一次性铺好。它可以生成一个能跑通的最小服务,但不会去读取数据库表结构、自动生成API接口,这些超出它的边界,也是刻意保持的克制。

2. 模板、占位符、交互提问:生成器最关键的设计决策

2.1 技术选型:原生Node脚本就够了,别急着引框架

t3code的实现语言选了Node.js,而且坚持不引入任何框架。理由很实际:团队里大家日常开发都装了Node环境,一个node t3code.js就能跑,不需要额外准备Python或者Go运行时;同时对目录遍历、文本替换、路径拼接这类操作,Node的原生模块已经足够顺手。

可能有人第一反应是用Yeoman或者现成的脚手架工具,我也考虑过。但Yeoman这类框架功能确实强大,生态插件也多,可它带来的依赖树和抽象层对我们这个场景有点过重。t3code要做的事情本质就是“读目录、问问题、替换文本、写文件”,这些用Node原生模块直接写,总共不到三百行代码,逻辑完全可控。与其花时间研究某个生成器框架的插件机制,不如把这点时间省下来,把模板和变量设计做扎实。

2.2 模板目录约定:__name__与{{变量}}的分工

模板目录是整个工具的核心资产,所以目录约定的设计一定要直观。t3code约定两种占位符:

占位符形式出现位置作用典型示例
__project__目录名、文件名文件/目录级别的重命名__project__/src/index.js
{{project}}文件内容文本内容里的变量替换"name": "{{project}}"
{{port}}文件内容用户输入的参数注入port: {{port}}

为什么文件名不用{{project}}这种形式?因为文件名在Windows和Linux下都允许花括号,但内容替换和路径重命名的处理逻辑其实是两套,最好从形式上直接区分开。模板里看到一个目录叫__project__,一眼就知道这整个目录名会被替换;打开文件看到{{project}},就明确这是内容层的变量。两种方式不混用,写模板的人永远不需要判断“这个占位符到底是用来改文件名还是改内容”。

模板根目录下按场景维护多个子模板:

templates/ node-service/ # Node.js API 服务模板 __project__/ package.json index.js src/ app.js config.js test/ smoke.test.js Dockerfile README.md frontend-page/ # 前端页面模板 __project__/ package.json vite.config.js src/ main.jsx App.jsx

这样t3code在执行时直接通过--template node-service来选定模板,模板之间完全隔离,互不影响。

2.3 交互设计:问题越少越好,默认值兜底

代码生成器最劝退的体验就是一启动让你回答三十个问题。t3code把每个模板的提问控制在五个以内,而且每个问题都提供默认值,用户直接按回车就能跳过。例如生成一个Node服务的交互过程:

$ node t3code.js new node-service ? 项目名: (my-awesome-service) user-center ? 服务端口: (8080) 8081 ? 项目描述: (A Node.js service) 用户中心服务 ? 是否需要Dockerfile: (Y/n) Y ✔ 模板加载完成 ✔ 已生成 user-center 下一步:cd user-center && npm install && npm run dev

所有问题都通过一个questions.json配置在模板内部,生成器并不需要知道每个模板有哪些问题。模板作者只要在模板目录里加一个配置文件,声明变量名、提示语、默认值和是否必填,剩下的事情t3code统一处理。

这种设计的好处是模板和生成器解耦。业务团队新增一个模板时,不需要修改t3code的任何代码,只要按约定的方式写好几个文件、声明一下变量就算接入完成。

3. 完整代码拆解:从读取模板到输出新工程

3.1 递归遍历目录并过滤无用文件

t3code的第一步是把模板目录里的所有文件列出来。这一步看起来简单,但有一个关键约束:必须过滤掉模板仓库自身带的一些目录,比如.git、node_modules、dist这类不该进入生成结果的东西。

const fs = require('fs'); const path = require('path'); const IGNORE_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.DS_Store']); async function walkDir(dir, fileList = []) { const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!IGNORE_DIRS.has(entry.name)) { await walkDir(fullPath, fileList); } } else { fileList.push(fullPath); } } return fileList; }

这里用withFileTypes: true的优点是不需要额外对每个路径再执行一次fs.stat,直接就能判断类型,IO开销小很多,模板文件几百个时毫秒级就遍历完了。

3.2 交互式变量采集:默认值、必填、非法字符清洗

变量采集最核心的原则是“用户按回车不能出错”。每个问题都必须有默认值,同时用户的输入必须经过合法性清洗,否则后面替换时很容易埋雷。

const readline = require('node:readline/promises'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); function sanitizeVariableValue(value) { // 去掉文件名和路径中不允许的字符,空格替换为中划线 return value.trim().replace(/[\\/:*?"<>|\s]+/g, '-'); } async function collectVariables(questions) { const variables = {}; for (const q of questions) { const hint = q.default ? `(${q.default})` : ''; const answer = await rl.question(`${q.label}: ${hint} `); const rawValue = answer.trim() || q.default || ''; variables[q.key] = q.required && !rawValue ? sanitizeVariableValue(rawValue) || q.default : sanitizeVariableValue(rawValue); } rl.close(); return variables; }

这段代码里sanitizeVariableValue处理的就是第4章会展开的那个坑的预防措施:用户在终端里输入什么都有可能,一旦项目名里出现空格或者斜杠,后续生成出来的目录结构直接乱套。所以这一步宁可保守一点,把所有路径敏感字符全部替换掉。

3.3 内容渲染与文件名的双重替换

这是t3code最核心的环节。对每个从模板目录遍历出来的文件,做两件事:第一,用{{变量}}替换文件内容里的占位符;第二,用__变量__替换路径上每一级的目录名和文件名。

function renderTemplate(content, variables) { return content.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => { return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : match; }); } function renderPath(filePath, variables) { const relativePath = path.relative(templateRoot, filePath); const renderedSegments = relativePath .split(path.sep) .map(segment => segment.replace(/__(\w+)__/g, (match, key) => { return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : match; })); return path.join(outputRoot, ...renderedSegments); }

内容替换用的是{{}}正则,路径重命名用的是__xx__正则,两类替换严格分开。代码里有一个细节值得强调:路径渲染时先通过path.relative拿到模板根的相对路径,再进行分段替换,最后重新拼到输出根目录下。这样做避免了用户把项目名输入成绝对路径时导致目录级别越界的问题。

3.4 生成后的自检脚本:装依赖、跑测试、输出下一步

t3code在做完所有替换和文件写入之后,并不会停在“已完成”就结束。如果生成的东西直接跑不起来,那体验和手工复制粘贴没有太大区别。因此在设计上增加了一个自检步骤:

const { execSync } = require('child_process'); function postGenerate(outputPath, variables) { const commands = [ 'npm install --silent', 'npm test --silent' ]; for (const cmd of commands) { try { execSync(cmd, { cwd: outputPath, stdio: 'inherit', timeout: 120000 }); } catch (err) { console.warn(`自检步骤执行失败:${cmd}`); console.warn('请手动检查生成结果,依赖或测试可能不完整。'); break; } } console.log(`下一步:cd ${variables.project} && npm run dev`); }

这一步深得团队好评。因为模板里预置了一个最小的smoke test,新生成的服务跑一遍测试就能验证基本链路是否通。测试不过就当场暴露,而不是等开发者自己写第一行业务代码时才被环境问题卡住。

4. 翻车记录:五个我现在还印象深刻的坑

4.1 第一个坑:模板目录里的node_modules被整个绑架了

t3code第一个可用的版本写出来后,我第一次用它生成一个新服务,当时很兴奋地敲下回车,结果发现生成过程卡了将近四十秒。我盯着终端以为是死循环,等它跑完一看,输出目录里多了个巨大的node_modules。

排查时发现原因是:t3code最早的遍历逻辑没有过滤目录,而我本地的模板仓库因为调试原因在某个子目录里跑过一次npm install,于是node_modules被当作普通目录一并拷贝到生成结果里。这个问题的后果不只是生成的工程目录瞬间膨胀几百MB,更严重的是它会带上本机编译过的原生模块,而新项目很可能要部署到Linux容器,那些darwin-x64的二进制文件一旦进仓库,后果就是CI乱套。

修复方案就是对遍历目录加了一层过滤。但这个坑给我们的教训很深刻:任何递归拷贝类的工具,默认必须过滤符号链接关联的依赖目录,否则指不定哪天就把本地环境的东西带出去了。

4.2 第二个坑:二进制文件被文本替换直接改坏

模板里我放了一个logo占位图,一个字体文件,生成之后打开图片,发现文件已经损坏,变成了乱码。原因是t3code最早的实现图省事,对每个文件的内容统一执行字符串替换。对一个PNG文件执行字符串替换,就会用UTF-8解码整个二进制流,再尝试去匹配{{project}},哪怕匹配不到,重新编码后的字节流也已经不是原来那个文件了。

这个问题的标准解法不是去判断文件扩展名,而是判断文件内容是否为二进制:

const { Buffer } = require('buffer'); function isBinaryBuffer(buffer) { const sample = buffer.subarray(0, 8000); return sample.includes(0); }

这个判断思路很简单:文本文件的前8KB里出现\0空字节的概率极低,而常见的图片、字体、压缩包几乎都会在头部里出现\0。生成器对每个文件先读成Buffer,判断是二进制就直接原样拷贝,不做任何渲染。修复之后,模板里放图片、favicon、字体文件都完全没有问题。

4.3 第三个坑:Windows路径分隔符引发的配置爆炸

团队里有同事用Windows开发,有次他在Windows环境下生成服务,跑完发现生成的package.json里scripts路径全乱了。排查发现模板里有一处是这样的:

{ "scripts": { "build": "node scripts/build.js --config {{configPath}}" } }

用户输入的configPath是一个Windows路径,比如src\config\app.json,替换之后变成了node scripts/build.js --config src\config\app.json。这个字符串在JSON里本身没问题,但一旦被当作命令解析,反斜杠后面跟的字符会被当作转义符号,路径直接爆炸。

最后处理是在渲染内容时统一把用户输入的路径变量里的反斜杠转成正斜杠,只在真正需要Windows原生路径的场景再做一次转换。这也算是一个跨平台团队才踩得到的坑。

4.4 第四个坑:变量值带着斜杠和引号,把模板搅成一锅粥

团队里另一个同学在生成服务时,项目名随手输了一个带空格的英文短语,于是整个目录名全带空格,CMakeLists直接读取不到模块路径。后来又有人输入了一个带双引号的描述,生成的README里Markdown语法都被搞乱了。

这个坑的根因是:用户输入属于不可信输入,生成器必须充当拦截方。我们最终的处理思路是:

  • 目录名和文件名中使用到的变量,统一经过sanitizeVariableValue清洗,把路径分隔符、通配符、双引号、尖括号等字符全部替换为中划线。
  • 内容中注入的变量,只做简单转义,不试图去“智能修正”用户的文本。
  • 对必填项做强校验,清洗后如果变成空字符串,直接报错退出,而不是默默生成一个名字为空的项目目录。

4.5 第五个坑:生成完的目录看起来不像一个“项目”

有段时间,新生成的服务放到VS Code里打开,侧边栏没有显示出package.json的图标,编辑器里也不提示依赖版本冲突。一开始以为是文件没生成对,后来发现是IDE的缓存和索引机制在作怪:t3code在很短的时间内创建了几百个文件,编辑器还没来得及把它们全部纳入文件系统监听,于是有一些文件没有立即出现在资源管理器里。

这个问题最有效的解决办法是改变生成器的收尾动作:不再只是打印一句“生成成功”,而是主动执行依赖安装和测试,触发真实文件写入,把工程目录“激活”成编辑器可以识别的结构。依赖装完之后,VS Code的自动刷新机制会重新扫描整个目录,一切恢复正常。虽然这个坑本质上不是t3code代码的问题,但它提醒了我:工具链的最后一个动作必须让生成结果对用户的下一个动作可见,否则用户会怀疑工具坏了。

五个坑的总结如下:

坑位表现根因修复方式
目录污染生成产物带node_modules遍历未过滤依赖目录在walkDir阶段排除
二进制损坏图片、字体文件乱码对所有文件做文本替换Buffer中空字节判断,二进制直接拷贝
路径分隔符命令里的路径被转义Windows反斜杠进入内容渲染时统一转为正斜杠
非法输入目录名带空格或引号缺少输入清洗sanitize + 必填校验
IDE不识别目录存在但文件不可见文件创建太快/未触发索引生成后执行依赖安装和测试

5. 四个迭代让t3code从玩具变成每天在用的工具

5.1 模板的Git化管理:改模板比改代码更频繁

t3code第一版把所有模板放在本地目录。用了一周后发现一个问题:某个模板的结构需要调整,我改好了自己机器上的本地模板,但团队其他人生成出来的还是老结构。这种不一致比没有模板还可怕,因为大家会慢慢开始怀疑“这个结构到底以谁为准”。

解决方式:把模板目录本身做成一个Git仓库,t3code在启动时检查模板仓库的远程更新,有新的commit就自动拉取。模板和生成器代码分开版本管理,业务团队提交的是模板修改,t3code核心代码几乎不需要动。

$ node t3code.js update ✔ 模板仓库已更新到 697d4f2

这个改动让模板演进的速度快了很多,有人发现初始工程里少了一条CI配置,直接往模板仓库提个MR,全员下次生成就都有了。

5.2 让每个新工程自动带上测试和CI,而不是空骨架

第一版模板只在test/里放了一个简单的smoke test,后来逐渐把CI配置也纳入模板。现在每次生成的新工程,可以直接提交到GitLab触发流水线,跑完就能看到测试结果。这样的好处是:新工程的“可用性”不是靠人保证的,而是靠流水线保证的。只要生成完提交,CI帮你验证了依赖安装、测试执行、构建脚本全部正常,这比任何人工检查都靠谱。

5.3 增量生成:给老项目也补上新结构

t3code最初只能从零生成新项目。后来团队里有一些老项目希望引入统一的测试目录规范,但不可能为了这一点重新生成整个工程。于是t3code增加了一个增量生成模式:指定模板和已有项目路径,只替换模板里存在的、而目标项目里缺失的文件,绝不覆盖已有文件。

$ node t3code.js sync --template node-service --dir ./user-center ✔ 缺失文件已补齐:test/smoke.test.js, .gitlab-ci.yml

这个功能让旧项目也能逐步对齐新结构,而不必强迫一次性迁移。老项目不再需要大动干戈,就能享受新模板带来的基础能力。

5.4 模板间继承:公共结构抽出来,子模板只写差异

随着模板越来越多,t3code里的模板出现了重复。一个前端页面模板和一个前端组件模板,80%的基础配置几乎一样,只有入口文件不同。与其让每个模板各复制一套package.json,不如抽一个base-frontend模板,其他模板基于它进行覆盖。

实现上是给模板加了一个extends字段,生成时先拷贝base模板的全部内容,再覆盖子模板自身的文件。这样公共部分只维护一份,子模板里的文件数量急剧减少,新增模板的成本也降下来了。

6. 想给团队也写一个?这五点建议值得先看看

6.1 别把代码生成器做成配置解析器

最早写t3code时,我一度想让模板引擎支持条件判断、循环、继承表达式,这样模板里可以写“如果选择了数据库,就生成ORM配置”。但后来发现这些需求完全可以通过维护多个模板来解决:选了MySQL就对应一套模板,选了PostgreSQL就换另一套,而不是在一个模板里塞一百个条件分支。生成器的价值在于把工程形态标准化,如果模板本身充满了各种分支条件,那么每个分支组合都是一个新的工程形态,就违背了标准化的初衷。

6.2 模板要当成正式代码来维护,有版本,有变更记录

模板不是一堆死文件,它是团队工程纪律的载体。模板的每一次改动都会影响未来每一个新项目,所以模板仓库必须走正式的代码评审、版本标签和变更记录流程。我们现在的模板仓库里有个CHANGELOG.md,每一条变更都记录了“改了什么、为什么改、对已有项目有没有影响”。这样任何一个模板改动,团队都能追溯到原始动机,而不是看到一堆文件差异一脸茫然。

6.3 生成结果不能只靠肉眼看,要跑差异对比

t3code早期每次更新模板,都要人工开一个新的生成产物去检查有没有问题。后来写了一个对比脚本:输入同一个变量集,分别用当前模板和上一版本模板生成两个工程,然后对整个目录做一次递归diff,任何一点差异都能列出来。这个脚本虽然简单,但成了模板变更的守卫者,每次提交模板之前先跑一遍对比,确保实际改动和预期完全一致。

6.4 让用户能快速失败

生成器最忌讳的是在最后一步才报错。t3code现在对所有输入变量在收集阶段就做严格校验,目录名不合法直接当场拦截,必填项留空直接退出,模板文件缺失在加载阶段就报错。快速失败的意思是:错误越早暴露,用户代价越小。与其生成一个半成品让开发者自己排查,不如在交互阶段就拦住。

6.5 知道边界在哪里,不要什么都往生成器里塞

t3code到现在也没有搞可视化界面,没有做Web版,没有去支持“通过拖拽组件生成页面”这类需求。它始终只是一个命令行工具,面向的场景就是开发者在终端里快速起一个新工程。如果哪天团队需要一个更重型的低代码平台,那是另一个产品级项目的范畴,不应该在t3code上继续堆功能。保持工具的边界清晰,它才会一直好用。

我在实际使用中的一个体会是:代码生成器最大的价值不在于省下那几分钟,而在于它把“新工程长什么样”这个问题从每个工程师的个人记忆变成了团队的统一共识。如果你也在复制粘贴的泥潭里挣扎,先别急着追求什么高大上的脚手架体系,就从一条最基础的模板开始,把你们团队最常见的那个工程结构固化成文件,然后写一个两百行的脚本去填充变量。用起来之后,你自然会知道下一步该往哪里走。

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

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

立即咨询