资讯动态

自研T3代码生成器t3code:从复制粘贴到模块秒级生成

发布时间:2026/10/27 16:13:14 来源:尧图企业网站定制
我前前后后维护了六七个基于 T3 技术栈TypeScript、Tailwind、tRPC、Next.js的全栈项目日子久了最折磨人的不是业务逻辑而是写了一遍又一遍的样板代码——新建一个user模块前端要写 API 封装、类型定义、React Query hooks后端要写 tRPC router、Zod schema、数据库 model连测试文件都得跟着复制一份改改名字。直到我做出了一个叫t3code的 CLI 工具这个局面才算结束。这篇文章不是来讲大道理的是我自己从零到一实现 t3code 的完整记录为什么会做它、架构怎么定、模板引擎怎么选、写入文件时踩了什么坑以及最后怎么让它变得真正好用。如果你也是天天打交道的 TypeScript 全栈开发者或者正在维护一套内部脚手架这篇应该能给你不少可以直接搬走的思路。1. 从第 8 次复制粘贴到动手写工具t3code 要解决的真实痛点先说动机。很多人以为脚手架工具是给“懒人”用的其实不完全是。真正让我动手的原因是复制粘贴导致的低质量代码泛滥到了不可忽视的程度。1.1 复制粘贴样板带来的三类问题第一类是命名不一致。同一个业务实体数据库表叫user_profilesAPI 路由叫userProfile前端类型叫IUserProfile三个地方三个风格。复制粘贴的时候手一抖中途某个文件没改名运行时报错根本找不到源头。第二类是遗漏关键代码。比如 tRPC 的protectedProcedure提权校验多模块复用的时候经常有人忘了加等于把权限漏洞复制到了生产环境。第三类是回归风险某个模块的 API 封装修了一个 bug其他五个模块的复制版全都不知道下次出问题还得一个个排查。我统计过一个数字新建一个中等复杂度的业务模块手写好一点的资深工程师大概要 40 分钟到 1 小时其中 60% 以上的时间是无效重复劳动。t3code 的目标就是把这部分时间压缩到秒级同时保证生成代码的风格统一、命名规范、校验逻辑不缺。1.2 工具的定位边界它不该被做成一个全家桶做工具之前我先给自己定了个规矩t3code 不是代码生成“全家桶”不打算接管项目的生命周期也不试图替代 Next.js 官方的create-next-app。它的范围只有一件事——在已有的 T3 项目中按需生成业务模块代码。为什么这么定位因为大多数项目脚手架在初始化阶段已经足够好用了真正缺的是“项目内的增量模块生成”。你不需要一个工具帮你从零搭项目你需要一个工具帮你把user、post、order这些业务模块快速地、规范地生成出来塞进已有的目录结构。这个定位收窄之后工程量小了很多但实用性反而大增。1.3 功能和约束清单我在动手前把需求拆成了一张表作为开发期的验收标准功能项具体说明约束模块初始化输入模块名生成 tRPC router、Zod schema、前端 API 封装遵循已有的目录约定交互式配置通过问答选择是否需要鉴权、是否需要注释不依赖图形界面幂等能力重复执行同一命令不产生重复内容写入前检查文件是否存在可配置性通过 JSON 配置覆盖默认目录不侵入项目业务代码命名转换自动转换 camelCase、kebab-case、PascalCase统一全项目的命名风格这张表做完我基本清楚了这是一个典型的“模板引擎 文件系统操作 交互式输入”项目。决定好要做什么之后接下来最重要的事情就是选型。2. 技术选型与架构骨架为什么不用 plop而是自己撸一套第二版做完之后我反思过一个问题这东西用现成的plop或者hygen不是更快吗为什么非要自己写答案是为了可控性尤其是对模板细节和输出代码质量的可控性。t3code 的定位决定了它对生成代码的最终格式有非常严苛的要求而通用工具在这一层往往要做大量的绕过工作。2.1 为什么 Node TypeScript 是最稳的基底选择 Node 几乎没有争议——T3 技术栈本身就跑在 Node 生态里生成的文件也都是 TS/TSX用 JS 胶水代码去操作文本文件最顺手。我用 TypeScript 写 CLI 本身不是为了炫技是因为这个工具未来要长期维护类型系统能让我在扩展命令的时候少犯低级错误。依赖方面我只保留了四个核心运行时依赖ejs做模板渲染inquirer做交互问答prettier做代码格式化commander做命令行解析。其他的全都用 Node 内置模块。越是底层的东西越不需要引入框架小工具保持轻量以后升级依赖也不会黑天鹅。2.2 手写代码生成器的三层结构没有用 plop主要是因为我想把“模板渲染”和“文件写入”这两件事彻底解耦。plop 的 Action 机制虽然好用但遇到「同一个模板在不同场景下要输出到不同位置」「渲染前要对比旧文件和预期内容的差异」这种需求写起来就很别扭。我自己设计的 t3code 核心只有三层交互层负责向用户提问、读取配置文件最终产出一个结构化的ModuleConfig对象。渲染层接收ModuleConfig通过 EJS 模板逐个文件渲染出字符串内容。写入层对渲染结果执行格式化、冲突检测、文件写入。层的边界很干净交互层完全不知道模板长什么样渲染层不知道文件该写到哪里写入层只知道把字符串变成文件。这样无论以后是要加 WebUI 前端还是要支持--dry-run模式都只需要动某一层。2.3 模板之间如何共享上下文这是我自己设计里最得意的一个思路把所有模板共享的变量统一塞进一个context对象模板内部不直接访问用户输入的原始值而是访问经过命名转换后的派生值。比如用户输入模块名userProfile我会在进入渲染阶段之前就计算出下面这些字段const context { moduleName: userProfile, // 原始输入 kebabName: user-profile, // 用于文件命名 pascalName: UserProfile, // 用于类名和类型名 camelName: userProfile, // 用于变量名和 tRPC 路由 upperSnake: USER_PROFILE, // 用于枚举值或者常量 dbTableName: user_profiles, // 用于数据库表名 };模板里只允许使用这些派生值不直接做字符串拼接。比如生成文件名时用% kebabName %.ts生成 tRPC router 路径时用% camelName %。这样做的好处是全项目任何地方出现命名不一致的 bug只需要去改这个转换函数模板本身永远不用动。2.4 架构落地后的目录结构最终 t3code 仓库本身的目录结构是这样的t3code/ ├── src/ │ ├── commands/ │ │ └── generate.ts # 命令入口模块生成 │ ├── core/ │ │ ├── context.ts # 命名转换和上下文构建 │ │ ├── fileWriter.ts # 文件写入、冲突处理 │ │ └── configLoader.ts # 配置加载 │ ├── render/ │ │ ├── ejsRenderer.ts # 模板渲染封装 │ │ └── formatter.ts # Prettier 格式化 │ ├── templates/ │ │ ├── server/ │ │ │ ├── router.ejs │ │ │ ├── schema.ejs │ │ │ └── db.ejs │ │ └── client/ │ │ ├── api.ejs │ │ └── types.ejs │ └── index.ts ├── t3code.config.json # 项目级配置 └── package.json结构这种东西一开始简单后面需求一多就容易乱。我给自己定的原则是commands里面只放命令入口core里面只放纯逻辑render里面只放跟字符串渲染有关的东西templates里面只放模板文件。任何跨层调用都必须通过函数参数传下去不搞全局状态。事实证明这个决定让后续加新模板的时候非常轻松。3. 模板层的核心工程EJS 的可维护性与格式化问题模板层是整个工具里最敏感的部分——改业务代码的人会骂改模板的人懂所以模板最好是让业务开发者也能看懂的形态。3.1 模板文件放在项目里的组织方式我一开始把模板区分成server和client两大组server 组负责生成后端代码client 组负责生成前端 API 层和类型文件。每组内部又按文件用途拆分server/router.ejs生成 tRPC 的 router 定义server/schema.ejs生成 Zod 的输入输出 schemaserver/db.ejs生成数据库 model 的占位定义client/api.ejs生成前端调用的 API 函数和 hooksclient/types.ejs生成前后端共享的 TypeScript 类型这样拆的目的是让每个模板文件尽量短小就算模板里写错了什么看文件名也能立刻定位。下面举个实际的例子这是server/schema.ejs简化后的样子import { z } from zod; export const % camelName %Schema z.object({ id: z.string().optional(), name: z.string().min(1), description: z.string().optional(), createdAt: z.date().optional(), updatedAt: z.date().optional(), }); export type % pascalName %Input z.infertypeof % camelName %Schema; export type % pascalName %Output % pascalName % { id: string; createdAt: Date; updatedAt: Date; };可能有人问模板里写这么细的字段定义生成的代码会不会太死板其实不会——模板代表的是一种“合理的默认值”生成之后业务开发者完全可以手改。重要的是模板给了业务代码一个可靠的起点尤其是z.infer派生出来的类型定义手写特别容易漏。3.2 EJS 的语法泥潭避免过度使用控制流EJS 最危险的地方在于它让你能在模板里写各种循环和判断一旦写多了模板就变得无法阅读。我的经验是尽量让数据在进入模板之前就已经是最终形态模板内部只做展开不做逻辑判断。比如“这个模块是否需要鉴权”这个开关我不会在模板里写成% if (config.enableAuth) { % export const % camelName %Router protectedProcedure... % } else { % export const % camelName %Router publicProcedure... % } %我会上游先把一个procedurePrefix字符串算好模板里只有一行export const % camelName %Router % procedurePrefix %...逻辑放在 TypeScript 里比放在 EJS 里好测一百倍。模板越接近纯文本占位符越不容易出错。3.3 渲染后格式化Prettier 是最后的救命稻草模板里最容易翻车的是缩进和换行。EJS 标签本身会占据行渲染出来的字符串经常带有多余空行或者缩进错位。解决这个问题的思路不是去手工调 EJS 的空格控制而是把格式化完全交给 Prettier。我的formatter.ts里做了三件事根据文件后缀决定 parser.ts用typescript.tsx用typescript.json用json。配置semi: true、singleQuote: true、printWidth: 80和生产代码保持统一。用prettier.format而不是prettier.writeFile先把格式化的结果返回给调用方然后再做写入决策。一行代码就能解决所有空行问题完全不用在模板里做-%之类的小技巧。3.4 模板版本管理把模板当代码一样做 diff到了后期我越来越觉得模板就是代码的一部分。模板改了之后生成的代码可能跟旧版本差异巨大所以我在仓库里加了模板的快照测试——每次改模板之后跑一遍测试看看某个固定输入下生成的输出文件 diffcommit 之前必须人眼核对一次这个 diff。这个习惯在团队协作中尤其重要至少能够防止别人改了命名约定而你毫不知情。4. 交互问答与配置持久化这套机制让工具真正适应不同项目模板引擎解决的是“怎么写”的问题真正决定“写出什么”的是输入数据。t3code 的数据来源有两路一路是命令行交互问答一路是项目根目录下的t3code.config.json配置文件。这两个东西的组合是工具好用不好用的分水岭。4.1 inquirer 问什么、不问什么我刚开始把交互问题设计得非常多字段名、数据库表名、是否需要分页、是否需要软删除……问得用户烦躁无比。后来我把问题精简到三个必问和一个选问模块名称必问唯一肯定要人工提供的信息是否生成前端 API 封装选问默认是是否带鉴权保护选问默认是是否生成数据库 model 注释选问默认否三个问题都做了默认值用户全程按回车也能得到一个合理的模块。很多人忽略这个细节CLI 工具的“默认值设计”比命令行参数设计更影响体验。默认值就是你对业务最佳实践的默认理解它帮用户做了大部分决策用户只需要为真正的特殊情况回答。4.2 配置文件的结构和职责t3code.config.json解决的是项目跟项目之间差异的问题比如有人把 tRPC routers 放在src/server/api/routers/有人放在src/server/routers/。配置文件里最核心的是路径映射{ paths: { routerDir: src/server/api/routers, schemaDir: src/server/db/schemas, apiDir: src/features, typesDir: src/types/api }, defaults: { enableAuth: true, generateClientApi: true, dbTablePrefix: app_ }, naming: { dbTableStyle: plural_snake } }这里有个值得说的设计细节配置文件只做路径和默认值的映射不做业务字段的模板数据映射。我见过一些工具试图把数据库字段也放在配置文件里最后配置文件膨胀得比代码还难管理。业务字段应该永远来自用户输入配置文件只回答“放哪里、默认值是什么”这类基础设施问题。4.3 配置合并的优先级顺序多套配置来源就需要一个明确的优先级。t3code 的规则很简单命令行问答的返回值优先级最高其次是项目根目录的t3code.config.json再次是 t3code 内置的默认配置每层配置之间做 shallow merge不做深合并。路径映射只有一个 key 被覆盖不会影响其他路径。默认值字段同理。这套规则的实现只有二十行代码但是避免了 99% 的“为什么我的配置没生效”类 issue。4.4 配置无效后的拯救方案dry-run 模式做了交互和配置之后还有一个场景值得专门提——用户想预览一下生成结果但还不想真正写文件。t3code 里加了--dry-run模式渲染完成后不进入写入层而是把每个文件将以什么路径写入、内容是什么一起打印到终端。这个模式平时看起来没什么用真正到了“我要在一堆旧结构项目里验证新模板”的时候它就是救命稻草。没有 dry-run 模式之前我每次改模板都要找个临时目录跑一遍有了之后直接在真实项目里验证确认没问题再真正写入。省下的时间远比开发这个模式花的时间多。5. 文件写入的博弈覆盖、跳过、备份的取舍很多人做代码生成器只关注模板怎么写完全忽视了文件写入环节。实际上写文件才是最容易把用户项目搞坏的一步。t3code 在这一层花的心思比模板层还多。5.1 冲突检测宁可什么都不写也不要瞎覆盖t3code 的写入逻辑默认采用「如果目标文件已存在就跳过并打印警告」的策略。这不是因为覆盖不好而是因为代码生成器无法理解用户对现有文件的改动——你无法确定一个文件是从模板生成的还是用户手写改过的。但是直接跳过也有问题重复跑命令的时候第一次生成的文件会妨碍第二次生成。为了解决这个问题我在写入层增加了一个--force参数强制覆盖以及一个--backup参数覆盖前生成.bak文件。用户可以根据场景自由选择默认是安全的 skip 策略。5.2 备份机制覆盖之前先留后路当用户选择--force的时候会不会直接覆盖掉一个重要文件会。所以我给--force加了一个伴随的--backup选项输出文件内容之前先把旧文件重命名成[filename].t3code.bak。备份文件的命名我故意带上了工具名t3code原因是让用户可以一眼看出这些备份文件是工具产生的可以放心删除。如果用普通的.bak用户看到一堆来历不明的备份文件反而不敢动。5.3 幂等性的实现细节幂等性我做了一个小时才意识到它的完整含义——除了文件存在检查之外还应该做内容级检查。如果目标文件已经存在且内容和本次生成的内容完全一样那就不应该打印“跳过”的警告而应该打印“已是最新”的状态。实现方式很简单先读取旧文件内容用字符串比对和本次生成的内容是否完全相同。相同就不做任何操作不同且没带--force的时候才警告。这个逻辑避免了重复执行命令时的恐慌感也让 t3code 可以在 CI 流程里安全地反复执行。5.4 目录自动创建不要高估已有结构的完整性我在测试中出现过这类问题用户配置了routerDir指向一个尚不存在的目录写入文件的时候直接报ENOENT。解决方式是在写入文件之前用fs.mkdirSync(path.dirname(filePath), { recursive: true })把目标目录创建出来。这一行代码大概是被我写过很多次又遗忘过很多次的经典代码。代码生成器的使用者不该为目录存在性负责工具自己创建缺失的目录是最低限度的容错能力。5.5 格式化后的写回先渲染后格式化再写入文件写入的最后一步不是fs.writeFileSync而是把渲染结果先交给 Prettier 格式化再写盘。这么做还有一个附加好处在 dry-run 模式下用户看到的预览内容就是最终的格式不是模板渲染出来的原始草稿。这个顺序千万不能反否则干跑模式和真实运行看到的代码不一样会让人非常困惑。6. 实测之后的三处修正跨平台路径、模板变更和快照测试的教训工具做完不等于结束。把这个工具放到真实业务中跑了两周之后我遇到了几个单测完全覆盖不到的问题每个都值得记录。6.1 跨平台路径分隔符是第一个陷阱t3code 最初在 macOS 上开发路径用的是/Windows 上跑的时候src/server/api/routers/user-profile.ts会被拼接成src\server\api\routers\user-profile.ts。在 Node 里这通常不是问题因为 Windows 的 API 大体兼容/但问题出在用户配置文件里手写路径的时候有人会写反斜杠有人会写正斜杠最终拼接出来的路径乱成一锅粥。我的修复方案是在configLoader里加了一个standardizePath函数把所有配置路径统一转换成 POSIX 风格用/在写入层再用path.normalize转换回当前平台的正确格式。这样配置文件的书写体验统一了底层不依赖用户手写风格。6.2 模板变更导致的老项目“二次生成”断裂这是我踩过的最大的坑。最初版本生成的api.ts和后来的模板版本结构有显著变化导致老项目里那些由旧版 t3code 生成的文件在新版模板下“重新生成失败”。因为在src/features/user/api.ts里用户已经基于旧模板改了业务代码新模板一覆盖用户改的内容全没了。这个问题没有完美的解法。我的做法是给 t3code 加了版本概念每个模块生成的时候会在文件头写入一行注释// generated by t3code v1.x写入层读取到不同版本号的文件时默认走 skip 策略不强制覆盖只提示用户手动 diff 合并。这个方案把“工具自动升级”的风险变成了“用户主动决策”事后的抱怨少了非常多。6.3 基于快照的模板回归测试是怎么帮到我的模板类的代码生成器最大的风险是“这次改动了 A 文件意外破坏了 B 文件的生成格式”。我在 t3code 仓库里建了一个tests/snapshots/目录针对固定输入跑一次完整渲染把生成的文件内容全部保存成快照。之后任何模板改动跑一次测试就能立刻看到哪些生成文件变了。快照 diff 变成 code review 的重要依据——每次提交模板改动的时候diff 也跟着提交reviewer 能看到模板改动对实际生成代码的影响。这个习惯帮我抓出了一个真实 bug当时改动server/schema.ejs中z.infer的使用方式导致所有客户端类型文件的Output类型名称错了一位快照 diff 一眼就发现了。说到底t3code 不是什么惊天动地的框架它就是把“重复劳动自动化的决心”落实成了一个几百行代码的 CLI。我个人的体会是工具项目最大的挑战从来都不是代码本身而是你想清楚边界在哪里、怎么保证生成结果可维护、如何用测试兜底让后续的模板改动不失控。如果你也在维护一堆结构相似的 T3 项目从一次模块自动生成开始动手大概率你会做出比我这个更好用的版本。

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

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

免费获取报价 →
↑