t3code:轻量项目脚手架生成器,模板变量一键替换
做新项目最烦的不是写业务代码,而是每次都要重复搭骨架:复制上一个项目改包名、删掉用不到的路由、换数据库连接串,一不小心还会把旧项目的敏感配置带到新项目里。我后来实在受不了,花了一个周末写了t3code这个小工具,它本质上是一个命令行脚手架生成器,帮你从预置模板里快速初始化新项目,同时把项目名、包名、描述、作者这些变量在生成时统一替换掉。这篇文章就聊聊我为什么不用现成方案而是自己写,以及整个过程里踩过的坑、做过的取舍和最终的落地效果。如果你也在维护多套项目模板、或者想给团队做一个统一的初始化入口,这篇东西应该对你有参考价值。
1. t3code 的由来与定位
1.1 为什么需要这样一个小工具
先说背景。我的日常工作里经常要开新仓库,有的是内部服务、有的是前端工程、有的是给客户交付的示例代码。这些项目虽然业务完全不同,但基础结构高度相似:一个package.json、一套目录规范、几个配置文件、一组公共脚本。早先我就是靠手动复制之前的仓库,然后全局替换关键字,效率低不说,还稳定出问题。
有一次我把一个带鉴权逻辑的旧项目复制给新客户做演示用,忘了清理.env文件里的内网地址,当场社死。那次之后我开始认真考虑做一个统一的初始化工具,要求就三条:模板与代码分离、变量替换可配置、生成过程可重复。于是就有了t3code。
名字里的 t3 其实是三层单词的缩写,代表 template、tree、toolchain,意思是模板、目录树和工具链一起处理。它的定位不是一个通用脚手架平台,而是一套贴合我日常使用习惯的轻量化方案。
1.2 与 create-vite、plop 等现成方案有什么不同
市面上已经有很多成熟的初始化工具,最常见的两类:
create-vite、create-react-app这类官方脚手架,特点是强约定、弱定制。Vite 官方帮我们定死了 React/Vue 项目该长什么样,你只能在它给定的选项里做选择,没法塞自己的私货。plop、yeoman这类代码生成器,灵活性很高,但学习成本也不低,尤其 yeoman 的 generator 体系对一个小工具来说过于笨重了。
我当时的想法很简单:我不需要生态、不需要插件市场、不需要在线更新,我只需要把自己的几套模板以最直白的方式管理起来,然后一条命令生成新项目。t3code的核心逻辑其实就是三步:读取模板目录、询问用户问题、渲染并拷贝文件到目标路径。没有任何黑魔法,代码量不大,但用起来非常顺手。
1.3 什么样的人适合用 t3code
如果你只是偶尔开一个 Vite 项目,直接用官方脚手架就够了,没必要自己搞一套。t3code适合下面三类人:
- 个人维护多个项目模板,每次开新项目都要重复手动初始化,想省时间。
- 小团队没有统一的前端/后端工程规范,想通过模板来强制统一项目结构。
- 需要给客户或内部交付“半成品项目”,希望生成时动态替换项目名、组织名之类的变量。
一句话:当你觉得“官方脚手架给不了我想要的默认结构,但我又不想每次手动改一堆文件”的时候,就是t3code的适用场景。
2. 核心设计思路与技术栈选择
2.1 三条设计原则:模板即代码、约定优于配置、最小依赖
动手之前我先给自己立了三条规矩,后面所有实现都围绕这三条走。
第一,模板即代码。模板文件不是一个黑盒,它就应该像代码一样存放在仓库里,可 diff、可 review。我希望模板目录里每一个文件都直接能看懂,而不是需要跑一个复杂的配置流程才知道最终会生成什么。
第二,约定优于配置。我不想像 webpack 那样搞一个几百行的主配置。于是定了一组非常简单的约定:模板文件里以.hbs结尾的文件会被当作 Handlebars 模板进行渲染,其他文件原样复制;变量定义在模板目录下的meta.json里。这样既不需要单独写一套配置文件,也不需要学习 DSL,打开模板目录扫一眼就明白所有规则。
第三,最小依赖。这个工具本身代码量就不大,所以能不用第三方依赖就不用。最终只保留了四个:commander处理命令行参数、prompts做交互式询问、handlebars做模板渲染、fs-extra做文件操作。加在一起体积很小,安装速度非常快。
2.2 为什么选 Node.js + TypeScript 而不是 Go、Python 或 Shell
技术栈这块我纠结过一阵,最后选了 Node.js + TypeScript。说实话 Shell 脚本不是不能用,但跨平台处理文件名、交互式询问这些事情写起来很痛苦;Python 的cookiecutter其实很成熟,但团队里不写 Python 的人维护起来有心理门槛;Go 编译成单文件确实很香,但脚手架开发里大量字符串模板操作,Go 写起来不如动态类型顺手。
Node.js 最大的优势是生态里已经有现成的、质量很高的交互询问库和模板引擎,而且所有前端工程师都看得懂,后续让别人来维护几乎没有学习成本。TypeScript 则帮我减少了一类特别常见的 bug:文件路径或者模板变量明明传错了类型,直到运行到一半才报错。
2.3 技术选型对照表
选型这件事上,我对比过几组方案,直接列张表给大家参考:
| 能力维度 | 我选用的方案 | 备选方案 | 落选原因 |
|---|---|---|---|
| 命令行参数解析 | commander | yargs / cac | commander 生态最成熟,文档也清晰 |
| 交互式询问 | prompts | inquirer | prompts 更轻量,输出风格现代,不需要维护太多配置 |
| 模板渲染 | handlebars | ejs / 自定义正则替换 | handlebars 语法简单,非开发者也看得懂,约束性强 |
| 文件操作 | fs-extra | 原生 fs / shelljs | 递归复制和删除目录太方便,省大量代码 |
| 语言 | TypeScript | JavaScript | 类型约束能提前发现路径拼接、参数传递错误 |
这里想多说一句为什么不用自定义正则替换。最原始的思路其实是用{{projectName}}这种占位符,然后正则全局替换。这个方案在小项目里能用,但一旦模板里要写循环(比如根据模块数组生成多个路由文件)或者条件判断(比如用户选择是否需要 docker 配置),正则替换就完全没法扩展了。引入 handlebars 之后,模板的能力边界一下子宽了很多,代价不过是模板文件里多几个{{#if}}标签而已。
另一个容易踩坑的点是:很多脚手架喜欢用ejs,但ejs的默认标签<% %>和 Vue 模板的小胡子语法容易混淆,而且它允许在模板里写任意 JavaScript 逻辑,时间一长模板里全是复杂逻辑。handlebars 在这方面更克制,变量用{{ }},逻辑用内置 helper,写不出太离谱的东西。
3. 从零实现:模块拆解与关键代码
3.1 项目目录结构规划
前期设计想清楚了,代码写起来就很快。t3code的仓库结构长这样:
t3code/ ├── package.json ├── tsconfig.json ├── src/ │ ├── index.ts # 入口,负责调用 cli │ ├── cli.ts # commander 命令定义 │ ├── questions.ts # 交互询问问题列表(根据 meta.json 动态生成) │ ├── render.ts # 核心渲染逻辑:遍历模板目录、渲染 .hbs 文件 │ ├── utils.ts # 路径处理、命名校验、字符串规范化等 │ └── types.ts # 类型定义 ├── templates/ │ ├── basic-web/ # 基础前端工程模板 │ │ ├── meta.json │ │ ├── package.json.hbs │ │ ├── tsconfig.json.hbs │ │ ├── src/ │ │ │ ├── index.ts.hbs │ │ │ └── App.tsx.hbs │ │ └── .gitignore │ └── api-server/ # Node API 服务模板 │ ├── meta.json │ ├── package.json.hbs │ ├── src/ │ │ ├── index.ts.hbs │ │ └── config.ts.hbs │ └── .env.example ├── dist/ # 编译产物 └── README.md有一个细节值得注意:t3code源代码(src/)和模板文件(templates/)是分开的。原因很简单,模板目录对最终用户来说应该是可读、可修改的,如果我把模板序列化到源码里,用户想加一个文件就得改代码重新构建,这就太不友好了。现在这样设计,任何人拿到项目后直接往templates/里扔一个新目录,就自动多了一个模板选项。
3.2 命令解析与交互询问实现
命令入口我用了commander,整体就一个命令:t3code create <template-name> <target-dir>,另外加一个t3code list用来查看本地有哪些模板。
import { Command } from "commander"; import { createProject } from "./create.js"; import { listTemplates } from "./list.js"; const program = new Command(); program .name("t3code") .description("基于本地模板快速生成项目骨架") .version("1.0.0"); program .command("create") .argument("<template>", "模板名称,例如 basic-web") .argument("<target>", "生成目标目录") .option("-f, --force", "目标目录已存在时强制覆盖") .action(async (template: string, target: string, opts: { force?: boolean }) => { await createProject(template, target, opts.force ?? false); }); program .command("list") .description("列出所有可用模板") .action(() => { listTemplates(); }); program.parse(process.argv);每个模板的meta.json里会定义这个模板需要向用户询问哪些变量:
{ "name": "basic-web", "description": "基础前端工程模板,包含 React + TS + Vite", "variables": [ { "key": "projectName", "message": "项目名称(会用作 package.json 的 name 和目录名)", "default": "my-app" }, { "key": "author", "message": "作者名称", "default": "anonymous" }, { "key": "needDocker", "type": "confirm", "message": "是否需要生成 Dockerfile?", "default": false } ] }questions.ts里做的事情就是把meta.json的variables数组转换成prompts需要的问题格式。这是一个非常值得推广的思路:模板的提问项不需要写死在代码里,而是让模板自己声明要问什么。这样往templates/里加新模板的时候,连一行代码都不用改。
import prompts from "prompts"; import type { TemplateMeta } from "./types.js"; export async function askQuestions(meta: TemplateMeta) { const questions = meta.variables.map((v) => ({ type: v.type ?? "text", name: v.key, message: v.message, initial: v.default, })); const answers = await prompts(questions); return answers; }3.3 模板变量渲染的边界处理
渲染这一步是核心,也是最容易踩坑的地方。我用 handlebars 作为渲染引擎,在遍历模板目录时,凡是.hbs结尾的文件就读取内容并执行Handlebars.compile,渲染完成后把.hbs后缀去掉再写入目标路径。
import fs from "fs-extra"; import path from "path"; import Handlebars from "handlebars"; async function renderTemplateFile( sourceFile: string, targetFile: string, data: Record<string, unknown> ) { const sourceContent = await fs.readFile(sourceFile, "utf-8"); const template = Handlebars.compile(sourceContent); const rendered = template(data); await fs.ensureDir(path.dirname(targetFile)); await fs.writeFile(targetFile, rendered, "utf-8"); }这里有几个必须注意的边界情况。
第一个是二进制文件。如果模板里放了图片、压缩包这类二进制资源,直接readFile成 utf-8 字符串再写回去,文件就坏了。所以我在代码里加了一个判断:遇到非文本文件就原样复制,不做渲染。实现上可以向后看文件后缀,也可以尝试Buffer.isBuffer判断,但最稳妥的办法是把已知文本后缀列成一个集合,不在集合里的一律按二进制复制。
第二个是.gitignore这类点文件。这在开发脚手架时是经典问题:模板里的.gitignore在执行拷贝时因为名字不再以.hbs结尾,会被正常复制,但如果你用的是某些框架的 glob 库,它默认会忽略点文件,导致生成的仓库里没有.gitignore。我在fs-extra的遍历逻辑里使用了dot: true的配置,确保隐藏文件也能被遍历到。
第三个是路径分隔符问题。绝不能直接在代码里写死/来拼接文件路径,否则在 Windows 上会返回一个无法访问的路径。所有路径操作都要用path.join和path.relative,这是新手最容易忽略的。
3.4 文件复制与特殊文件保护
模板目录到目标目录的复制,我封装了一个递归遍历函数:
async function copyDirectory( sourceDir: string, targetDir: string, data: Record<string, unknown> ) { const entries = await fs.readdir(sourceDir, { withFileTypes: true, dot: true }); for (const entry of entries) { if (entry.name === ".git" || entry.name === "node_modules") { continue; } const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await copyDirectory(sourcePath, targetPath, data); } else if (entry.name.endsWith(".hbs")) { const targetFilePath = targetPath.slice(0, -4); await renderTemplateFile(sourcePath, targetFilePath, data); } else { await fs.ensureDir(path.dirname(targetPath)); await fs.copy(sourcePath, targetPath); } } }这个实现里我主动排除了.git和node_modules,避免用户把模板仓库自己的版本信息带进新项目。还有一个保护逻辑是:如果目标目录已经存在并且非空,命令会先询问用户是否要合并或覆盖,加了--force才会静默覆盖。这个保护非常必要,因为fs.remove一旦执行是不可逆的,我见过不少人手滑把整个项目目录删掉,所以t3code默认在所有破坏性操作前都会二次确认。
4. 实操演练:用 t3code 生成一个完整项目
4.1 本地调试:npm link 的正确姿势
工具写完后不能直接发布 npm 再测试,那样迭代太慢了。我用的方法是npm link,在项目根目录执行:
npm run build npm link这条命令会把当前包链接到全局 node_modules 里,之后在任意目录执行t3code都会调用本地开发版本。开发期间我反复执行npm run build && t3code create basic-web test-output,配合--force覆盖旧输出目录,调试效率很高。
有一个小技巧:如果你修改了代码但没重新编译,执行t3code时仍然是旧的 dist 代码。所以我通常在package.json里加一个 dev 脚本,先用tsc --watch监听 TypeScript 变更并自动编译,再开一个终端执行命令测试,这样就能做到改代码即改行为。
注意:
npm link在 Windows 上偶尔会因为权限问题失败,遇到 EPERM 错误时用管理员权限打开终端再试。
4.2 命令行交互全过程
实际用t3code生成项目的过程长这样:
$ t3code create basic-web my-project ✔ 项目名称(会用作 package.json 的 name 和目录名) · my-project ✔ 作者名称 · lencx ✔ 是否需要生成 Dockerfile? · false 正在生成项目骨架... 模板复制完成,共生成 24 个文件。 接下来你可以执行: cd my-project npm install npm run dev整个过程不到 3 秒。生成后的目录结构就是一个完全可运行的前端工程,不需要手动改任何配置。这也是我当初设计时的目标:生成结果必须开箱即用,如果生成完还要手工修修补补,这个工具就没意义了。
4.3 生成结果验证
我习惯生成后马上做三件事来验证:
- 检查关键变量是否替换正确,比如
package.json里的name字段是不是my-project而不是模板里的占位符。 - 检查
.gitignore是否存在,并且里面没有node_modules这个目录被意外忽略导致装不上依赖。 - 直接
npm install && npm run build看能否一步通过。
有一次生成后构建失败,排查了很久发现是模板里的 TypeScript 版本锁定在了一个过期版本,和新生成的代码不兼容。从那以后我养成了一个习惯:每次改模板内容,都要用t3code实际生成一遍并构建验证,而不是只看模板文件本身觉得没问题就不测了。
5. 常见问题排查与避坑指南
5.1 高频问题速查表
| 表现 | 原因 | 解决方案 |
|---|---|---|
生成的.gitignore不存在 | 遍历时过滤了隐藏文件 | 确保遍历逻辑包含dot: true |
| 生成的图片或字体文件损坏 | 把二进制内容当成 utf-8 渲染了 | 对非文本后缀的文件直接fs.copy |
| 在 Windows 上路径解析失败 | 代码里写死了/路径分隔符 | 所有路径操作使用path.join |
模板变量替换后仍然显示{{ }} | .hbs文件没被渲染就复制过去了 | 检查文件后缀是否为.hbs,且渲染后去掉后缀 |
npm link执行后命令不存在 | 没有先执行npm run build或 PATH 未刷新 | 重新编译并重开终端 |
5.2 三个我从实际使用中踩过的坑
第一个坑是模板里的注释被渲染到了生成文件里。如果你用<!-- {{projectName}} -->写注释,handlebars 会原样渲染,导致项目里出现一个奇怪的注释。正确做法是使用{{! 注释内容 }}语法让 handlebars 彻底忽略这段文字。
第二个坑是meta.json里定义的变量默认值没有做合法性校验。比如用户输入了一个带有中文和空格的projectName,生成出来的package.json直接不合法。后来我加了正则校验,只允许字母、数字、中划线和下划线,并在用户输入非法值时报错要求重输。
第三个坑是关于模板更新的。很多人把模板做好之后就再也不更新,导致三个月后生成的项目里全是过期依赖和旧版 API。我现在每次生成新项目都会顺手更新对应的模板文件,把项目里新增的配置反向同步回模板,形成闭环。这个维护习惯比工具本身更重要。
5.3 后续可扩展的方向
t3code目前还是一个面向本地的轻量工具,但这套设计思路完全可以向外延伸:
- 支持从 Git 仓库拉取模板,这样团队可以把模板放在私有仓库里统一维护,
t3code负责拉取、渲染和生成。 - 支持生成后自动执行勾选的命令,比如
npm install和git init,真正做到一条命令完成从零到可开发状态。 - 支持在模板里声明文件操作动作,比如“删除某些文件”“重命名某些文件”,让模板更加灵活。
目前最值得做的是第一点,也就是远程模板仓库。本地模板适合个人使用,但团队协作场景下,模板放在 Git 仓库里才能被多人维护和版本化。
我在实际使用t3code这几个月里,最大的感受不是生成速度变快了,而是每次开新项目时的心理阻力变小了。以前想到要重新配一堆文件就拖延,现在一条命令跑完,直接进入写业务逻辑的状态。最后分享一个小技巧:给你的默认模板里预置一份标准的 README 模板,包含技术栈说明、启动命令、部署方式和常见问题四个小节,这样每次生成项目都天然带着文档骨架,长期下来团队里的项目文档水平会有明显提升。