t3code 这个名字是我最近折腾出来的一个命令行工具,本质上是一个基于 T3 技术栈的代码生成器。简单说,你执行一条命令,回答几个交互式问题,它就能在当前目录下生成一套结构完整、风格统一的组件、页面、API 路由或工具函数。做这个项目的起因很朴素:我发现自己很多时间都花在复制粘贴旧项目代码、改文件名、改变量名这些毫无创造力的机械操作上,每次新开一个功能模块,流程都一模一样。于是我想不如写个工具,把这些可重复的部分沉淀下来,顺便把团队里大家代码风格不统一的毛病也一起治了。
这篇内容适合谁?只要是平时写 TypeScript、React、Next.js、Node.js,或者正在用 T3 stack 做业务的人,都会用得上。哪怕你从来没写过 CLI 工具,也可以照着我这套思路,从零搭一个自己能持续维护的代码生成器。我会把架构设计、核心实现、踩坑过程都拆开讲,偏实战,不整虚的。
1. 项目概述:为什么需要 t3code
1.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. 核心实现:从草稿到可用 CLI
3.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): Promise<TemplateFile[]> { 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: Record<string, 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 参数校验:名字合法是模板安全的前提
前面提到模板不做转义,那么参数校验就是模板安全的底线。我定义了自己的一套校验规则,只允许字母、数字、下划线和中划线。组件名一般用 PascalCase,Hook 名用 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 的集成终端里也会遇到类似卡住。
解决方法是在入口处判断环境变量。我约定CI=true时跳过所有交互问题,直接使用参数里的值,或者使用模板的默认值。同时提供一个--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] = useState<string | 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 了”,这两类处理方式完全不同。
- 这类小工具,最难的不是实现,而是坚持维护。一个工具写出来三个月不用,基本就废了;反过来,只要每天都在用,它就会自己慢慢长得越来越顺手。