如果你也经历过“开个新服务,先花两小时复制旧工程、改包名、调依赖、删冗余配置”这种事,大概能理解我为什么给这个项目起代号 t3code。它不解决业务逻辑,只解决“新项目从零到能跑”这一段的重复劳动;它也不打算替代 create-vite 这类脚手架,而是要管住脚手架生成之后那些没人管的配置文件。这个工具是我自己维护的一套命令行初始化工具集,最初只是内部用,后来折腾久了发现里头的设计点挺值得拿出来聊聊。这篇就把 t3code 当作一个案例拆开:从痛点、选型、用法,到模板设计最容易翻车的地方,最后给一个可以直接抄作业的最小骨架。适合想自建工程初始化工具、或者正在整理团队工程规范的人。
1. 为什么会有t3code:一次新项目初始化让我烦透了
其实最开始的导火索很普通。团队大概在十人左右,前端三个小组,每个小组都有自己的一套“标准工程”。每次开新项目,流程基本是:找老项目拷贝,全局搜索替换应用名,删掉业务代码,保留基础设施。听起来没什么,但实际操作里有几个问题很磨人。
1.1 复制粘贴式初始化到底浪费了什么
首先是时间。一次完整初始化,从复制到跑通至少要半小时,遇到依赖装不上、锁文件冲突、CI 配置忘了改,一小时就没了。一个月开两三个项目,看着不多,但对团队来说,这段时间是完全没有产出的。关键问题还不只是时间,而是“每次初始化都不完全一样”——今天复制的是 A 项目的工程,明天复制的是 B 项目的工程,同样的规范配置,两个项目差异可能很大。时间久了,团队内部会出现多套并存的标准工程,新成员入职看到 A、B 两个项目长得不一样,第一反应往往是“只要我能跑起来就行”,规范就这样悄悄松弛了。
1.2 规范文档不等于规范落地
我们当时也写过工程规范文档,Markdown 写了十几页,目录、命名、lint、提交格式,事无巨细。后来发现一个挺无奈的规律:文档被阅读的频率和它被更新的频率是成正比的,但只要初始化这一步靠手工,就算有人读了文档也会漏。人做事有个特点,越重复越容易跳步,跳步的时候自己不会觉得,等 CI 报错才发现少了配置。所以我想明白一件事:规范要落地,就得从“靠人遵守”变成“靠工具生成”。t3code 这个名字里,“code” 指的不是业务代码,而是“工程化配置代码”,也就是 ESLint 配置、TypeScript 配置、CI 文件、dockerignore、目录骨架这些。
提示:如果你的团队已经有工程规范文档,但新项目仍然靠复制粘贴,那么你缺的不是更好的文档,而是把文档变成模板的初始化工具。
1.3 t3code 划定的问题边界
动手前,我给自己定了一条边界:t3code 不碰业务代码生成,不碰接口定义,只做“工程骨架+规范配置”的生成。原因很简单,业务千差万别,每个项目都要人写;工程配置是高度可模板化的,值得自动化。范围这样一划,工具复杂度立刻可控了。t3code 具体管四件事:
- 新项目初始化:交互式问几个关键参数,按团队模板生成目录和配置文件;
- 存量项目迁移:老项目没有标准化配置时,执行一条命令补齐缺失文件;
- 配置升级重放:模板升级后,对已有项目重放变更,而不是手动再改一遍;
- 生成审计:每次生成之后留下一个 manifest,记录用了哪个模板版本、哪些参数。
这个边界也影响了后面的所有选型。它不追求“一条命令生成一个完整可上线的服务”,而是追求“工程配置一次生成、持续同步”。
2. t3code的定位与技术选型拆解:它不是脚手架,是规范落地工具
很多同事第一次听说 t3code,会觉得这就是个脚手架。实际用起来你会发现,它和 create-vite、degit 这类工具的侧重点完全不一样。脚手架的价值在于“下载模板并初始化”;t3code 的价值在于“生成之后,还能追踪、迁移、升级”。所以从定位上,它更接近“模板引擎+配置管理”的组合,而不是下载工具。
2.1 为什么不直接用 degit 或 create-vite
不是不能用,而是解决不了我的问题。degit 很擅长把远端模板拉到本地,但拉下来之后,模板里的版本、配置就凝固了。模板更新了,老项目不知道;老项目初始化时用了旧参数,也没人记录。create-vite 这类官方脚手架更聚焦单一技术栈,换一个技术栈就得换工具。我更希望有一个统一的入口:不管前端、后端、还是一个简单的静态站点,都从同一个命令行工具进入,只是背后加载不同的预设(preset)。这个“统一入口+多预设”的结构,遇到的场景越多,越能体现出价值。
2.2 关键选型:命令行框架用什么
我最后选了 Node.js 生态。理由是团队前端为主,Node 环境人人都有,CLI 入口可以用npx直接跑,不需要额外安装。具体三个库:
commander:做参数解析和子命令注册,生态成熟,文档清晰;prompts:做交互式提问,支持单选、多选、文本输入,生成的答案结构很干净;handlebars:做模板渲染。一开始考虑过 EJS,后来换成 Handlebars,原因是模板里尽量减少逻辑,变量输出和条件判断就够用,手写逻辑越少,模板越不容易出错。
有两点值得说明。一是为什么不用 Shell/Python:Shell 在文件复制和文本替换上很快,但交互参数校验、模板上下文传递、跨平台路径处理都比较麻烦,Windows 上一套 sed 命令到处踩坑;Python 是好的,但团队成员不一定装了,还要处理 pip 环境。Node 配合npx t3code,门槛最低。二是为什么不用二进制分发的语言(Go/Rust):不是不行,而是考虑到“模板文件要能热更新”,把模板和 CLI 拆成两个仓库会更灵活,二进制方案反而把模板版本和程序版本绑死了。
2.3 模块划分:templates 与 engines 分离
t3code 最终的模块结构是三层:
- 模板层:一个独立仓库存 templates,里面按 preset 分目录,每个目录是一套完整的工程骨架,里面有普通文件,也有
.hbs后缀的模板文件; - 引擎层:CLI 负责拉取模板、读取参数、渲染文件、执行钩子;
- 预设层:preset 是一份 JSON 配置,描述“这个模板需要问哪些问题、每个问题怎么校验、渲染时往上下文里注入什么默认值”。
这个拆分带来一个好处:CLI 升级不影响模板,模板更新不需要发版。你可以像维护一个 npm 包一样维护模板仓库,用 tag 管理版本;CLI 安装时默认使用最新 tag,也可以指定--template-version锁版本。这个设计在后面做存量项目迁移时非常有用,因为你可以精确地对一组历史项目重放某个旧模板版本的变更,而不是被新版本强行带上。
2.4 一次初始化要经过哪些阶段
t3code 的init命令在内部走了一条固定流水线:
- 加载指定 preset,得到问题列表和默认值;
- 交互式提问,得到答案后做一次整体校验(比如应用名是否合法、端口是否冲突);
- 组装渲染上下文,把答案、当前时间、git 用户信息放进去;
- 拉取模板到临时目录,用 Handlebars 渲染所有
.hbs文件; - 把渲染结果复制到目标目录,忽略以
_开头的“指令文件”; - 对每个文件执行轻量校验(比如文件锁、CI 是否有固定关键字);
- 执行用户自定义的后置钩子:安装依赖、git init 等;
- 写入
.t3code/manifest.json,记录参数、模板版本、执行时间。
这个过程听起来不复杂,但每一步都有不少细节。后面的章节我把最容易翻车的地方单独拿出来讲。
3. 两条使用路径:交互式初始化和配置迁移
t3code 日常用得最多的是两条命令:t3code init和t3code migrate。一条负责新项目从零开始,一条负责老项目向规范靠拢。两条路径共用同一套模板和预设,这点很重要——它保证了“新生成的工程”和“被迁移后的老工程”结构一致,不会出现两种风格。
3.1 交互式初始化是怎么收敛参数的
很多脚手架的问题在于问题太多,用户一路回车,最后也不知道自己选了什么。t3code 的做法是给每个 preset 定义“最少提问集”。比如一个 Node 服务 preset,只问四个问题:
- 应用名(用于 package name、目录名);
- 包管理器(npm/pnpm/yarn);
- TypeScript 开不开;
- 是否需要 Dockerfile。
其余全部使用预设默认值,默认值在 preset 里集中配置,不占用用户注意力。这样设计有一个考虑:初始化的关键参数越少,人越愿意认真回答。如果一上来问二十个问题,用户大概率全选默认,反而失去了交互的意义。
实际跑起来大概是这样(省略了 CLI 内部渲染过程,只看命令效果):
$ npx t3code init --preset node-service ? 应用名是什么? (my-service) user-service ? 包管理器? (pnpm) pnpm ? 是否启用 TypeScript? (Y/n) Y ? 是否生成 Dockerfile? (y/N) N ✔ 模板已生成到 user-service/ ✔ 依赖安装完成 ✔ 已写入 .t3code/manifest.json你看到的每个问题后面括号里的就是默认值,直接回车即可。对新人非常友好,对熟练用户也不啰嗦。
3.2 生成结果与 manifest
生成后的目录大概是这样:
user-service/ ├── .github/ │ └── workflows/ci.yml ├── src/ │ └── index.ts ├── .eslintrc.cjs ├── .gitignore ├── .t3code/ │ └── manifest.json ├── package.json ├── tsconfig.json └── pnpm-lock.yaml生成之后,.t3code/manifest.json会记录:
{ "template": "node-service", "templateVersion": "v1.4.2", "generatedAt": "2025-02-14T10:00:00.000Z", "params": { "appName": "user-service", "packageManager": "pnpm", "typescript": true, "dockerfile": false } }别小看这个 manifest,它是后面 migrate 命令的基础。有它,t3code 才能判断“这个项目是哪个模板版本生成的、参数是什么”,从而知道升级时该往哪些文件里注入什么。
3.3 存量项目迁移:migrate 的增量逻辑
老项目没有 manifest,怎么办?t3code 的migrate命令不要求项目必须由 t3code 生成。它会做两件事:
- 对比目标目录里缺失的模板文件,补齐;
- 对已存在的文件,如果模板里标记为“必须一致”,则做内容替换;否则跳过。
这里有个非常容易踩的坑:内容替换绝不能盲目全量覆盖。老项目可能已经有自己改过的 lint 规则,直接覆盖会引发大范围 diff,还容易得罪同事。t3code 的策略是:模板文件里通过特殊注释声明更新策略,比如.eslintrc.cjs顶部写# t3code:tracked,表示该文件由模板管理,升级时整体覆盖;而package.json这种几乎一定会被业务改动碰到的文件,默认不覆盖,只在迁移时通过--merge选项做有选择的合并。
合并逻辑演示(这是迁移后帮你生成的变更预览):
$ npx t3code migrate --preset node-service --dry-run [+] 将新增: .github/workflows/ci.yml [+] 将新增: .t3code/manifest.json [~] 将更新: .eslintrc.cjs (tracked) [ ] 已跳过: package.json (未标记,且已有本地修改)--dry-run是我强烈建议使用的参数。跑迁移前先看变更预览,比直接执行然后回滚要稳得多。
3.4 preset 的 JSON 配置示例
预设文件长什么样直接决定了模板好不好维护。我习惯用一个preset.json描述行为:
{ "name": "node-service", "version": "1.4.2", "questions": [ { "name": "appName", "message": "应用名是什么?", "type": "text", "default": "my-service", "validate": "^[a-z][a-z0-9-]*$" } ], "defaults": { "packageManager": "pnpm", "typescript": true, "dockerfile": false }, "hooks": { "postGenerate": ["npm run install"] } }我把“问题”和“默认值”分开,是因为它们用途不同:问题只在交互时出现,默认值则始终参与渲染上下文。比如非交互模式下用户可以跳过全部问题,直接用--typescript=false覆盖默认值,脚本化初始化也走得很顺。
3.5 非交互模式的适用场景
不要以为 CLI 就一定要交互。在 CI 里批量创建临时项目,或者做模板回归测试时,交互模式反而碍事。t3code 支持完全非交互调用:
npx t3code init --preset node-service --app-name user-service --package-manager pnpm --typescript --no-dockerfile参数齐全时不会触发 prompt,直接生成。这个模式最大的价值是可测试性——你可以用同一组参数反复生成项目,然后对生成结果做 diff,验证模板改动是否引入意外变化。
4. 模板设计里最容易翻车的三件事
工具本身写起来不难,难的是模板设计。我在这块踩过的坑,比 CLI 代码里的 bug 多得多。这里挑三个典型问题,每一个都是实际遇到后花了不少时间才修正的。
4.1 第一个坑:应用名一会儿是 user-service,一会儿是 UserService
很多配置需要的名字形式不一样:package.json里要 kebab-case,class 名要 PascalCase,docker 镜像名要小写。如果模板里直接写{{appName}},那其他地方就得靠人手工改。t3code 的做法是在渲染前把上下文扩展成一组派生变量,模板里只引用派生变量:
function enrichContext(params) { const appName = params.appName; return { ...params, appNameKebab: appName, appNamePascal: toPascalCase(appName), appNameCamel: toCamelCase(appName), appNameUpperSnake: toUpperSnakeCase(appName), }; }对应到模板里:
{ "name": "{{appNameKebab}}", "version": "0.0.0" }export const serviceName = '{{appNamePascal}}';这样一改,模板里就不需要任何“条件拼接”逻辑,规矩统一。原则是:模板作者不要写逻辑,把命名转换全交给上下文生成层,模板出错率会低很多。
4.2 第二个坑:每次生成都带时间戳,diff 永远不干净
最开始我图省事,在 LICENSE 里生成年份,在 README 里生成构建时间。结果模板回归测试怎么做都过不了,因为两个批次生成的文件总有差异。后来定了一条铁律:模板输出必须是输入参数的纯函数,任何会变的元信息只允许写入 manifest,不写进正式文件。LICENSE 年份要写,也是从当前年份推断,但这个推断结果要能被--license-year参数覆盖,否则年初和年末生成的工程又会产生 diff。
如果你也做模板测试,建议把“两次生成、直接 diff”作为 CI 里的一个固定任务。它能快速发现模板中任何一个非确定性输出。
4.3 第三个坑:锁文件到底要不要进模板
锁文件是个争议点。不放进模板,项目初始化后每个人本机跑install会各自生成一份,依赖版本完全不可控;放进模板,模板作者每次升级依赖都得同步更新锁文件,维护负担很大。我的折中方案是:模板仓库里放一个经过认真审查的锁文件,依赖升级时用工具批量更新锁文件,并提交一次独立的 commit。这样初始化后没人需要在本地重新锁版本,能保证团队起始版本一致。
至于“包依赖升级策略”,我一般这样约定:
| 依赖类型 | 在模板里的写法 | 升级方式 |
|---|---|---|
| 直接依赖 | 指定^主版本小版本区间 | 定期t3code upgrade-template统一升级 |
| 开发依赖 | 指定精确版本(不带^) | 与 eslint、ts 等配置强相关,必须精确 |
| 传递依赖 | 不写,靠锁文件 | 由锁文件统一锁定 |
这条约定帮助我们避免了“直接依赖全写最新,结果 A 项目和 B 项目版本不一致”的情况。
4.4 模板的可测试性:没有 golden test 的模板等于没规范
这是我最想强调的一点。模板被很多人当成“复制一堆文件”,所以几乎没有人给模板写测试。但实战里,模板和代码一样会腐烂:今天有人改了.gitignore,明天有人加了条 lint 规则,谁也没发现这些修改会让某些工程坏掉。t3code 的做法是给每个 preset 配一个 golden test:固定一组参数,跑非交互生成,然后把输出目录和一个 golden 目录做 diff,一旦有非预期差异,测试就挂。这个测试让模板升级时有了安全感,而不是“改完心里没底,靠手工验证一个项目就算过”。
注意:golden test 有代价。任何有意的输出变化都需要先更新 golden 目录再通过,这等于给每一次模板变更设置了一道显式关卡。我觉得这个代价非常值得。
4.5 容易被忽略的细节:目录复制用白名单而不是黑名单
复制模板到目标目录时,一定要小心“多复制了文件”的问题。我的做法是:模板目录下的每个文件默认都会被复制,但以_开头的指令文件会被忽略,同时支持一个emit白名单配置,明确哪些文件只有满足某个条件才输出。白名单比黑名单安全得多,因为黑名单永远追不上模板的演变——今天加了一个临时文件忘了配忽略规则,下次初始化就会把它带到工程里。
5. 从0到1复刻一个最小版本t3code的骨架
如果你也想做类似的初始化工具,不需要一上来就搞完整功能。我建议先复刻一个最小闭环:init 跑通、模板渲染、manifest 记录。以下骨架是 t3code 内部的最小版本,直接用 Node 内置模块和handlebars实现。
5.1 最小项目结构
t3code-min/ ├── package.json ├── bin/ │ └── t3code.js ├── lib/ │ ├── cli.js # 子命令和参数解析 │ ├── prompt.js # 交互提问 │ ├── render.js # 模板渲染与文件输出 │ └── manifest.js # manifest 读写 └── templates/ └── node-service/ ├── package.json.hbs ├── tsconfig.json └── .gitignore刻意保持扁平,方便你一眼看懂每个模块的职责。
5.2 入口文件与命令注册
bin/t3code.js只做一件事:把命令行参数交给lib/cli.js。
#!/usr/bin/env node const { run } = require("../lib/cli"); run(process.argv);在cli.js里用commander注册子命令:
const { program } = require("commander"); const { initTemplate } = require("./render"); program.name("t3code").description("工程化配置生成工具"); program .command("init") .option("--preset <name>", "模板预设") .option("--app-name <name>", "应用名") .action((options) => { initTemplate(options); }); program.parse(process.argv);这是最小形态。进阶一点,可以加上--dry-run、--no-install等布尔选项,改动成本很低。
5.3 渲染上下文与模板输出
渲染的核心是“上下文 + 模板 = 文件”。这个环节我把派生变量也封装进去,模板只认这些变量:
const fs = require("fs/promises"); const path = require("path"); const Handlebars = require("handlebars"); async function renderPreset(presetDir, outDir, context) { const entries = await fs.readdir(presetDir, { withFileTypes: true }); for (const entry of entries) { const src = path.join(presetDir, entry.name); const dest = path.join(outDir, entry.name); if (entry.isDirectory()) { await renderPreset(src, dest, context); continue; } if (entry.name.endsWith(".hbs")) { const content = await fs.readFile(src, "utf8"); const template = Handlebars.compile(content); const rendered = template(context); const outName = entry.name.replace(/\.hbs$/, ""); await fs.writeFile(path.join(outDir, outName), rendered); } else { await fs.copyFile(src, dest); } } }实际项目里要注意:目标目录要先经fs.mkdir(..., { recursive: true })确保存在;复制文件前最好检查是否存在并有变更,避免覆盖用户手改的配置。这里不展开,但你要知道这是方向。
5.4 顺手记录 manifest
最小版本也要记得写 manifest,这是“能持续升级”的地基。新建目录.t3code,写入 JSON:
const fs = require("fs/promises"); const path = require("path"); async function writeManifest(projectDir, meta) { const dir = path.join(projectDir, ".t3code"); await fs.mkdir(dir, { recursive: true }); await fs.writeFile( path.join(dir, "manifest.json"), JSON.stringify(meta, null, 2) ); }meta 至少包含:template 名、模板版本、执行时间、参数对象。将来 migrate 全靠它。
5.5 为什么这个最小骨架就已经够用
很多人做工具容易一上来就设计插件系统、设计远程模板仓库。我的建议是:先跑通这个最小闭环,再考虑扩展。最小闭环能让你回答三个关键问题:模板渲染是否顺滑?参数校验是否及时?生成的工程是否马上就能跑?这三个问题验证完,你才知道自己到底需不需要更复杂的注册中心、插件体系。至少在我的实践里,90% 的团队不一定需要复杂扩展,简单的文件模板加上严格的版本管理,已经能解决大部分规范混乱问题。
5.6 可以扩展的方向(按价值排序)
如果最小闭环跑通了,我按性价比排了几个扩展方向:
- migrate 命令:让老项目也能被规范覆盖,价值最高;
- 模板版本锁定:支持
--template-version,可复现历史生成结果; - 插件钩子:Post-generate 时跑格式化、lint 修复;
- 远程模板仓库:模板从 git 仓库拉取,协作更自由;
- 多预设管理:套餐之间可以继承、覆盖,形成组织级预设。
扩展时记住一条原则:工具的核心价值是“确定的重复”,每加一个功能,都要想想它是否让“生成结果更可预期”。如果答案是“增加灵活性但降低确定性”,请三思。
按我的经验,初始化工具这类项目最好的起步时机是“你们已经复制粘贴第三次同样的工程配置”的时候。再往后拖,项目之间的差异越来越多,迁移成本只会更高。t3code 现在也还在迭代,但它帮我解决的最大问题,不是省了那半小时,而是让“工程规范”从一纸文档变成了一种可执行、可验证、可复现的过程。最后分享一个特别小但很实用的技巧:在模板文件头部加一行注释,比如.eslintrc.cjs顶部写// generated by t3code, do not edit,这样同事在编辑器里看到生成文件时,不会误以为它是手工维护的,后续改动会回到模板和预设里去改,而不是在一个个项目里手工补丁。下次谁再问“你们的规范是怎么统一的”,你可以直接把这份工具链演示给他看。