资讯动态

t3code:轻量项目脚手架生成器,模板变量一键替换

发布时间:2026/10/26 8:55:14 来源:尧图企业网站定制
做新项目最烦的不是写业务代码而是每次都要重复搭骨架复制上一个项目改包名、删掉用不到的路由、换数据库连接串一不小心还会把旧项目的敏感配置带到新项目里。我后来实在受不了花了一个周末写了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 技术选型对照表选型这件事上我对比过几组方案直接列张表给大家参考能力维度我选用的方案备选方案落选原因命令行参数解析commanderyargs / caccommander 生态最成熟文档也清晰交互式询问promptsinquirerprompts 更轻量输出风格现代不需要维护太多配置模板渲染handlebarsejs / 自定义正则替换handlebars 语法简单非开发者也看得懂约束性强文件操作fs-extra原生 fs / shelljs递归复制和删除目录太方便省大量代码语言TypeScriptJavaScript类型约束能提前发现路径拼接、参数传递错误这里想多说一句为什么不用自定义正则替换。最原始的思路其实是用{{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: Recordstring, 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: Recordstring, 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 模板包含技术栈说明、启动命令、部署方式和常见问题四个小节这样每次生成项目都天然带着文档骨架长期下来团队里的项目文档水平会有明显提升。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑