从复制粘贴到类型安全:t3code 如何构建标准化脚手架流水线 t3code 这个名字真正定下来是因为那个下午我同时开着六个终端窗口在给部门里第六个 Node 服务做初始化。六个服务看起来长得都差不多实际上没有两个的配置完全一致ESLint 规则的版本差着两个大版本、tsconfig 有的开了 strict、有的没开、日志格式从 JSON 到纯文本乱成一锅粥。最讽刺的是我在第七个服务里又看到了三年前那批带着过时依赖的模板代码。就是那一刻我意识到我们缺的不是一个“新建文件夹”的脚本而是一套能把工程初始化这件事标准化、类型化、可验证的流水线。t3code 就是在这个背景下出现的内部工具。这个代号有两层含义一是它坚持 TypeScript-first 的工程基线二是它把 template、transform、typecheck 三条原语作为整个工具的核心动作。如果你也在维护多个内部项目或者正在纠结要不要自己写一个脚手架这篇东西会记录完整的架构思路、核心实现细节以及从能用到好用之间必须踩过的那些坑。1. 为什么会有 t3code从复制粘贴式初始化到脚手架流水线1.1 复制粘贴式初始化的隐性成本复制粘贴初始化项目这件事短期看起来效率极高。从老服务里把 package.json、tsconfig.json、eslint.config.js 拖过来改一改包名马上就能跑起来。但问题出在“改一改”这三个字上每复制一次总会有几个字段被留在上一个项目的语境里。比如 private、name、依赖版本和脚本路径这些东西在只有一个服务的时候都是对的一旦多出六七个服务差异就开始滚雪球。我说一个真实观察团队里有三个服务都号称“严格遵守 ESLint 规则”但它们的 eslint 配置一个是.eslintrc风格、一个是eslint.config.js风格还有一个直接在 package.json 里裸写 rules。最麻烦的是每个服务对应的 rule 版本都不一样同一段代码在这个仓库报警、在那个仓库不报。新成员入职之后根本不知道该以哪个仓库为基线最后只能以自己入职时看到的那个为准这其实就是隐性技术债的开端。这类债务不会出现在监控指标里但会体现在每次升级依赖、每次统一日志格式、每次迁移构建工具的时候。你每多做一次“把 A 服务的配置同步到 B 服务”的操作就是在偿还一次利息。t3code 出现之前我和团队已经这么还了两年利息。1.2 t3code 的定位template、transform、typecheck 三条原语t3code 不打算做一个无所不包的生成器。它的核心定位是三个动作的流水线template模板是事实的来源所有标准配置只维护一份放在仓库的 templates 目录里。transform生成时必须允许插值、条件片段、文件级转换而不是简单的字符串 copy。typecheck生成之后的产物必须通过一次真实的类型检查把错误挡在提交代码之前。三条原语连起来就是一条完整的链路读模板、套变量、写文件、跑检查。这是我后来觉得 t3code 这个名字贴切的原因t3 恰好同时是 TypeScript-first 的简称也是这三条原语的首字母集合。整个工具的走势也证明了这个划分是对的后续新增的功能几乎都能归到这三条原语之一。1.3 为什么不直接白嫖社区脚手架一定会有人问社区的 create-t3-app、create-next-app 之类的脚手架那么成熟为什么不直接用。原因是它们解决的是“创建一个标准的社区项目”的问题而内部开发需要的是“创建一个符合团队私有规范的项目”的问题。团队的私有规范包括内网镜像地址、统一的日志拦截器、特定的 CI 流程这些没法通过参数化一个社区脚手架实现而且社区脚手架的升级节奏我们也控制不了。如果只是把社区模板 fork 一份维护成本也扛不住上游每次大更新都要同步fork 之间还会出现分叉。t3code 从一开始就设计成面向自己的模板体系社区工具可以补位解决“项目初始化后的深层配置”但它不应该成为唯一依靠。2. 整体架构设计一个微型模板引擎加一个命令调度器2.1 仓库目录结构t3code 本身的仓库结构没有用 monorepo因为一个 CLI 工具在相当长一段时间里都没有拆分包的诉求。保持简单反而更容易维护。实际目录如下t3code/ ├── bin/ │ └── t3code.ts # CLI 入口负责解析命令和参数 ├── src/ │ ├── commands/ # init/add/doctor/upgrade 四个命令 │ │ ├── init.ts │ │ ├── add.ts │ │ ├── doctor.ts │ │ └── upgrade.ts │ ├── engine/ # 模板渲染引擎 │ │ ├── render.ts │ │ ├── parse.ts │ │ └── ignore.ts │ ├── schema/ # zod schema 与类型导出 │ │ └── answers.ts │ └── utils/ # 路径、日志、进程工具 ├── templates/ │ ├── service-api/ │ │ ├── template.json │ │ └── files/... │ └── lib/ │ ├── template.json │ └── files/... ├── tests/ │ ├── fixtures/ # 测试专用的最小模板 │ └── render.test.ts ├── package.json └── tsconfig.json命令层用 commander 解析交互层用 prompts。为什么要这两个而不是自己手写参数解析因为参数解析的问题是已经被解决过的问题我不需要在引号、转义、缺省值这些细节上重新犯错。2.2 命令层init、add、doctor、upgrade四个命令对应四个高频场景init用指定模板新建项目。最核心的路径。add向已有项目追加模板片段比如给老服务补一个 eslint 配置。doctor检查当前环境、模板完整性、已有项目和模板的漂移程度。upgrade升级 t3code 自身以及模板缓存。命令拆分的原则是初始化、增量变更、体检、更新这四件事的失败模式完全不同。init 失败可以扔掉重来add 失败却可能破坏一个正在运行的项目。把命令拆开之后每一段的错误处理都可以更专注。2.3 模板注册表与元数据每个模板目录下必须有一个 template.json它是对模板自身的描述。这个文件我坚持用普通 JSON 而不是 JS/TS 文件理由是元数据必须可以被任何语言解析将来如果要做一个 Web 端的模板预览JSON 是最没有成本的格式。template.json 的字段设计如下{ name: service-api, version: 1.2.0, description: 标准 API 服务的初始化模板, vars: { projectName: { type: string, required: true }, strict: { type: boolean, default: true }, nodeVersion: { type: string, enum: [18, 20] } }, ignore: [files/node_modules, files/dist] }vars 字段其实就是变量的声明。它和后面 zod schema 之间是一一对应的关系模板元数据描述“有哪些变量、大概是什么类型”zod schema 负责在运行时真正执行校验。两者分开的理由会在第四章展开。3. 模板渲染硬核细节目录遍历、插值引擎与文件保护3.1 渲染引擎的选型与取舍在决定渲染引擎之前我把主流的方案过了一遍。mustache 语法简单、心智负担低但逻辑能力偏弱做条件判断和循环还可以一旦要在渲染期动态拼路径、动态决定哪几个文件参与输出就会很吃力。handlebars 能力强几乎什么都能干但它把太多东西放进了模板模板会变得越来越不像“数据”更像一份可执行代码这和维护性相悖。最终我选择手写一个小型插值引擎只支持三种语法变量插值{{ var }}、条件块{{#if var}}...{{/if}}、循环块{{#each list}}...{{/each}}。这三个语法覆盖了脚手架 95% 的需求剩下的用 transform 原语去做也就是在模板里写一个 hook渲染完成后对指定文件做一次程序化修改。把“模板表达能力不够”的问题从模板层抽到代码层是我在这个项目里做过的最重要的取舍之一。3.2 目录遍历与文件属性保持渲染一个目录表面上是递归读文件、写文件实际操作时要处理不少细节。这里的 fs 是node:fs/promises的命名空间导入。renderDirectory 核心逻辑大概是这样import { promises as fs } from node:fs; export async function renderDirectory( srcDir: string, targetDir: string, context: RenderContext ): Promisevoid { const entries await fs.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { const srcPath path.join(srcDir, entry.name); const relative path.relative(srcDir, srcPath); if (isIgnored(relative, context.ignore)) continue; if (entry.isDirectory()) { await renderDirectory(srcPath, path.join(targetDir, relative), context); } else { await renderFile(srcPath, path.join(targetDir, relative), context); } } }看起来平淡无奇真正的坑在于文件权限。很多模板里会放一个 deploy.sh它需要 755 权限。如果渲染逻辑只是 readFile 再 writeFile权限直接丢了。解决方法是先 stat 一下源文件的 mode写入后 chmod 恢复。这个小细节我第一次完全没考虑直到在 Linux 上生成的项目里跑 deploy.sh 出现 Permission denied 才意识到。另一个细节是输出目录的清理策略。init 到一个非空目录时我默认不覆盖已存在的文件而是把冲突文件列表打印出来让用户选择备份或跳过。这个做法比直接覆盖温和也比询问“是否删除整个目录”安全。3.3 二进制文件与 dotfile 的守护这是模板渲染里最容易被忽略的一关。模板目录里可能会有 .png、.ico、.woff2 之类的二进制资源如果都按文本读进来再 UTF-8 编码写出去文件直接损坏。我的检测策略是读取文件头部 512 字节如果发现空字节就判定为二进制function isBinary(buf: Buffer): boolean { return buf.subarray(0, 512).indexOf(0) ! -1; }这个方法简单可靠文本文件在头部出现空字节的概率几乎为零。二进制文件直接走 stream copy不渲染。dotfile 的问题更隐蔽。模板里的.gitignore.tpl是一个必须渲染的文件因为它里面有{{ projectName }}这样的变量但渲染完成后它要改名为.gitignore。如果模板里直接放.gitignoreGit 会试图跟踪它模板仓库的版本控制就会变得混乱。我的约定是所有需要改名且以点开头的文件统一加.tpl后缀渲染时剥掉后缀。3.4 从渲染环境到输出目录一次完整的渲染管线把上面这些串起来一次 init 的实际流程是解析参数和交互回答得到经过 zod 校验的变量对象扫描模板目录按 ignore 规则过滤递归遍历每个文件判断 binary 还是 texttext 文件按扩展名和内容特征决定是否插值最后统一刷新文件权限剥掉 .tpl 后缀生成 summary。summary 会列出生成的文件列表和后续还需要手动完成的步骤很多人觉得这个输出多余但它其实是在给使用者一个明确的“下一步”锚点。4. 把变量和类型安全焊在一起zod schema 驱动的配置链路4.1 变量从哪来交互问答、命令行参数、配置文件模板需要的数据无非三个来源命令行参数、交互式问答、放在项目里的配置文件。t3code 的处理顺序是先用命令行参数提供默认值再交给交互层补齐缺失字段最后把结果交给 zod 做整体校验。为什么交互层不能直接跳过因为绝大多数使用者并不记得模板有哪些字段需要填交互一是补齐信息二是顺便起到“引导使用者理解模板”的作用。交互问答是动态生成的。template.json 里的 vars 声明决定 prompts 要展示哪些问题如果命令行参数已经给出某个值对应的问题就不出现。这样既支持t3code init service-api --projectName my-api这样的无交互用法也支持什么都不带直接回车。4.2 zod schema 作为唯一事实来源这一步是 t3code 和普通“复制文件工具”拉开差距的地方。template.json 里的 vars 只是声明真正执行校验的是代码里的 zod schemaimport { z } from zod; export const serviceApiSchema z.object({ projectName: z .string() .regex(/^[a-z][a-z0-9-]*$/, 项目名只能包含小写字母、数字和短横线), strict: z.boolean().default(true), nodeVersion: z.enum([18, 20]).default(20), }); export type ServiceApiAnswers z.infertypeof serviceApiSchema;把 schema 从模板元数据里抽出来放进代码一开始看起来像重复劳动实际是必要的。模板元数据要被渲染引擎读取它不能依赖 TypeScript 的运行时schema 却需要强类型推导它必须活在 TS 的世界里。两者之间用一份变量声明同步是可行的我用的方法是写一个测试用例遍历所有模板把 template.json 里的 vars 和对应的 zod schema 逐个比对字段名和类型不一致直接报错。4.3 类型安全的传递链路与模板内联类型校验通过之后变量对象会在整个渲染过程中以ServiceApiAnswers类型传递这意味着模板里写context.projectName时编辑器有自动补全写错字段名在编译期就会暴露。对模板作者来说这是体验上的质变以前是在生成后才能发现变量名拼错现在是写模板的时候就发现了。更进一步的类型安全体现在模板内容上。例如 Node 版本这个变量在模板里会生成一个.nvmrc文件内容是v20。如果模板只写{{ nodeVersion }}这个值的合法性完全依赖渲染前的校验一旦校验逻辑出 bug 就可能生成一个不存在的 Node 版本。我倾向于在模板里额外做一道约束用条件块判断 nodeVersion 是 18 还是 20分别生成对应内容宁可让模板多两行也要把非法值挡在文件系统之外。5. typecheck 闸门生成之后的第一次体检与常见失败5.1 为什么脚手架必须内置类型校验t3code 生成的项目至少要达到一个底线安装依赖之后tsc --noEmit必须能过。如果连这个底线都保证不了“脚手架”就退化成“文件复制器”生成的东西能不能跑完全看运气。把 typecheck 放进脚手架流程等于把所有模板的合法性检查前置任何模板改动都必须先让所有基于它的项目通过类型检查才能合入模板仓库。这个闸门还有一个额外收益它逼着模板作者把依赖版本写死。如果模板里typescript写的是宽松范围比如^5.4.0今天生成的项目的类型结果可能明天就变了typecheck 就失去了确定性。5.2 实现细节调用本地 tsc 而不是全局命令执行类型检查时最容易踩的坑是调全局 tsc。全局安装的 TypeScript 版本和项目本地版本可能差很多用全局 tsc 校验本地项目完全没有代表性。正确做法是在生成的项目目录里解析本地 typescriptimport { execFile } from node:child_process; import { promisify } from node:util; import { createRequire } from node:module; import path from node:path; const require createRequire(path.resolve(targetDir, package.json)); const tscBin require.resolve(typescript/bin/tsc); const execFileAsync promisify(execFile); const { stdout, stderr } await execFileAsync( process.execPath, [tscBin, --noEmit, --pretty, false], { cwd: targetDir } ); const output stdout stderr; if (output.trim()) { reportTypeErrors(output, targetDir); }为什么用process.execPath跑 tsc因为 bin 脚本本质上是一个 Node 文件直接用 node 执行它就是最稳的方式也可以避免一些系统上 bin 链接配置的问题。check 之后如果输出不为空把错误按文件分组输出并给出常见的修复提示。我刻意没有做“自动修复”因为自动修改生成的代码既危险又不可控tsc 的错误信息已经足够人类工程师定位问题脚手架的职责是尽早暴露问题而不是替工程师决定怎么改。5.3 常见失败与自动修复尝试实际跑过那么多模板之后typecheck 失败集中在几类strict 模式下对可能为 null 的值做运算、某个 import 的路径大小写写错、新版本 TypeScript 对某些 API 的默认类型收窄。前两类是模板本身的问题改模板就好。第三类是依赖抖动这部分到第六章详细说它背后的根因不是模板错了而是依赖版本没有锁住。对于第一类失败一个可用的缓解策略是模板里统一使用satisfies和显式类型注解让潜在问题在模板编译阶段就暴露。这不能根治问题但它能把“生成一个坏项目”的概率压到更低。6. 从能用到好用之间隔着这些坑6.1 Windows 路径与 CRLF一次跨平台故障的完整排查t3code 早期基本只有我在用跑在 macOS 上。直到一个 Windows 同事试用后发来截图生成的配置文件里路径变成了D:\repo\service-api\src\config然后.env文件每行结尾带着奇怪的^M。排查的第一步是复现。我在 Windows 虚拟机上跑了一遍同样的 init确认问题稳定存在。第二步是定位范围。我分别测试了“从模板读取文件后直接写入”“中间经过插值引擎”“插值且变量包含路径”三种场景最终确认问题出在插值阶段从命令行传入的 Windows 路径会带反斜杠模板里用{{ projectDir }}渲染后反斜杠在部分上下文里被解释成转义符产生了乱码。至于^M则是模板文件本身以 CRLF 保存读取后我按 UTF-8 原样写入Windows 环境下 Git 又自动加了 CRLF导致双重转换。修复方案分两层插值引擎对传入的字符串统一做一次路径规范化把反斜杠转成正斜杠所有文本文件写入前统一把换行符规范化为\n并借助.gitattributes里的text eollf约定把模板仓库的换行符锁死。这之后 Windows 上生成的代码行为才完全和 macOS 一致。这个坑教会我的不是“怎么处理路径”而是“跨平台工具必须让核心渲染管线与操作系统无关”。路径是操作系统的方言方言应该在边界处被翻译而不是在管道里到处穿。6.2 占位符被渲染函数误伤变量命名引发的连锁反应第二个坑更难发现。有一天我用 t3code 生成一个内部管理后台模板里有一行 Vue 代码span{{ userName }}/span。渲染完一看userName 变成了空字符串。我第一反应是变量没传进去排查了很久才意识到问题根本不是变量缺失而是自己的插值引擎把模板里本来就存在的 Vue 插值语法{{ userName }}当成 t3code 的变量占位符执行了替换而渲染环境里根本没有 userName 这个变量于是它被替换成了空。这个问题的本质是占位符定界符冲突。任何一门模板语言都可能引入{{ }}语法你的脚手架只要生成 Vue、Angular、Handlebars 相关的代码迟早会撞上。我的解决方案是给插值引擎增加一层转义语义\{{表示字面量{{渲染时保留原样同时把渲染结果标记为“已渲染”禁止对已渲染的文本再次执行插值。这条规则虽然简单但当时是在踩过坑之后才补上的。如果让我重新设计一次我会在一开始就把定界符改成不那么容易冲突的[[ ]]而不是沿用 mustache 风格的{{ }}。兼容社区习惯带来了一点便利却换来了和下游模板语法长期共存的风险这个账其实是不划算的。6.3 依赖抖动本地的绿灯CI 的红灯typecheck 集成之后有一段时间我在本地怎么跑都是绿的CI 却时不时飘红。最开始以为又是 Windows 的问题直到我在 CI 日志里看到typescript每次解析到的版本都不一样才反应过来模板里的依赖声明写的是^5.4.0这类范围版本每次 install 都会重新解析最新小版本TypeScript 的小版本升级偶尔会带来更严格的类型检查于是同一个模板今天能过、明天就可能不能过。排查和修复的过程不复杂模板里所有关键依赖改成精确版本或~范围提交 lockfileCI 使用pnpm install --frozen-lockfile。真正值得反思的是为什么这个问题拖了这么久才暴露因为我给 t3code 的测试用例都是在本地跑本地 node_modules 早就锁死了版本根本不会重现“全新环境安装”的场景。后来我在 CI 上加了一个每晚执行的“从零生成并 typecheck”的任务才真正把这类抖动挡在合并之前。6.4 测试策略用 fixture 而不是拿真实业务当小白鼠t3code 的测试大概可以分成三层。第一层是渲染引擎的单元测试覆盖插值、条件、循环、转义、二进制保护这些纯函数。第二层是命令层的集成测试在临时目录里跑完整的 init然后对照快照检查生成文件的路径、内容和权限。第三层是跨平台矩阵在 GitHub Actions 的 ubuntu、macos、windows 三个 runner 上跑同一套测试用例。fixture 模板和真实模板必须分开。真实模板会随业务演进拿来当测试基线会让测试跟着模板一起变最终退化成“确认现状没变”。fixture 模板小而稳定只服务测试这样引擎的测试结果才可靠。也是因为有了这一层 fixture后面改渲染引擎时才敢做重构。7. 从个人工具到团队基建t3code 的成长路线7.1 离线缓存与自动升级t3code 作为内部工具一开始没有考虑离线环境。但内网开发环境经常在一个隔离网络里npm install 需要走内部镜像。为此我在模板目录里增加了一个 lock 文件记录模板期望的镜像地址和依赖版本组合init 之后自动改写 package.json 里的 registry 配置。这一步看似不起眼实际上让内网同事的初始化时间从十几分钟降到几十秒。自动升级用的是一个简单的版本检查每次 init 前请求一次模板注册表接口把本地模板版本和远程版本做对比有新版就提示。我把这个检查的开关默认设为关闭原因是内网环境不是都有公网出口把一个可选的网络请求做成强制会得罪所有人。7.2 自己吃自己的狗粮Dogfooding 对脚手架工具的验证效果比任何测试都强。我把 t3code 本身也纳入模板管理每次从模板生成一个最小 CLI 项目然后在这个项目上继续加功能。这个循环能强制发现模板里的所有问题如果一个模板连它自己的作者都无法流畅使用那说明模板里还有未消化的复杂度。7.3 下一步从代码工程到代码规范如果 t3code 继续演进我期望的方向是 AST 级改造。目前的 transform 原语还是基于字符串替换它能做 90% 的工作但对“统一 import 顺序”“强制导出一个入口函数”这类结构化改造很吃力。AST 级 transform 会让模板深入很多但复杂度也会显著上升。现阶段我更倾向于保持三条原语的简单性把复杂模板拆成分层文件而不是把一个模板做成一个万能机器。最后分享一个我在实际维护中一直坚持的小习惯每次给 t3code 增加一个新能力先要求它“生成后的项目必须能在两分钟内被新同事看懂”做不到就把能力砍掉。脚手架不是给自己秀操作的地方它服务的对象永远是下一个打开代码仓库的人。这个标准听起来模糊但它帮我挡掉过不少华而不实的功能提案。