做全栈项目这两年,我最大的感受是:真正消耗精力的并不是业务逻辑本身,而是大量的重复工作。加一个页面要先改路由,建一张表要先写模型,前后端接口要小心翼翼地保持字段一致——这些事情每换一个项目就要重来一遍,而且每次都会因为细节差异引入新的 bug。t3code 是我为了解决这类问题维护的代码生成工具,它的核心思路很简单:把 T3 技术栈(TypeScript、Tailwind CSS、tRPC)项目中那些高度模式化的部分抽象成模板,通过命令行交互自动生成。这篇文章我会从项目定位、架构选型、核心功能实操到踩坑记录,完整拆一遍这个工具的来龙去脉。
1. 项目定位与要解决的问题
1.1 从"复制粘贴"到"一键生成"
我见过太多团队新项目初始化是这么干的:找个人翻出三个月前的老项目,复制一份,全局搜索替换项目名,然后把里面早就该删掉的试错代码一起带过来。看起来省了时间,实际上是把历史包袱原封不动地搬进了新项目——老的依赖版本、废弃的 API、已经不生效的 lint 规则,全都成了隐患。
t3code 想解决的就是这个问题。它把项目初始化从"复制粘贴"变成了"带着约束生成":你告诉它项目叫什么名字、用什么数据库、要不要带用户鉴权模块,它按照内置的模板和约定,生成一套前后端类型安全、结构标准、可以直接跑起来的代码骨架。它不是一个全量框架,而是一层薄薄的脚手架,核心价值在于让重复工作变得可预期、可审计、可复用。
1.2 T3 理念与工具的核心价值
先说 T3 这个概念。它通常指一套组合:TypeScript 提供类型系统,Tailwind CSS 负责样式,tRPC 让前后端接口调用像调用本地函数一样安全。这套组合最大的特点是类型安全贯穿全链路:后端怎么定义 API,前端就有对应的类型提示,字段写错了编译期就报错,而不是等到联调才发现。
t3code 并不是把这三样东西绑死,而是把这类项目里高频出现的模式沉淀下来。比如新增一个数据模型,通常要动五类文件:数据库 schema、接口校验、服务端路由、前端 API 封装、页面表单。手写这五处很容易漏掉一处,而代码生成器的优势就在这里——它一次把五处都改好,并且每个文件都按团队约定的风格输出。这个工具很适合两类人:一是刚接触 T3 生态、想快速搭起可运行项目的开发者,二是项目已经跑起来、每天要写大量相似 CRUD 业务代码的团队。
2. 整体架构与技术选型
2.1 CLI 工具的骨架设计
t3code 本身是一个 Node.js CLI 程序,入口命令设计成两个层级:t3code init负责初始化新项目,t3code g <type>负责在已有项目里生成模块。这种命令风格参考了社区里成熟的脚手架工具,对使用者来说学习成本很低:记不住参数也没关系,执行不带参数的t3code init会进入交互模式,用方向键和回车就能完成选择。
CLI 的代码结构分成三层:命令解析层、模板解析层、文件落地层。命令解析层做参数校验和选项解析,模板解析层负责定位模板目录、读取配置、渲染内容,文件落地层处理冲突检测、目录创建、格式化。这三层分开之后,新增一个命令不需要动模板引擎,新增一种模板类型也不需要改命令解析,维护成本低很多。这也是我在设计工具时最看重的一点:生成器的耦合度如果太高,后面每加一个功能都会带出一堆问题。
2.2 模板引擎与配置机制
模板渲染我选了 handlebars,没有用 EJS,也没有用 JSX 作为模板语言。原因很实际:handlebars 的模板语法足够表达变量插值和简单条件判断,但禁止写复杂的业务逻辑,天然地逼着模板作者把逻辑放在 meta.json 配置里而不是藏在模板代码中。这看起来是限制,实际上是对可维护性的保护,团队里新成员改模板时不容易写出让人看不懂的东西。
每个模板目录里放一个 meta.json,它定义了模板需要的变量、默认值、以及生成后的钩子动作。以生成一个 CRUD 模块为例,meta.json 里会声明modelName、fields、withAuthPage这些变量,生成后钩子会提示是否执行prisma migrate、是否安装额外依赖。把变量和动作都配置化之后,模板本身就很纯粹,主要是文件骨架,谁来看都知道这个模板会产出什么。
2.3 技术栈选型的取舍
工具自身为什么要用 Node + TypeScript,而不用 Go 或 Rust?最核心的原因是生态。t3code 生成的用户项目本身就是 TypeScript 技术栈,工具和项目同构意味着团队可以做贡献的时候不需要跨语言,而且 npm 分发可以直接用npx调用,用户不用额外安装任何运行时。
这个取舍在工具类项目里经常被低估。很多开发者在做一个脚手架时会想"我要用最快的语言重写它",但在生成器的场景里,性能瓶颈根本不在模板渲染这几毫秒,而在生态丰富度、扩展能力、贡献门槛。我也想过用 esbuild 把工具编译成单文件再分发来提升启动速度,实测下来启动时间从 400ms 降到 150ms 左右,体验确实变好,但这不是架构级的选择,只是构建层的优化。
3. 核心功能拆解与实操
3.1 初始化一个完整项目
先讲最常用的初始化流程。假设我现在要开一个新项目,执行:
npx t3code init my-app --stack next-trpc-prisma这个命令会先检查当前环境里的 Node 版本,低于 18 会直接警告;然后进入交互模式,让我选择数据库类型(SQLite 还是 PostgreSQL)、是否启用 NextAuth 鉴权、包管理器(npm / pnpm / yarn)。选择完成后,工具开始生成项目文件、安装依赖、初始化 git 仓库,并在最后打印一份启动说明。
生成的目录结构大致是这样的:
my-app/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── components/ │ ├── pages/ │ │ └── api/trpc/[trpc].ts │ ├── server/ │ │ ├── api/ │ │ │ └── root.ts │ │ └── db.ts │ ├── styles/globals.css │ └── utils/ │ └── api.ts ├── tailwind.config.js ├── .t3code.config.ts └── package.json这类模板文件都是经过验证的可运行组合,不是简单地把官方文档的示例粘在一起。比如 tRPC 的server/api/root.ts里已经挂好了健康检查路由,前端utils/api.ts里配置好了 React Query,api/trpc/[trpc].ts里配置好了 Next.js 的 API 路由转发。用户不需要自己查文档拼装,第一次npm run dev就能看到页面。
3.2 生成业务模块与数据模型
项目跑起来之后,最常用的命令是生成模块。比如我想做一个订单功能,数据模型有id、userId、amount、status四个字段,执行:
t3code g module order -f amount:number status:string工具会做四件套的事情:
- 在
prisma/schema.prisma里追加Order模型,并生成对应的OrderStatus枚举(如果检测到 status 字段); - 生成一份 zod 校验 schema,用于服务端和前端共用;
- 生成一个完整的 tRPC router,包含
list、detail、create、update、delete五个标准接口,每个接口都带有入参校验和错误处理; - 生成前端的 API 调用 hook,以及一个基本的列表页和表单页。
生成完成后,工具会用 prettier 和 eslint 对新增文件做格式化。这段流程我刻意做得比较"霸道":不让用户选要不要格式化,因为既然是团队协作,代码风格统一比个人偏好更重要。
3.3 路由与页面结构自动装配
生成 tRPC router 之后,需要把它挂到 root router 上,这一步如果手做很容易漏。t3code 的做法是扫描src/server/api/root.ts,找到appRouter定义的地方,自动注入 import 语句并注册新路由。默认只是追加,如果新模块名和现有路由冲突,会停下来让用户确认,而不是静默覆盖。
页面结构的装配也是自动的。比如生成order模块,会创建src/pages/orders/index.tsx和src/pages/orders/[id].tsx,同时如果项目里已经配好了导航菜单组件,工具会尝试在菜单配置里追加入口。自动装配解决的最大痛点不是省几行代码,而是让"加一个模块"这件事从需要理解路由注册逻辑,变成了执行一条命令,新手也不会做错。
3.4 配置文件的组织方式
每个项目生成时都会附带一个.t3code.config.ts,它是后续所有生成命令的依据:
import { defineConfig } from 't3code' export default defineConfig({ srcDir: 'src', templateDir: '.t3code/templates', styleEngine: 'tailwind', packageManager: 'pnpm', routerFile: 'src/server/api/root.ts', modelFile: 'prisma/schema.prisma', })这套配置的解释很简单:告诉工具模板藏在哪、路由文件在哪、数据模型文件在哪、样式方案是什么。团队如果对目录结构有特殊要求,只需要改配置而不需要改工具代码。我见过不少脚手架把目录结构写死在代码里,导致团队想调整目录结构时只能去改源码,这完全违背了脚手架应该"帮你做事而不是绑架你做事"的原则。
4. 实操过程与关键实现
4.1 命令行交互的实现要点
交互层用的是 prompts 库,它支持单选、多选、确认、输入等常见控件。实际开发中比较容易被忽略的是参数校验时机:用户既可能通过--force直接传参,也可能在交互模式里手输。t3code 的处理是让配置值、命令行参数、交互输入三者的优先级依次递增,配置里定义好的默认值在用户没有显式输入时生效,这样可以保证同样的输入在任何环境下都产出同样的结果,方便 CI 场景下的无人值守生成。
另外一个值得写的细节是--dry-run。这个参数会在不写任何文件的情况下把将要生成的文件列表打印出来,包括每个文件的目标路径和内容摘要。每次批量生成前我都会先用 dry-run 看一眼,确认不会误覆盖已有文件再真正执行。这个习惯帮我避免过好几次因为模板变量命名错误导致的批量覆盖事故。
4.2 模板渲染与变量注入
模板渲染的核心是变量的构建。一个可靠的生成器必须能从多个来源收集上下文:meta.json 里声明的字段信息、用户在命令行输入的参数、项目本身的状态。比如生成 tRPC router 时,工具会读取项目里的package.json判断是否安装了 zod,如果没有就把 zod schema 的生成逻辑降级为简单的类型别名。这保证了生成出来的代码一定是当前项目装得上的代码,不会出现模板写得很美但依赖都没有的尴尬。
命名转换是一个容易翻车的细节。同一个orderItem,数据库模型要写成OrderItem,文件路径要用order-items,前端变量要用orderItemData。我在工具里内置了一套命名转换函数,从 camelCase / PascalCase / kebab-case / snake_case 之间互相转换,模板变量统一采用{{ modelNamePascal }}这种语义化命名方式。新加模板时不用记转换函数名,只要在 meta.json 的变量定义里声明派生规则即可。
4.3 文件合并与冲突处理
文件生成过程中最怕出现的是覆盖用户已有代码。t3code 的默认策略是:目标文件不存在就直接写入;目标文件存在但内容与将要生成的内容完全一致就跳过;内容不一致时如果没加--force就会报错并列出差异。
对目录类的操作,比如生成页面时可能要创建多层嵌套目录,工具会采用"只创建缺失层级"的方式,不动已有的目录和文件。生成完成后会执行一次 prettier --write 和 eslint --fix,这两步在 CI 里会占用一点时间,但对本地体验没有影响。合并.gitignore时采用追加缺失条目的方式,不会重构整个文件。这套策略让生成器在"足够主动"和"足够保守"之间找到了平衡点。
注意:生成前一定要跑一次
git status确认工作区干净。工具虽然有冲突检测,但如果模板有 bug,误覆盖的情况仍然可能发生,干净的工作区意味着任何意外都可以用 git 恢复。
5. 常见问题与排查实录
5.1 生成后的依赖安装失败
这是用户反馈最多的一类问题。新项目初始化时会执行依赖安装,很多人卡在node-gyp编译阶段,尤其是项目里带了sharp、bcrypt这类原生模块的时候。常见原因有三个:Node 版本和模块要求的 node-gyp 版本不匹配、网络源不稳定导致下载中断、使用 pnpm 时幽灵依赖导致模块找不到。
我的建议是:生成项目时如果依赖安装失败,不要反复重试同样的命令,先删掉node_modules和锁文件,然后用pnpm install --shamefully-hoist重装一次。如果还不行,就把原生模块逐个排查,确认本机是否缺少编译工具链。另外我会在设计模板时尽量少引可能触发编译的依赖,能选纯 JS 实现就不选原生绑定。
5.2 tRPC 路由未自动注册
自动注入 router 失败的情况,十有八九是项目里改了根路由文件的位置或者文件名。默认情况下工具通过.t3code.config.ts里的routerFile定位文件,但这个字段如果没配置,工具只能靠约定路径兜底探测,探测失败就会静默跳过注册,只生成 router 文件本体。
遇到这种情况,最直接的排查方式是查看生成日志里是否出现router registration skipped提示。如果有,就在配置文件里显式填上routerFile的路径。如果填对了路径还失败,那多半是模板输出的 import 路径用了错误的相对路径,检查生成的 router 文件和 root router 的目录层级关系即可。这类问题一般五到十分钟能定位,不建议在代码里做太多"智能"处理,因为路径推断在复杂项目里永远是不可靠的。
5.3 Tailwind 样式类名冲突与页面样式失效
新生成的页面打开后如果有结构但没有样式,第一反应不是去改模板,而是去查tailwind.config.js里的 content 配置。Tailwind 只会扫描 content 字段匹配到的文件,如果模板生成了src/modules/orders这种目录但没有把它加进 content globs,那新页面里的类名一个都不会生效。
t3code 在处理这个问题时,默认会把src下所有子目录都配进 content,用的是./src/**/*.{ts,tsx}这种宽泛匹配。这样确实会带来一点构建性能开销,但对大多数项目来说是值得的。如果团队对构建性能有极致要求,可以把生成目录显式追加到 content 里,而不是用完全宽泛的匹配,但一定要记得:每新增一个模块目录就同步更新配置,否则样式白天没问题、加了模块就突然丢了。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 初始化时依赖安装卡住 | 网络源不稳定、原生模块编译失败 | 换 registry,清理后重装 |
| 生成了 router 但接口 404 | 根路由未注册 | 检查routerFile配置与自动注入日志 |
| 新页面没有样式 | Tailwind content 未包含生成目录 | 更新tailwind.config.js的 content globs |
| 生成内容与自己改动冲突 | 模板变量或目标路径重合 | 用--dry-run预检并合入改动 |
| pnpm 下运行时缺依赖 | 幽灵依赖 / 提升策略不同 | 使用shamefully-hoist重装依赖 |
这个表是我在项目维护过程中逐步积累的,每次有用户带着问题来,我都会先把问题记录到速查表里再排查,这样同样的问题就不会来第二次。生成器类工具的用户画像差异很大,有人很懂业务但未必熟脚手架,一份清晰的排查表比文档里的长篇大论有用得多。
6. 我能看到的扩展方向与个人心得
6.1 从本地模板到远程模板源
目前 t3code 的模板都放在项目本地,好处是离线可用、没有分发延迟,坏处是团队如果想用统一模板,只能通过 git 仓库同步.t3code/templates目录。我正在做的扩展是支持远程模板源:在配置里声明一个 git 仓库地址,命令执行时自动拉取模板到本地缓存,这样团队模板可以集中维护,更新一次所有人受益。
这个扩展的技术难度不高,真正要想清楚的是版本管理。远程模板若是没有版本概念,今天写好明天用户更新后生成的结果可能截然不同。我计划参照语义化版本的思路,给模板包加templateVersion字段,生成产物头部自动标注版本号。代码生成与依赖管理一样,可复现性必须放在第一位,否则工具的确定性就没了。
6.2 生成器的插件化设计
当前的架构里,内置的 CRUD 生成器是特权模块,它知道太多文件路径和组装逻辑。下一阶段我打算把所有生成器都改写成插件,核心框架只负责解析模板、渲染、落盘,业务类型的组装逻辑交给插件实现。插件协议已经想好了:每个插件提供name、metaSchema、render(ctx)三个部分。这里metaSchema用来声明这个插件需要哪些输入变量,render返回一份文件描述数组,框架拿到数组后统一做冲突检测和格式化。
插件化的收益不只是让别人可以写第三方生成器,更重要的是让核心越变越薄。工具的核心代码越少,越难出现"改一个功能牵动一片"的情况。这也是我维护 t3code 到现在最大的体会:生成器本质上是一个减法工具,它不应该尝试理解业务,只需要把重复的决策路径固化住,剩下的交给开发者。
6.3 给想引入代码生成器的团队三个建议
先说第一个建议:不要一开始就追求大而全。很多团队看到生成器就想着把所有工作流都自动化,结果模板越写越重,最后难维护就直接弃用。更稳妥的路径是只挑一个高频场景做试点,比如新表 CRUD 生成,跑一个月看效果再决定是否扩展到其他场景。自动化工具的信任度要靠小步快跑积累。
第二个建议:生成的代码要被当成普通代码对待。我见过一些团队把生成出来的文件全部提交到代码库,但从来不 review,甚至 lint 报警也不处理。这其实是本末倒置。生成器只是把重复劳动自动化了,生成的代码仍然需要走 code review、跑测试、随版本演进。不要被"这是工具生成的"这句话迷惑,工具生成的不等于一定是对的。
第三个建议:定期清理模板里的死分支。模板很容易出现为某个历史需求留下的条件分支,那些逻辑可能已经没人用,但一直留在模板里。每次重读模板时,遇到不确定是否还有人用的分支就去代码库里搜一下引用,没有就删掉。模板和普通代码一样会腐化,而且因为用的人少(通常只有几个维护者),腐化速度比业务代码更快。
我在实际维护过程中最坚持的一个原则是:代码生成器本身也在制造维护成本,所以它必须让生成结果的收益显著大于模板维护的投入。与其做一个能生成一切但没人敢改动的庞然大物,不如做几个精心维护、覆盖面小但极其可靠的模板。每次有用户过来提问,我都会先把问题复现一遍,然后打开模板逐行对照,这个动作虽然慢,但让我对这个工具生成出来的每一个文件都有底气。如果你也想在自己的项目里引入类似的生成流程,我的建议是从最简单的场景开始,让工具先跑起来,再逐步扩大边界。工具真正好用的状态,是你在写代码的时候几乎忘记它的存在,但每次新建模块它都在那里,安静地把脏活干完。