
t3code 这个名字是我最近折腾出来的一个命令行工具本质上是一个基于 T3 技术栈的代码生成器。简单说你执行一条命令回答几个交互式问题它就能在当前目录下生成一套结构完整、风格统一的组件、页面、API 路由或工具函数。做这个项目的起因很朴素我发现自己很多时间都花在复制粘贴旧项目代码、改文件名、改变量名这些毫无创造力的机械操作上每次新开一个功能模块流程都一模一样。于是我想不如写个工具把这些可重复的部分沉淀下来顺便把团队里大家代码风格不统一的毛病也一起治了。这篇内容适合谁只要是平时写 TypeScript、React、Next.js、Node.js或者正在用 T3 stack 做业务的人都会用得上。哪怕你从来没写过 CLI 工具也可以照着我这套思路从零搭一个自己能持续维护的代码生成器。我会把架构设计、核心实现、踩坑过程都拆开讲偏实战不整虚的。1. 项目概述为什么需要 t3code1.1 日常开发里的重复劳动先说我在真实项目里遇到的场景。假设你在维护一个 Next.js 项目后端数据访问层用 Prisma接口层用 tRPC样式用 Tailwind CSS这套组合现在很常见。每次新增一个业务模块我基本要做下面这几件事在 prisma/schema.prisma 里新增 model然后执行prisma migrate dev生成 migration。在 server/trpc/router 里新建一个 router 文件把 CRUD 方法一个个写上。在 server/api 或者对应的 data access 目录里补上数据库查询方法。在 components 目录里新建表格、表单、弹窗这些 UI 组件连带配套的 zustand 状态管理。在 pages 或者 app router 对应的目录里新增路由页面。这些事情每个单独看都不难但问题在于它们高度重复。不同人做出来的差异也很大有人喜欢把查询逻辑写在 router 里有人会抽一层 service有人组件里直接手写 Tailwind 类名有人习惯用 cn 函数合并命名更是各有各的喜好。新同事进来第一个月光适应这些约定就要花不少时间。t3code 想解决的就是两件事第一把高频操作变成一条命令把花在复制粘贴上的时间压缩掉第二把代码规范沉淀到模板里大家生成出来的代码天然就是统一的代码 review 去掉了大量“风格问题”的讨论只关注业务逻辑。1.2 现有脚手架和代码生成方案的问题可能有人会问现在不是有 create-next-app、create-t3-app 这类脚手架工具吗为什么还要自己写一个。这里要把“项目脚手架”和“代码生成器”区分开。脚手架解决的是“从一个空目录变成一个可运行项目”的问题它一般在项目初始化阶段用一次。而 t3code 解决的是“在已经存在的项目里快速增加一个功能模块”的问题它在开发过程中会被反复使用。两者定位完全不同。我也试用过市面上一些代码生成工具比如 hygen、plop。它们都很强大尤其是 plop基于 handlebars 模板引擎生态成熟也可以交互。但我实际用了一段时间后总觉得有几个别扭的地方模板语法对团队里不熟悉 handlebars 的成员来说还是有点门槛遇到循环、条件嵌套很容易写错。默认行为太“重”很多功能用不上配置项看半天才能搞明白。生成的代码往往不是我想要的风格模板里的逻辑判断太多反而难维护。我不是说这些工具不好而是它们更通用。通用意味着要为各种使用场景做适配到了具体项目里反而需要再做一层封装。t3code 的出发点就是“私货最大化”只为我常用的技术栈和代码风格服务不追求通用但求在特定场景下最好用。1.3 工具定位一条命令产出完整模块最初我把 t3code 定位成“模块生成器”后面用着用着发现它还能承担更多工作。比如新写一个 React Hook一条命令生成 hook 文件、类型定义文件、单元测试文件还包括一个简单的使用示例新加一个 tRPC 路由一条命令生成 router 文件、对应的 zod schema、以及前端的调用封装。这样不仅是减少打字量更重要的是不用再挨个文件去补 import、去检查默认导出命名。工具的定位可以一句话概括面向 T3 技术栈的“项目内增量代码生成器”。它不是脚手架是脚手架之上的效率层。2. 整体设计从命名到核心架构2.1 为什么叫 t3code包含哪些技术栈名字逻辑很简单t3 就是 TypeScript Tailwind CSS Next.js tRPC 这套被大家叫做 T3 stack 的组合code 表示生成代码这件事。合起来 t3code意思就是这个工具生成的代码遵循 T3 技术栈的约定。模板块目前覆盖了 four 类UI 组件React Tailwind、状态管理zustand、API 路由tRPC zod、数据模型Prisma schema 与相关类型。工具本身用 Node.js TypeScript 编写命令行交互用 commander 和 prompts模板引擎没有引入外部依赖而是自己写了一个极简的占位符替换器后面我会详细说明为什么这么做。选 Node.js 而不是 Go 或 Rust是因为它的生态里和前端工具链集成最顺。t3code 需要读取用户的 tsconfig、package.json、prisma schema 等文件用 Node.js 处理这类场景最直接而且前端团队的同事如果想二次开发TypeScript 对他们来说是零学习成本。2.2 项目目录结构t3code 的仓库结构如下我尽量保持简单。很多 CLI 工具失败是因为一开始就把目录划得太细光模板目录就有三层嵌套最后反而没人愿意维护。t3code 的目录是我刻意压到最简的状态t3code/ ├── package.json ├── tsconfig.json ├── src/ │ ├── index.ts # CLI 入口注册命令 │ ├── generate.ts # 生成流程控制 │ ├── prompts.ts # 交互式问题定义 │ ├── templates.ts # 模板加载与解析 │ ├── files.ts # 文件写入与路径处理 │ └── utils/ │ ├── validate.ts # 项目名、路径校验 │ └── logger.ts # 彩色日志输出 ├── templates/ │ ├── component/ │ │ ├── component.tsx.tpl │ │ ├── types.ts.tpl │ │ └── test.tsx.tpl │ ├── hook/ │ │ ├── hook.ts.tpl │ │ └── test.ts.tpl │ ├── router/ │ │ ├── router.ts.tpl │ │ └── schema.ts.tpl │ └── prisma/ │ └── model.prisma.tpl └── dist/src目录管逻辑templates目录管模板。这种做法最大的好处是一个不熟悉 TypeScript 的同事也能修改模板只要他理解模板里那几个占位符是什么意思就可以调整生成代码的样子不用深入到 CLI 的逻辑层。模板和代码逻辑分离是这类工具设计上最关键的一步。2.3 模板引擎不引入 handlebars 的真实原因很多人听到“模板引擎”第一反应是 handlebars、ejs、Nunjucks 之类的成熟方案。我一开始也试过用 handlebars但遇到一个很实际的问题t3code 的模板主要用来生成 TSX 文件而 JSX 语法里的{}和 handlebars 的{{ }}看起来非常接近编辑器里打开模板文件语法高亮几乎是乱的。另外handlebars 的 helper 机制确实灵活但模板文件里一旦用了太多{{#if}}、{{#each}}可读性会迅速下降。我们团队的实际目标是“模板要尽量像一个写好的源文件”而不是“模板要像一个逻辑丰富的程序”。所以我自己实现了一个最小可用的模板替换机制只支持两种语法{{variable}}普通变量替换{{#if variable}}...{{/if}}条件包含循环怎么办我的做法是不在模板里做循环而是在代码逻辑里把需要循环生成的内容先转换成字符串再将这个字符串作为变量传入模板。这一点很重要后面我会专门演示。这个取舍在几个项目里验证下来都很舒服生成的代码从第一眼看上去就和手写的一致不用在脑子里做一层“模板逻辑到最终代码”的转换。3. 核心实现从草稿到可用 CLI3.1 初始化 CLI 骨架第一步是把命令行的壳搭起来。我用 commander 注册了一个generate命令可以简写成g。示例命令t3code g component --name UserCard --variant tsx--variant用来选择生成函数组件还是普通组件。如果某些参数缺省工具会进入交互式提示逐个询问。这里我建议命令设计上坚持一条原则--name是必填的其余尽量可选。因为生成代码总得知道目标名字而其他配置用交互式补全反而更自然。入口文件最核心的部分是调用生成流程然后捕获错误。错误信息一定要清楚我见过太多 CLI 工具在报错时直接丢一个 stack trace 出来用户根本不知道是自己参数写错了还是工具 bug。我的做法是用自定义错误类型把“可预期的错误”和“异常错误”区分开前者输出提示后就退出后者才打印详细的堆栈。// src/index.ts import { Command } from commander; import { generate } from ./generate; import { logger } from ./utils/logger; const program new Command(); program .name(t3code) .description(Generate code for T3 stack projects) .version(0.1.0); program .command(generate) .alias(g) .argument(type, component | hook | router | prisma) .option(--name name, target name) .option(--variant variant, variant of template, default) .action((type, options) { generate({ type, ...options }).catch((err) { logger.error(err.message); if (process.env.DEBUG t3code) { console.error(err); } process.exit(1); }); }); program.parse();这段代码本身不复杂但它定义清楚了命令的形态。注意我给--variant设置了默认值default这样模板目录里只需要放一个默认子目录或文件不会因为缺参数就报错。3.2 模板加载与解析模板文件放在templates目录下文件名带有.tpl后缀。这样做的好处是编辑器不会把它们当作真正的tsx文件去检查语法也不会被项目的 prettier 或 eslint 误伤。加载模板的流程很简单根据用户传入的type拼出templates/type/variant的路径读取该目录下所有以.tpl结尾的文件然后按照文件名映射到输出文件。映射规则是把component.tsx.tpl的.tpl去掉得到component.tsx。如果是test.tsx.tpl则要结合用户提供的名称生成Name.test.tsx。这里会有一些特殊命名规则我统一放在一个resolveOutputName函数里处理避免生成流程里的逻辑散得到处都是。// src/templates.ts import { readdir, readFile } from fs/promises; import path from path; export interface TemplateFile { template: string; outputName: string; } export async function loadTemplates(templateDir: string): PromiseTemplateFile[] { const files await readdir(templateDir); const tplFiles files.filter((f) f.endsWith(.tpl)); const templates await Promise.all( tplFiles.map(async (f) { const content await readFile(path.join(templateDir, f), utf-8); return { template: content, outputName: f.replace(.tpl, ), }; }) ); return templates; }模板解析是手写的替换函数。一开始我没有考虑条件块后面发现有些模板内容只有特定 variant 才需要就加了if语法。实现时要注意一个坑先处理条件块再替换普通变量。如果顺序反过来条件块里包含变量的时候会被提前替换掉导致判断失效。// src/templates.ts export function renderTemplate( template: string, vars: Recordstring, string | boolean ): string { let result template; // 1. 处理条件块{{#if key}} content {{/if}} result result.replace(/\{\{#if (\w)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, key, content) { return vars[key] ? content : ; }); // 2. 处理普通变量{{key}} result result.replace(/\{\{(\w)\}\}/g, (_, key) { return vars[key] ! undefined ? String(vars[key]) : ; }); return result; }普通变量替换这里我故意没有做转义也就是说模板里如果出现{{name}}而传入的name是UserCard就会直接变成UserCard。这个设计需要有一个前提就是我们在命令行参数校验时已经把所有危险字符挡掉了小到一个连字符、空格都不能出现在变量名里。这个我在 3.4 小节会具体展开。3.3 生成流程从模板到真实文件的完整链路t3code 的生成流程可以拆成五个步骤读取配置、调用交互提示、加载模板、渲染模板、写入文件。完整链路的代码在generate.ts里伪代码大致如下// src/generate.ts import path from path; import { loadTemplates, renderTemplate } from ./templates; import { askQuestions } from ./prompts; import { validateName, ensureDirectory } from ./utils/validate; export async function generate(options: GenerateOptions) { const name validateName(options.name); const answers await askQuestions(options.type, options); const templateDir path.resolve(__dirname, ../templates, options.type, answers.variant); const templates await loadTemplates(templateDir); const outputBase path.resolve(process.cwd(), answers.outputDir ?? ); for (const tpl of templates) { const vars buildTemplateVars({ name, answers }); const content renderTemplate(tpl.template, vars); const outputPath path.join( outputBase, resolveOutputName(tpl.outputName, name, answers) ); await ensureDirectory(path.dirname(outputPath)); await writeFileSafe(outputPath, content); } logger.success(Generated ${templates.length} files for ${name}); }实际执行时我最担心的是“文件写到一半失败了怎么办”。所以在writeFileSafe里我会先检查目标文件是否已存在默认情况下是询问用户是覆盖还是跳过只有用户显式传了--force才会直接覆盖。这个保护非常重要尤其是 t3code 生成代码时如果某个同名文件已经存在且里面有手工改动直接覆盖会丢代码这是所有自动生成工具最容易被骂的地方。3.4 参数校验名字合法是模板安全的前提前面提到模板不做转义那么参数校验就是模板安全的底线。我定义了自己的一套校验规则只允许字母、数字、下划线和中划线。组件名一般用 PascalCaseHook 名用 camelCase文件名用 kebab-case这些由不同的模板类型自行决定但底层字符集必须是安全的。// src/utils/validate.ts export function validateName(name: string): string { if (!name) { throw new Error(name is required); } if (!/^[A-Za-z0-9_-]$/.test(name)) { throw new Error( Invalid name: ${name}. Only letters, numbers, underscore and hyphen are allowed. ); } return name; }为什么不用更宽松的规则因为模板里的变量会被直接拼到文件路径和代码标识符中如果允许空格、连续中划线或者中文生成的代码大概率是有问题的。比如一个组件名带空格生成的 import 语句是import User Card from ./User Card这文件根本编译不过。校验严格一点宁可在命令阶段报错也不要等到编辑器和编译器报一堆让人摸不着头脑的问题。交互式提示用的是 prompts 这个库它支持单选、多选、输入还能做校验。一个我后来才发现的细节是交互式问题一定要提供默认值。我刚开始的版本里 “输出目录” 这个问题没有默认值用户经常直接按回车结果生成到了项目根目录文件散落一地。后来改成默认当前目录下的components或server/routers按回车就是最合理的选择明显更顺手。4. 踩坑实录高频问题与排查技巧4.1 Windows 路径分隔符导致的模板路径失效第一次在 Windows 环境跑 t3code 的时候模板目录的路径拼接莫名失败。排查之后发现是路径分隔符的问题。在 Windows 上path.join(templates, component)的结果是templates\component这个没问题。问题出在我后来用字符串拼接的方式处理templateDir / filename结果混出了templates/component\component.tsx.tpl这种不伦不类的路径。解决办法是全程使用path.join不要手写/或\。还有一个细节如果要在日志里展示生成路径最好用path.relative(process.cwd(), absPath)转换成相对路径给用户看不然在 Windows 上会输出一长串C:\Users\xxx\project\...体验很差。这个坑看着小但对跨平台 CLI 工具来说属于必踩门槛。4.2 模板中的{}被误判为占位符的问题t3code 生成的是 TypeScript 和 TSX 代码模板里天然会有大量花括号。比如const [open, setOpen] useState(false);如果我的占位符规则写得太宽比如用/\{(.?)\}/g去匹配那上面这行代码里的{和}会被当成变量边界模板解析直接崩。后来我把占位符收敛成{{双花括号问题就消失了。但 JSX 里还有一些场景会用到对象字面量比如style{{ color: red }}这里会出现{{和}}如果模板里正好有一段时间模板解析器只处理{{...}}也会踩中。最终的解决办法是让模板解析器只识别{{变量名}}这种“双花括号内只包含字母数字下划线”的模式不识别任何带表达式的模式。纯对象字面量如{{ color }}在 JSX 里本身很少见遇到的话我会在模板里把对象字面量改写成变量引用或者先预处理成一个字符串变量。到目前为止这个约定对外部模板编辑者最友好也最容易讲清楚。4.3 并发写文件时目录不存在早期版本我用了Promise.all并发写多个文件生成的模块同时包含组件文件、类型文件、测试文件理论上并发是没问题的。但实际跑的时候有个诡异的报错ENOENT: no such file or directory。排查后发现是test.tsx要写到__tests__目录而这个目录在父目录创建之前还没建好。虽然我写了一个ensureDirectory函数但它是异步的每个写文件任务都调了一次。当多个任务并发执行时它们可能同时检查目录并得到“不存在”的结果然后同时去创建最后先创建的任务把目录建好了后创建的任务却因为文件系统层面的竞争拿到错误状态。这个不是每次必现但一旦出现就让人很困惑。解决办法有两个方向一是先收集所有需要创建的目录去重后统一按顺序创建再并发写文件二是干脆取消并发改用串行循环。对于 t3code 这种单次生成文件数量在 3 到 5 个的场景串行写文件的性能损失微乎其微但可靠性提升巨大。我现在选的是串行简单、不需要等目录构建完再开始写入。4.4 交互式命令在 CI 环境被卡住t3code 支持交互式问答这本是好事但如果你在 CI 或自动化脚本里调用它就会出问题prompts 在非 TTY 环境下没有输入来源进程会一直挂着最后超时。更隐蔽的是很多人的本地终端虽然看起来是正常运行的但在 IDE 的集成终端里也会遇到类似卡住。解决方法是在入口处判断环境变量。我约定CItrue时跳过所有交互问题直接使用参数里的值或者使用模板的默认值。同时提供一个--yes参数强制非交互模式。这个设计对任何 CLI 工具都适用不要等到用户吐槽你的工具在流水线里跑不动才补。// src/prompts.ts export function shouldSkipPrompts(options: GenerateOptions): boolean { return Boolean(process.env.CI) || Boolean(options.yes); }4.5 npm 缓存导致模板不更新这个是我自己开发 t3code 时遇到的严格来说不是工具代码的 bug而是开发体验问题。每次改完模板执行npm link把本地命令链接到全局结果命令行为还是旧版。排查到最后发现是 npm 的缓存和 link 机制在某些版本下有延迟尤其是模板文件被打包进 dist 目录之后旧文件没有及时失效。现在的习惯是开发时直接用tsx src/index.ts跑不依赖 npm link发布前再 build 一次确保模板文件也被复制到 dist 目录。相关地package.json里的files字段必须把templates目录显式包含进去否则发布到 npm 后模板丢得一干二净。5. 实测效果模板生成与手工编写的差距5.1 一组直观的对比数据我用一个真实需求做了测试新增一个叫OrderList的组件包含基础表格、空状态、加载状态、zod schema、以及一个 mock 数据文件。手工写的话我的速度大概是 12 到 15 分钟因为要从旧项目里找到类似组件再逐段复制、改变量名、调整 import。用 t3code 跑一遍从输入命令到文件落地耗时约 8 秒剩下需要手工调整的只剩业务字段和事件处理逻辑。这个对比不是说自动生成的代码比人写得好而是说它能帮你省掉最枯燥的那一部分。相当于你不需要每次都从画草图开始而是直接拿到一个标注清晰的骨架把心思放在真正需要设计的地方。5.2 生成后的文件长什么样以生成一个hook为例模板内容如下// templates/hook/hook.ts.tpl import { useCallback, useState } from react; /** * {{description}} hook */ export function use{{Name}}() { const [state, setState] useState{{StateType}} | null(null); const reset useCallback(() { setState(null); }, []); return { state, setState, reset }; }执行t3code g hook --name useUserProfile后输出的文件就是import { useCallback, useState } from react; /** * useUserProfile hook */ export function useUserProfile() { const [state, setState] useStatestring | null(null); const reset useCallback(() { setState(null); }, []); return { state, setState, reset }; }如果你打开最终文件完全看不出它是模板生成的因为模板内容本身就是“正常代码 少量占位符”的结构。这和我前面说的设计目标是一致的生成物要比生成过程更值得关注。5.3 性能瓶颈与适用边界t3code 生成文件本身非常快真正的瓶颈在于模板数量的维护。模板越多维护成本越高。我建议不要把任何“只出现过一次”的代码结构固化成模板至少要等到同一个结构出现三次以上才值得为它写一个模板。另外一个边界是t3code 适合生成结构性很强的代码比如 CRUD 路由、基础组件骨架、hook 模板不太适合生成强业务逻辑的代码比如复杂的权限判断、多层次的数据转换。这类逻辑写成模板只会让模板变得极其臃肿最终失去意义。我自己目前维护的模板只有十来个但覆盖了日常开发里 80% 的重复工作。这个平衡点非常重要模板库不是越大越好而是越精准越好。6. 后续扩展从个人工具到团队基建6.1 增加自定义模板路径t3code 目前支持从内置模板目录加载模板我计划下一步支持用户自定义模板路径。具体实现很简单增加一个.t3coderc配置文件允许指定templatesDir如果这个目录存在优先使用用户自定义模板不存在则回退到内置模板。这样一来每个团队可以在不修改包代码的情况下维护自己的私有模板库。配置文件用 JSON 还是 YAML我的建议是 JSON。原因是在 Node.js 里读取 JSON 不需要额外依赖而且 t3code 的配置项很少JSON 足够清晰不需要引入 YAML 的灵活性和复杂性。6.2 支持从数据库读取模板片段这个想法是后加的。模板文件是静态的但实际项目里有很多规则是动态的比如根据不同的业务表一个 CRUD 模块需要生成不同的字段和类型。与其把几十个字段写死在模板里不如让 t3code 支持一个“元数据驱动”的模式你在交互式问答中传入一组字段定义模板里通过{{#each fields}}来渲染每个字段会渲染出对应的 input、table column、zod schema 片段。要实现这个就需要在模板引擎里重新引入循环能力。我计划在一个独立的dynamic生成模式下支持而不是把内置模板全部改造避免破坏现有稳定性。6.3 接入 lint 和格式化生成代码之后免不了还要手动跑一遍 prettier。与其要求用户生成后再执行不如 t3code 在写完文件之后自动跑一次prettier --write。但这里有个体验问题如果用户在未安装 prettier 的项目里执行 t3code会报错。所以我的做法是生成结束后检测项目根目录是否有 prettier 配置和依赖有则执行没有则跳过并给出提示。实测来看这个自动格式化步骤非常加分。很多用户评价工具“顺手”不是因为工具本身功能多而是因为生成的代码直接符合项目的格式规范不用再手工调整。7. 最终复盘一些真实的经验和建议整个 t3code 从第一个能用的版本到现在中间经过了三次比较大的重构。第一次是模板引擎从 handlebars 换成自研占位符第二次是交互模式加了一堆默认值和 CI 检测第三次是错误处理把各种边界情况捋清楚。每次重构都不是因为闲着没事而是真实使用中遇到了不舒服的点才动手改。最后分享几个自己反复验证过的经验工具的核心不是“自动写代码”而是“统一代码风格和结构”。它的价值一半在模板里一半在约定里。模板不要追求一次写完要允许自己维护。同一个结构至少出现三次再写模板否则就是在过度设计。命令行的参数设计要克制。能用交互解决的就不要加参数参数越多用户越不愿意用。对外输出错误信息时一定要区分“用户用错了”和“工具出 bug 了”这两类处理方式完全不同。这类小工具最难的不是实现而是坚持维护。一个工具写出来三个月不用基本就废了反过来只要每天都在用它就会自己慢慢长得越来越顺手。