t3code 这个名字,一开始只是我在本地目录里随手建的文件夹名。第三次重写同一个脚手架工具的时候,我决定把它收拾成一个能见人的开源项目,于是顺手把这个早就看顺眼的代号留了下来。t3code 要解决的痛点很实际:每次新起一个后端服务、新写一个 React 组件,都要把上一个项目里的样板文件复制过来,再手动改一堆名字,既慢又容易漏。这篇文章就把这个工具从设计到发布的全过程拆开讲一遍,适合想自己做 CLI 工具、想把手头脚本变成开源项目的朋友。我会尽量把每个设计决策的“为什么”也讲清楚,不只是贴代码。
1. 缘起:为什么会有 t3code,第三次重写的由来
1.1 最初的需求:复制模板这件事,远比想象中麻烦
做技术的人基本都有这种经历:项目里总有那么一批“差不多长得一样”的文件。比如新加一个后端 API 模块,要写 router、service、controller、DTO、测试文件,每个文件里的类名、导入路径、路由前缀都不一样。我最早的做法是打开旧模块,全选、复制、粘贴,然后全局替换名字。这个流程前两次还行,等到项目模块超过二十个,问题就出来了:替换不干净导致报错,有的文件忘了改,还有的模块结构已经和旧模板不一致,复制过来之后得手工增删好几段代码。
t3code 最开始就是一个本地脚本,只有一个功能:给一个模板目录里的全部文件做变量替换。后来用着用着,我加上了交互式提问、模板列表、校验规则、跨平台路径处理,脚本膨胀到几百行,才开始认真考虑它应该长成什么样。第一版和第二版都是纯 JavaScript 写的,功能能跑,但代码结构一团糟。重写第三次的时候,我把项目命名为 t3code,一半意思是“third time code”,另一半意思是“template to code”,算是给自己提个醒:别急着上来就写功能,先想清楚这个工具到底服务谁。
1.2 适用场景和边界
如果你也在维护一个多模块项目,或者经常需要基于固定结构生成代码,t3code 这类工具就很合适。它和常见的工程脚手架(比如各大 CLI 工具生成整个项目)的区别在于:我不需要把你电脑上所有项目都统一成一种结构,你只需要为自己的团队、自己的项目定义一个模板。模板是人写的,放文件夹里就行,t3code 负责把模板变成“真实代码”。
它不适合做什么?不适合替你做复杂的代码生成逻辑,比如根据数据库表结构自动生成 CRUD。那种场景需要更重的代码生成器,模板引擎只是其中一个环节。t3code 的定位很朴素:把“带占位符的文件”变成“带真实变量的文件”,把目录结构也一并处理好。
2. 技术选型:TypeScript + Node 内置模块,够用就行
2.1 为什么不用 shell 脚本,也不用 Python
写这种工具,第一反应往往是 shell 脚本,但 shell 处理字符串替换和交互式输入都比较别扭,尤其是跨平台的 Windows 兼容性。Python 也不差,但团队里不是每个人都装了 Python,而且打包分发要额外考虑。我最终选了 Node.js + TypeScript,原因很实际:团队成员都装有 Node,直接npm install -g t3code就能用;TypeScript 的类型系统让我在重构第三版的时候少掉不少头发;Node 18 以上的内置readline/promises已经能做交互式提问,完全不需要为了一个提问功能引入 inquirer。
选依赖时我给自己定了一条原则:能不用第三方库就不用。最后整个工具只依赖一个commander用来解析子命令。后来我甚至觉得 commander 也可以不要,但考虑到t3code new、t3code list、t3code init这几个子命令的参数解析、帮助信息、错误提示,自己手写反而容易出边界问题,就保留了。
2.2 项目结构设计
第三版的项目结构如下:
t3code/ ├── package.json ├── tsconfig.json ├── templates/ │ ├── api-module/ │ │ ├── template.json │ │ ├── {{name}}.service.ts │ │ ├── {{name}}.controller.ts │ │ └── __tests__/ │ │ └── {{name}}.test.ts │ └── react-component/ │ ├── template.json │ └── {{name}}.tsx └── src/ ├── cli.ts # 入口,解析命令 ├── commands/ │ ├── init.ts # 把内置模板复制到用户目录 │ ├── list.ts # 列出所有可用模板 │ └── new.ts # 根据模板输出文件 ├── core/ │ ├── template.ts # 扫描模板目录,读取 template.json │ ├── render.ts # 变量替换 + 文件复制 │ └── fs-utils.ts # 统一的文件系统操作 └── types.ts # 类型定义这里有个关键设计:模板仓库可以放在用户目录~/.t3code/templates,也可以放在当前项目的.t3code/templates里。运行t3code init会把内置模板复制到用户目录,后续你改成自己的模板也不会被工具更新覆盖。这样用户自己的积累能保留,工具升级不会把配置冲掉。
3. 三个核心模块:模板发现、渲染、交互输入
3.1 模板描述文件 template.json
每个模板目录下都会放一个template.json,它决定 t3code 向用户提哪些问题。schema 很简单,我直接贴一个示例:
{ "name": "api-module", "description": "生成一个带基础路由与测试的 API 模块", "variables": [ { "name": "moduleName", "message": "模块名(首字母大写)", "default": "Hello", "validate": "^[A-Z][a-zA-Z0-9]*$" }, { "name": "author", "message": "作者", "default": "t3code" } ] }variables是核心。每个变量都有 name、message、default、validate 四个字段。name 是模板文件里{{moduleName}}这种占位符的名字;message 是交互式提问显示给用户的问题;default 是回车时的默认值;validate 是正则,不匹配就重新问。
选择这种 JSON 描述方式,而不是在代码里写死提问逻辑,是为了让非编程人员也能给团队贡献模板。目录里放什么文件、占位符叫什么名字,模板作者可以完全掌控,不需要懂 TypeScript。
3.2 模板发现:约定优于配置
模板发现的规则很简单:扫描~/.t3code/templates和当前项目.t3code/templates下的所有直接子目录,每个目录里必须有template.json。目录名就是模板名,比如api-module。这样t3code list就能直接展示可用模板。完整逻辑如下:
import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; export interface TemplateInfo { name: string; description: string; path: string; } const USER_TEMPLATE_DIR = path.join(os.homedir(), '.t3code', 'templates'); async function scanTemplates(dir: string): Promise<TemplateInfo[]> { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return []; } const result: TemplateInfo[] = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const templatePath = path.join(dir, entry.name); const configPath = path.join(templatePath, 'template.json'); try { const config = JSON.parse(await fs.readFile(configPath, 'utf8')); result.push({ name: entry.name, description: config.description ?? '', path: templatePath }); } catch { // 没有 template.json 的目录,跳过 } } return result; } export async function getTemplates(): Promise<TemplateInfo[]> { return [ ...(await scanTemplates(USER_TEMPLATE_DIR)), ...(await scanTemplates(path.join(process.cwd(), '.t3code', 'templates'))) ]; }项目本地目录的模板优先级更高,这样每个仓库可以自定义自己的模板,不会受全局模板干扰。
3.3 渲染与复制:小心处理每个文件
渲染模块是整个工具的心脏,但核心代码反而不长:
import fs from 'node:fs/promises'; import path from 'node:path'; function renderString(template: string, vars: Record<string, string>): string { return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, key: string) => { return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match; }); } async function copyTemplate(srcDir: string, destDir: string, vars: Record<string, string>): Promise<void> { const entries = await fs.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(srcDir, entry.name); // 文件名的占位符也要替换 const renderedFileName = renderString(entry.name, vars); const destPath = path.join(destDir, renderedFileName); if (entry.isDirectory()) { await fs.mkdir(destPath, { recursive: true }); await copyTemplate(srcPath, destPath, vars); } else if (entry.isFile()) { const content = await fs.readFile(srcPath, 'utf8'); await fs.writeFile(destPath, renderString(content, vars), 'utf8'); } } }注意renderString里占位符匹配不到变量时,我会保留原样,而不是替换成空字符串。这个小决策很重要:如果模板文件本身包含{{这种字符(比如某些前端框架的模板语法),t3code 不会误删它们。我自己见过一些模板引擎遇到不认识的占位符直接报错或清空,那种设计对代码生成场景来说太危险。
3.4 交互式输入:用 Node 内置能力实现
既然目标是少依赖,交互式输入用的是 Node 17.3 之后内置的readline/promises。核心流程是遍历template.json里的 variables,逐个提问,校验不通过就重新问:
import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; async function askVariables(config: TemplateConfig): Promise<Record<string, string>> { const rl = readline.createInterface({ input, output }); const answers: Record<string, string> = {}; try { for (const variable of config.variables) { while (true) { const hint = variable.default ? `(默认 ${variable.default})` : '(必填)'; const answer = (await rl.question(`${variable.message} ${hint}: `)).trim(); const value = answer === '' && variable.default !== undefined ? variable.default : answer; if (variable.validate && !new RegExp(variable.validate).test(value)) { output.write(`输入不合法,要求匹配:${variable.validate}\n`); continue; } answers[variable.name] = value; break; } } } finally { rl.close(); } return answers; }这段代码里有几个细节值得说明。一是finally里关闭 readline 接口,避免进程挂住不能退出。二是校验不通过时,我把正则原样打印出来,不额外解释,因为正则本身就是最精确的说明。三是必填项在实现上是把 default 设为空,用户在提示里看到“必填”,如果直接回车,值就是空字符串,后续正则校验会拦住。
4. 踩坑记录:跨平台和转义问题的完整排查
4.1 Windows 上的路径分隔符:一个反斜杠引发的错乱
第一版在 macOS 上跑得很欢,拿到 Windows 上直接崩。崩溃原因不是代码逻辑,而是模板文件名里的占位符被手写拼接过。当时我用了类似srcDir + '/' + entry.name的写法,在 Windows 上偶尔能跑,一旦模板嵌套目录多,有的路径变成C:\foo\bar/{{name}}.ts,Node 的fs能识别这种混合写法,但后续读取、写入的测试断言就全对不上了。
排查方法很笨:把错误堆栈里涉及的路径逐个打印出来,很快发现问题集中在拼路径的地方。修复方案也简单:所有路径拼接全部换成path.join,另外在测试里加上 Windows 风格的路径用例。如果你也在写跨平台工具,记住一条原则:永远不要手动拼路径分隔符,path.join和path.sep就是干这个的。
4.2 模板里自带 {{ }} 字符,怎么保证不被替换
这个问题比路径坑得多。我在模板里想放一段 Vue 组件的示例代码,里面天然有{{ }},比如:
<span>{{ title }}</span>如果{{ title }}不是 t3code 预设的变量,按我的renderString会原样保留,这是正确的。但问题来了:如果title恰好是用户的变量,比如用户要生成的模块名就叫title,那这一段本来应该是字面量的{{ title }}也会被替换,模板就毁了。
我排查过几次之后,给 render 加了一个“双重花括号转义”约定:模板里想输出字面量{{ title }},就写{{{title}}}或{{{{ title }}}}?这个方案折腾了两天,体验很别扭。最后我选择了更简单的方案:保留变量替换的单一规则,但模板作者需要知道,不要在模板里使用与变量重名的占位符作为字面量。这个限制写在 README 里,实际使用中几乎没有造成困扰,因为模板里真正需要字面量{{ }}的场景远少于预期,真遇到就用变量间接拼一下。
这里我的经验是:模板工具最容易过度设计。第一版我试图支持转义、支持条件判断、支持循环,结果写出来一堆没人用的功能。实际用下来,简单的搜索替换加目录复制,覆盖了九成需求。
4.3 异步写入导致目录还没创建,文件先写失败
这个坑非常隐蔽。最初copyTemplate里遇到目录时,没有先mkdir就直接递归进入,结果 fs 写入深层文件时发现父目录不存在,抛ENOENT。加上{ recursive: true }之后好了,但这又引出另一个问题:如果目标目录已经存在,递归创建会静默成功,旧文件不会被清理,多次运行会留下冗余文件。
最后的处理逻辑是:执行t3code new时,如果目标目录存在且非空,直接拒绝执行,要求用户换成空目录或者加上--force参数。这个决策让我少了很多“生成了,但文件混在一起了”的困扰。校验代码如下:
async function ensureEmptyDir(dir: string, force: boolean): Promise<void> { try { const files = await fs.readdir(dir); if (files.length > 0 && !force) { throw new Error(`目标目录 ${dir} 不为空,使用 --force 强制覆盖`); } } catch (error: any) { if (error.code === 'ENOENT') { await fs.mkdir(dir, { recursive: true }); return; } throw error; } }5. 测试与发布:从本地脚本到开源包
5.1 测试怎么设计:不测花架子,只测核心路径
我见过不少工具项目测试覆盖率拉满,测的全是无关紧要的辅助函数。t3code 的测试思路很简单:把最有价值的三条主路径测透。
- 模板渲染:给定一份带占位符的文件,断言替换后的内容正确。
- 文件夹递归复制:断言嵌套目录和文件名占位符都处理正确。
- 交互式输入:不太好自动化测,所以这一块我用固定变量值绕过提问,直接调用渲染接口。
Node 18 自带node:test测试运行器,所以这部分的依赖也是零。关键测试长这样:
import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { renderString, copyTemplate } from '../src/core/render.js'; test('renderString 替换正确', () => { const output = renderString('hello {{ name }}!', { name: 'world' }); assert.equal(output, 'hello world!'); }); test('renderString 保留未知占位符', () => { const output = renderString('keep {{ unknown }}', { name: 'world' }); assert.equal(output, 'keep {{ unknown }}'); }); test('copyTemplate 递归处理嵌套目录', async (t) => { const src = await fs.mkdtemp(path.join(os.tmpdir(), 't3code-src-')); const dest = await fs.mkdtemp(path.join(os.tmpdir(), 't3code-dest-')); await fs.mkdir(path.join(src, 'src', 'components'), { recursive: true }); await fs.writeFile(path.join(src, '{{name}}.tsx'), 'export const {{name}} = 1;'); await fs.writeFile(path.join(src, 'src', 'components', '{{name}}.test.ts'), 'test {{name}}'); await copyTemplate(src, dest, { name: 'Button' }); const rootFile = await fs.readFile(path.join(dest, 'Button.tsx'), 'utf8'); const nestedFile = await fs.readFile(path.join(dest, 'src', 'components', 'Button.test.ts'), 'utf8'); assert.equal(rootFile, 'export const Button = 1;'); assert.equal(nestedFile, 'test Button'); });测试临时目录用fs.mkdtemp创建,测试结束后自动清理。这块的教训是:不要在项目目录里留测试残留,否则很快会被垃圾文件淹没。
5.2 发布前必须做的三件事
发布 npm 包之前,我踩过几次低级错误。现在固定走这么几步:
npm run build,确认 TypeScript 编译没有错误。npm pack --dry-run,查看即将发布的文件列表,确认没有把src下的源码和测试文件带进去,也没有丢失templates目录。- 在全新目录里
npm install -g ./t3code-*.tgz,用打包产物跑一遍init、list、new三条命令,确认不是本地项目“能用”,而是全局安装后“真能用”。
这几个步骤里最容易忽略的是templates目录。它不参与 TypeScript 编译,如果package.json里的files字段没写对,发布出去的包内容就不全,用户安装之后t3code init会报“找不到模板”的错。我自己就遇到过一次,排查的时候发现本地跑全通过,发布后一测就挂,原因就是files字段漏了templates。
5.3 package.json 的关键配置
直接放一份我当时用的核心配置供参考:
{ "name": "t3code", "version": "1.0.0", "type": "module", "bin": { "t3code": "dist/cli.js" }, "files": [ "dist", "templates", "README.md" ], "scripts": { "build": "tsc", "test": "node --test --test-reporter=spec dist/__tests__/", "prepublishOnly": "npm run build && npm test" }, "engines": { "node": ">=18.0.0" } }注意bin指向的是编译后的dist/cli.js,所以dist/cli.js文件第一行必须是#!/usr/bin/env node,否则全局安装后命令无法执行。engines字段我设成了 18,因为readline/promises在 17 才进入稳定,设成 18 减少兼容性争议。
6. 对模板生态的进一步想法与使用体会
工具跑通之后,我把团队的 API 模块模板、React 组件模板、甚至项目初始化模板都放了进去。两个月用下来,最大的感受是:模板的维护价值比工具本身高得多。t3code 只是一个执行替换的壳,真正决定生成质量的是你放进 templates 目录里的那些文件。我会定期把项目里已经重构过的最新结构同步回模板,让下一个新模块从一开始就长成理想的样子。
对于也想做类似工具的人,我的建议是先别急着写代码。先用两周记录自己重复做的工作,看哪些是最常复制粘贴的模块。t3code 就是我观察了两周之后才下决心重写的。重写过程里,我反复提醒自己一句话:工具是给自己用的,不是给简历写的。所以设计上一直优先“足够简单、真实可用、改动有预期”,而不是追求支持一切可能性。
最后再说一个小技巧:模板目录也纳入版本管理。我把团队公共模板放在一个独立 git 仓库里,t3code 支持模板目录本身就是 git 仓库时,t3code list会额外显示最近提交时间。这样模板有了变更历史,谁改了什么一目了然,回滚也方便。这个功能改动量不大,但对多人协作场景很有用。如果你正在做的模板类工具也支持这种扩展方向,不妨试一试。