资讯动态

t3code:面向TypeScript项目的增量代码生成器设计与实践

发布时间:2026/10/26 8:42:26 来源:尧图企业网站定制
复制粘贴是程序员最诚实的习惯但也是最危险的习惯。我写业务代码时最常干的蠢事就是从老项目里拷贝一个组件全局替换变量名然后删删改改最后留下一堆没用的 import、写死的数据结构、和那个“上一个项目遗留的逻辑”。这样干到第三次我终于受不了了。于是有了 t3code 这个项目——一个面向 TypeScript 项目的增量式代码生成器解决的不只是“生成代码”而是“怎么让生成出来的代码少犯错、少返工、真正能合并进现有工程”。t3code 的定位很明确它不是 create-next-app 那样的一次性脚手架而是“随叫随到的代码模块工厂”。你要新增一个 API 路由、一个 React Hook、一个 zod schema、一个 service 层文件直接在终端敲t3code add hook user工具会按照模板把文件创建出来同时自动帮你把路由注册表、类型导出文件、index.ts 里的 barrel export 全部更新掉。它做的是“代码生成 增量联动”两件事。写这篇东西主要是想把 t3code 从 0.1 到 0.9 版本这段路的选型、踩坑、设计取舍完整记下来。无论你是打算自己写一个内部工具还是想评价一下这类“代码生成器到底有没有用”这篇文章应该都能给你一些参考。1. 从“复制粘贴代码”到“增量代码生成器”t3code 的诞生背景1.1 现成脚手架解决不了的问题我原来试过三大类方案。第一类是项目级脚手架比如 create-next-app、npm create vite。这东西只能初始化一个空项目项目跑起来之后它就不管了。第二类是 IDE 的 snippet 或者 vscode 插件这类方案的问题是每个插件有自己的模板格式多人团队里没法统一而且它只负责“把文本塞进去”没有任何校验也不处理“生成之后还要注册路由、导出类型”这种后续动作。第三类是公司内部的代码生成平台通常要连一个后台 web 服务模板存储在远端权限、审批、发布流程都很重个人项目用起来太重了。我真正想要的东西很小本地一个命令输入几个参数就能产出符合团队规范的文件并且把和这个文件相关的其他文件也一起改好。比如生成一个 API endpoint就要自动在 router 注册表加一行生成一个 domain model就要自动在类型出口文件里加一行 export。这些“周边动作”才是复制粘贴最耗时间、最容易漏的地方。t3code 就是冲这个去的。1.2 命名背后的设计原则Type-safe、Three-step、Templatest3code 这个名字三个 T 我分别理解为 Type-safe、Three-step、Templates。Type-safe 指模板参数校验全部用 zod schema 定义生成前先把参数合法性兜住Three-step 指整个工具的心智模型只有三步选模板、给参数、看结果Templates 则是说它本质上是一个模板渲染器 文件系统操作器。这套原则直接决定了功能边界不做在线市场、不做远程模板拉取、不做项目管理后台。t3code 的默认工作场景就是本地仓库模板可以放在项目里templates/目录也可以放在用户主目录~/.t3code/templates作为个人全局模板。之前的教训告诉我工具一旦想管理的东西太多了复杂度就会失控。t3code 宁可让模板存储“朴素”一点也要把“参数校验”和“增量联动”做扎实。1.3 v0.1 的第一次可用版本长什么样第一个能跑通的版本功能非常少一个add命令两个内置模板hook和schema一个基于 ejs 的渲染器没有任何交互式提示参数全部从命令行传入。当时的使用方式是这样t3code add hook User --typeUser它会读取templates/hook文件夹下的hook.ejs和meta.ts用参数渲染出代码然后写到src/hooks/useUser.ts再检查src/hooks/index.ts是否已经导出了useUser如果没有就自动补一行。这个“自动补一行”的功能现在看很普通但在当时确确实实震撼到了我自己——我复制粘贴代码这么多年第一次感受到“代码是被正确生成出来的”而不是“被我从别处搬运过来的”。2. 技术底座与模板语法设计为什么这样选型t3code 的技术栈基本就是 Node.js TypeScript没有引入奇怪的框架。我见过不少工具项目喜欢把所有依赖都换一遍键盘敲得很爽后期维护像在给一堆定时炸弹拆线。这里分享几个关键选型思路。2.1 CLI 框架commander 足够不需要 yargs 的魔法CLI 入口用的 commander 的 11.x 版本。选择理由是稳定、抽象程度低、类型定义清晰。yargs 功能更强但它的“参数中间件”“自动补全”“推荐候选值”能力在 t3code 这种参数数量很少的工具里完全用不上。commander 里我经常用到的就是.command()、.argument()、.option()、.action()这几个方法。program .command(add) .description(根据模板生成一个模块/文件) .argument(template, 模板名称) .argument([name], 生成对象名称如 User) .option(--type type, 数据类型) .option(--dry-run, 只预览不写入) .action(async (template: string, name: string | undefined, opts: AddOptions) { await runAdd(template, name, opts); });有朋友问我为什么不用 Deno 或者 Bun 来跑 CLI实际上 v0.1 确实跑过 Bun 的原生 runtime但最后发布 npm 包时还是选择了 Node 兼容产物。原因很简单目标用户是写 TypeScript 的普通开发者让他们为了一个 CLI 工具先装 Bun 不现实。代码本身是 runtime 无关的最后打包成 CommonJS ESM 双格式Node 16 都能跑。2.2 模板引擎EJS 是那个“够用且容易懂”的选择模板渲染层我调研过 Handlebars、Nunjucks、EJS还认真考虑过自己写一个基于正则替换的迷你模板器。最后的结论是自己写模板引擎是新手最容易犯的错误没有之一。模板引擎看起来简单但真正需要覆盖的条件分支、循环、JS 表达式、转义处理、错误定位每一块都会被实际模板抽得遍体鳞伤。选 EJS 有几个实际理由语法就是 JavaScript团队里任何前端都能零成本看懂模板文件。支持% if %、% %、%- %三种标签我只需要前两种。渲染前可以注入locals配合 zod 的 parse 结果非常舒服。输出错误时能定位到模板行号调试体验远好于正则模板。EJS 也有一个经典痛点如果模板里要渲染“能包含%字符”的代码比如生成一个 JSP 文件或者生成一段包含另一个模板引擎语法的字符串转义会很难受。我这边的解法是做了一个自定义文件扩展名约定以.ejs结尾的参与渲染不以.ejs结尾的原样复制。这样模板作者可以根据需要把“要渲染的”和“不需要渲染的”文件分区不需要跟 EJS 的字符串转义机制死磕。2.3 参数协议zod schema 就是模板的“输入契约”t3code 的参数收集机制不只是--typeUser这样简单地从命令行拿字符串。每一个模板目录下都必须有一个meta.ts文件导出两个东西schema和一个prompts。schema是一个 zod schema定义了生成这个模板需要哪些参数、参数类型、默认值prompts是交互式提问的配置name字段对应参数名message是问题文本。zod 的describe()方法能拿到字段描述这正好可以作为交互式模式下默认问题的说明文字一套 schema 既用于校验、又用于生成交互提问。// templates/hook/meta.ts import { z } from zod; export const schema z.object({ name: z.string() .min(1) .regex(/^[A-Z][A-Za-z0-9]*$/, 名称必须是驼峰格式首字母大写) .describe(Hook 名称例如 User), type: z.string().default(unknown).describe(返回的数据类型例如 User), withLoading: z.boolean().default(false).describe(是否生成 loading 状态), }); export const prompts [ { name: name, message: Hook 叫什么名字 }, { name: type, message: 返回的数据类型是什么 }, ];这里的关键设计是模板作者和模板使用者看到的是同一个协议。使用者不需要读模板源码才能知道应该传什么参数跑一次t3code info hook或者直接看meta.ts就明白了。后续如果要加入远程模板市场这个meta.ts就是模板的 manifest 文件它可以被单独解析用来展示说明、参数表格、依赖要求不需要下载整个模板内容。这个设计让 t3code 的“类型安全”不只停留在 TS 类型层面而是真正在运行时干活了。2.4 配置文件的约定t3code.config.ts 与解析逻辑每个使用 t3code 的项目根目录下可以放一个t3code.config.ts我把它设计成一个 TS 文件而不是 JSON原因还是类型安全配置文件本身可以被 zod schema 校验一遍写配置的人能享受编辑器补全。它主要负责四件事import { defineT3Config } from t3code; export default defineT3Config({ templates: [./templates, ~/.t3code/templates], defaultTarget: src, onGenerated: { api: sync-route-register, }, injectors: ./t3code/injectors, });templates模板搜索路径优先级从高到低。defaultTarget没有指定输出目录时的默认落点。onGenerated生成某个模板之后要执行的联动动作后面会专门讲。injectors锚点注入脚本所在目录。不要把配置能力做得太强。我的经验是配置文件一旦支持函数、循环、条件继承项目就会退化成“在配置文件里写业务逻辑”然后变成另外一个没人能维护的代码库。t3code 的配置只做声明式描述任何逻辑都放到模板的meta.ts或injectors里。3. init / add / generate三条主命令的完整实现链路t3code 的命令设计经过了好几轮简化最终只保留了三条加一个辅助命令init、add、generate、info。init面向新项目add面向日常增量开发generate是更底层的批量生成命令info用来查看模板详情。3.1 init从交互式收集到配置落盘init命令做的事说穿了就是三个步骤探测当前项目信息、询问使用者、生成t3code.config.ts。探测环节用package.json的关键字段判断项目类型比如dependencies里有next就认为是 Next.js 项目有vitest就推荐src/__tests__作为测试文件目录。这一步不做到完美只要给出合理默认值用户后面在配置文件里改就行。const configSchema z.object({ templates: z.array(z.string()).default([./templates, ~/.t3code/templates]), defaultTarget: z.string().default(src), onGenerated: z.record(z.string()).default({}), injectors: z.string().optional(), });交互式询问用的是我自己写的一个极简prompt模块——基于 Node 原生readline。我不在这里引入inquirer原因很朴素为了“三个字符串问题”引入一个重量级交互库不值。而且 inquirer 在 Node 22 的某些版本上存在提示符崩溃问题我踩过一次后直接换成了原生实现。核心机制就是顺序读取问题 → 写入答案 → zod parse → 不合法则重新问。async function promptForFields(fields: FieldConfig[], existing: Recordstring, unknown): PromiseRecordstring, unknown { const answers { ...existing }; for (const field of fields) { let answer: unknown; let valid false; while (!valid) { const raw await readLineAsync(${field.message}默认${String(answers[field.name] ?? )}: ); answer raw.trim() ? answers[field.name] : raw; const result schema.safeParse({ ...answers, [field.name]: answer }); if (result.success) { Object.assign(answers, result.data); valid true; } else { console.log(参数不合法, result.error.issues.map(i i.message).join(; )); } } } return answers; }配置落盘时用detect-indent和detect-newline两个小库去探测当前项目已有文件里最常见的缩进和换行风格保持配置文件与项目代码风格一致。这些细节不加生成的配置文件就会成为“看起来不像这个项目的文件”很掉价。3.2 add模块级生成的增量路径add是日常使用频率最高的命令。完整流程是这样的解析模板名 → 合并模板默认参数和命令行参数 → 如果缺参数且当前终端是 TTY进入交互模式 → 渲染模板到临时目录 → 执行联动动作 → 确认冲突策略 → 写入目标文件。它的核心难点在“合并默认参数”。“默认参数”来自三个地方模板meta.ts里的initial函数、命令行参数、配置文件里的overrides。合并顺序是initial最低 →overrides中间 → CLI 参数最高。比如meta.ts里可以定义initial: () ({ owner: t3code })然后用户项目里统一overrides: { prefix: /api/v1 }命令再传一个--nameUser最后 Zod 校验整个合并结果。export async function runAdd(templateName: string, name: string | undefined, opts: AddOptions) { const template await loadTemplate(templateName); const initialParams template.meta.initial ? await template.meta.initial({ cwd: process.cwd() }) : {}; const cliParams { name, type: opts.type }; const merged { ...initialParams, ...template.meta.overrides, ...stripUndefined(cliParams) }; const parsed template.meta.schema.safeParse(merged); if (!parsed.success process.stdin.isTTY) { const answered await promptForFields(template.meta.prompts, merged); const reParsed template.meta.schema.safeParse(answered); if (!reParsed.success) throw new T3Error(参数校验失败, reParsed.error); return executeAdd(template, reParsed.data, opts); } if (!parsed.success) { throw new T3Error(参数校验失败请检查命令行参数。使用 --dry-run 可预览。, parsed.error); } return executeAdd(template, parsed.data, opts); }这段代码的优雅之处在于交互模式和非交互模式走的是同一份校验逻辑只是参数的获取方式不同。CI 环境里没有 TTY就直接报错提示缺哪些参数而不是卡死在等待输入上。这一点对后续自动化集成非常重要。3.3 generate渲染管道全流程generate命令是add的底层展开也叫批量模式。它接收一个--file参数指向一个描述文件里面可以同时定义多个模板生成任务。典型用例是要生成一个“post 模块”同时需要schema、api、hook、page四个文件如果跑四次add参数要重复输入四次。用generate可以写一个post.gen.json{ target: src/modules/post, tasks: [ { template: schema, name: Post }, { template: api, name: Post }, { template: hook, name: Post, params: { type: Post } }, { template: page, name: Post, params: { withTable: true } } ] }渲染管道的完整流程从读取模板文件夹开始。一个标准的模板目录长这样templates/hook/ ├── hook.ejs # 主模板文件 ├── history.md # 模板版本说明 ├── meta.ts # 参数协议 校验 └── fixtures/ # 测试样例 └── expected-hook.ts渲染时把meta.ts导出的 schema 解析结果注入 EJS locals然后遍历模板目录里所有.ejs结尾的文件根据文件路径映射到目标路径。比如hook.ejs如果放在hooks/子目录下生成时就映射到src/hooks/。文件名里的use{{Name}}这种模式会被替换成useUser.hook.ts。生成完最后一个文件后统一落到内存中的文件树等待下一步的联动和冲突检查。3.4 dry-run 与安全审查add和generate都支持--dry-run。我特别看重这个参数因为代码生成器最大的风险是“把项目改坏了”。dry-run 会在内存里完成全部渲染和联动流程但不写磁盘只输出一份类似 git diff 的报告。$ t3code add hook User --dry-run [预览] 将创建文件src/hooks/useUser.ts [预览] 将修改文件src/hooks/index.ts增加 1 行 [预览] 将修改文件src/types/export.ts增加 1 行 [预览] 模板校验通过 [预览] 重复检测未检测到同名文件这里有一个小设计dry-run 模式下和文件系统相关的副作用函数都会被替换成“记录器”联动脚本拿不到真的 fs 写入能力只能返回“我想做什么”。这样至少保证预览和实际执行走的是同一套调度器预览结果足够可信。很多工具用“先执行再回滚”的思路做预览我不喜欢——回滚意味着已经产生了副作用而 dry-run 应该是零副作用的。4. 增量联动与代码一致性让生成结果真正可维护表面上看t3code 做的事情就是“根据模板生成文件”这类工具太多了。但 0.3 版本之后我把重心全部押在了“增量联动”上这就是它和其他片段生成器的本质区别。4.1 生成 API 模块时自动注册路由我在写一个内部后台系统时每新增一个 API 文件几乎一定会忘记在路由注册表里加路由。后来路由注册表都是手工维护的router/index.ts每新增一个 domain就要加一行 import、一行 route 注册。t3code 解决这个问题的方式是onGenerated里的内置动作sync-route-register。// t3code.config.ts export default defineT3Config({ templates: [./templates], defaultTarget: src, onGenerated: { api: sync-route-register, }, });当add api User执行完模板渲染后调度器会调用sync-route-register。这个动作会扫描src/router/目录下的路由文件寻找一个包含registerRoute(调用的文件解析其中已有的路由再把新生成的 route 合并进去。为了避免误修改它只做两件事检查目标文件里是否已经包含要插入的代码片段如果包含就跳过不包含就在最后一个registerRoute(调用后面追加一行。这个设计价值在于路由注册表永远与 API 文件“同时更新”不会出现“代码生成了但忘了注册”的问题。而且它对已有文件不做任何格式化、不动其他代码只做一行注释级别的注入冲突概率极小。4.2 锚点注入在已有文件中精准插代码生成新文件容易修改已有文件最难。“自动补 exports”就是一个典型。早期版本用正则去扫描export * from ./useUser;是否存在看多了就知道这种字符串处理很容易出 bug——比如注释里的字符串被误识别、文件编码格式不同、缩进风格混乱。0.6 版本我引入了“锚点注入”方案。每个 injected file 里可以写一行特殊注释作为锚点t3code 的 injector 会在锚点后面追加内容。// src/hooks/index.ts export * from ./useUser; // t3code:inject:exportinjector 读到t3code:inject:export锚点后先判断useUser是否已经出现在锚点上方如果出现过就跳过没有才在锚点行下面插入一行export * from ./useUser;。注入前还会做个稳定性检查如果锚点前后出现不正常的大段空白会自动格式化这个区块避免越插越乱。这种方式让 t3code 对已有文件的修改完全可控也不会影响开发者手写的内容。4.3 多模板组合生成一个页面的完整闭环前面说过generate可以批量跑多个模板。联动机制的威力在组合场景下展示得最明显。生成一个 Post 管理页面需要走完下面 5 个步骤schema 模板生成domain/post/postSchema.tsapi 模板生成api/post/postApi.tshook 模板生成hooks/usePost.tstable 模板生成components/post/postTable.tsx页面模板生成pages/post.tsx如果单独跑五次add前四个文件生成后最后一个页面模板无法知道前四个文件的位置因为它需要 import 路径信息。在generate模式下所有任务共享一个参数上下文后面模板可以通过context.getGeneratedFile(schema)拿到前序模板的真实输出路径然后拼进自己的 import 语句里。const schemaPath context.getGeneratedFile(schema); const apiPath context.getGeneratedFile(api); // 渲染输出中自动拼接 import 路径这种上下文共享机制是从“模板参数”到“模板间依赖”的质变。每个模板不再是孤岛而是一个流水线上的工位。回到 Post 页面的例子我可以保证生成的页面文件的 import 路径一定正确、一定和实际文件位置匹配。手工复制粘贴根本做不到这种程度的自洽。5. 0.x版本的五个实战坑与解法从 0.1 到 0.9我踩着的地雷足够写小册子了。这里挑五个最经典、最可能复发的给准备写同类工具的人提个醒。5.1 模板转义陷阱EJS 输出的标签字符串被二次解析某次我新增了一个模板生成的是 NestJS 的配置文件内容里包含了大量% ... %这种 EJS 语法字符串其实就是另一个模板引擎的占位符。结果 t3code 渲染时把这些也当成自己的标签执行了直接报Unexpected token %。解决方案是引入两层保护首先所有模板文件必须以.ejs结尾才会参与渲染其次暴露一个escapeTag工具函数给模板作者让他们显式把特殊标签转成%3C%再输出生成完再反转回来。从这以后我再也不敢用“自动识别哪些文件是模板”这种聪明的设计了。5.2 Windows 路径分隔符导致的模板寻址灾难某次开发者在 Windows 上跑t3code add hook User报错“找不到模板文件”。排查后发现我在 glob 匹配模板文件时用了path.join拼出templates//hook在 Windows 上变成了templates\\hook而模板加载器的内部索引是用正斜杠存的匹配不上。后来所有模板路径统一转成/开头的相对路径格式再做匹配写文件时再用path.resolve还原成系统路径。代码生成工具如果不在第一天就用 Posix 风格的内部路径表示Windows 用户会一直遭殃。5.3 交互式提问与 CI 环境的冲突0.4 版本之前add命令遇到缺参数时会一律进入交互式提问不管当前进程有没有 TTY。结果是在 GitHub Actions 里跑t3code add schema User --dry-run时进程永远卡在等待输入上直到 timeout。修复思路是判定process.stdin.isTTY非 TTY 环境直接抛错同时给出“缺了哪些参数、怎么补”的明确提示。这之后我又加了一个--yes-mode参数用于 CI 里强制使用默认值或者是从环境变量读取参数把自动化场景彻底跑通。5.4 自定义模板升级后旧项目配置的兼容迁移模板结构没有版本管理之前我改过hook模板的目录结构把use{{Name}}.ts改成了{{name}}.hook.ts结果老项目里所有引用旧文件名的地方全部失效。现在每个模板目录下的history.md记录版本变更内容同时meta.ts里必须声明templateVersion。t3code 在生成时发现当前项目的配置文件锁定的模板版本和模板实际版本不一致会输出警告并停止生成。用户需要主动执行t3code migrate template让工具根据迁移脚本改写旧文件。没有这层保险自定义模板生态根本不敢发展。5.5 生成超时递归模板的循环引用检测generate支持组合任务之后出现了“任务模板 A 引用模板 BB 又引用模板 A”的循环引用可能。第一次遇到是用户在任务链接里不小心把schema模板的linkedTasks指向了它自己结果工具无限递归内存暴涨卡死。解决方案是在调度器里加一个简易的循环检测器每执行一个任务就把模板名压入栈里如果新任务模板已经存在于栈中就抛出一个错误并把任务依赖链打印出来让人一眼看出是哪条链出了问题。function createTaskVisitedGuard() { const visited new Setstring(); return { enter(template: string, chain: string[]) { if (visited.has(template)) { throw new T3Error(检测到循环模板引用${chain.join( - )} - ${template}); } visited.add(template); }, }; }6. 发布、使用心得和下一步6.1 打包双格式产物与全局命令的 shim发布 npm 包时有一个细节值得提为了让 CJS 和 ESM 两种项目都能用构建工具需要输出dist/index.cjs和dist/index.mjs两套产物。但命令行工具的入口不建议直接指向其中任何一个更好的做法是生成一个十几行的 shell shim用import()动态加载对应格式的入口。这样即便用户的 package.json 里缺了exports字段shim 也能保证命令正常执行。如果你自己写的工具链准备发布给外部用户使用这层 buff 建议加上。6.2 从个人资产到公共模板库的计划t3code 现在的模板组件已经有一个比较完整的模板语义拆分成了基础模板schema、api、hook和组合模板crud、page。基础模板是一个文件或多个关联文件组合模板是任务编排它依赖基础模板并且有顺序依赖关系。下一步我计划支持逐版本发布的模板 registry用与 npm 类似的 dist-tag 机制让团队可以明确锁定一个团队的模板版本。模板之间不再只是本地文件而可以被引用为team/post-template这样的独立单元。6.3 v1.0 的三个必备项相比 0.xv1.0 我希望重点做三件事。第一内置代码格式化器选项——生成完代码后用 prettier 或 biome 格式化一遍让模板作者不用把缩进写成“看起来像格式化过的样子”。第二create-t3code 命令——一条命令初始化一个 t3code 项目自动引入推荐模板集。第三一个稳妥的本地缓存机制——让t3code update能检查并更新全局模板集同时保证不会破坏已经生成出来的文件。我自己用 t3code 重构过两个中型项目最大的体会是代码生成工具的终点不是替你把文件生成了而是让“生成”这个动作成为团队的一种可复用资产。模板就是资产参数协议就是接口文档联动动作就是工程规范的自动化执行者。如果你的大部分业务代码都存在“高度相似但不完全相同”的重复那这类工具值得从一个小模板开始试水。先做一个entity模板跑通从参数到输出到注入的闭环再往里面加复杂度——这条路比我一开始直接搞一套庞大框架走得稳得多。

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

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

免费获取报价 →
↑