用 t3code 终结 T3 项目样板代码:从模型到全链路的工程实践 上个季度我接手了一个基于 Next.js 13 TypeScript tRPC Prisma Tailwind 的新项目也就是大家常说的 T3 全家桶。项目业务本身不算复杂但有个事特别磨人每新增一个业务模块都得手动从头到尾敲一大串样板代码——Prisma model、Zod schema、tRPC router、React Query hooks、表单、表格一个都不能少错一个类型整条链路全崩。前两个模块我还能忍到第三个模块时我实在受不了了于是开始折腾 t3code。这篇就把我这几个月的真实使用经验写出来包括它到底解决什么问题、怎么接入现有工程、核心的工作流长什么样、团队协作该怎么约定以及我踩过的几个坑的完整排错过程。如果你是 T3 技术栈的重度用户或者团队正在为“照抄上一个模块再改类型”这种低效做法发愁这篇文章应该能帮你省下不少时间。1. t3code 是什么定位不等同于脚手架而是增量代码生成器1.1 先对齐一下 T3 这个词说到 T3很多人的第一反应是 create-t3-app——那个一键生成 Next.js TypeScript Tailwind tRPC Prisma 项目的脚手架。它解决的问题是“项目从零到一”跑一条命令就能得到一个结构完整、类型安全的种子工程。但项目跑起来之后真正的日常工作是“从一到 N”今天加一个订单明天加一个商品分类后天再来个用户积分流水。这个阶段没有现成命令可敲大家的常规操作是打开上一个类似模块的代码全选复制然后全局替换字段名、类型名、注释里的业务词。听起来挺快实际上破绽百出——漏改一个字段类型对不上复制过来的校验规则忘了改表单屡屡验证失败更别说代码风格在几个模块之间逐渐漂移。t3code 在命名上很容易被误认为是 T3 的另一个脚手架但用下来你会发现它走的完全是另一条路不负责项目初始化只负责功能模块的增量生成。你给它一份领域模型定义它负责把从数据库层到 UI 层一整条链路的代码铺好。1.2 样板代码的真实成本手工复制到底贵在哪我用数据说明一下为什么手工复制不划算。以我们公司一个典型业务模块为例它包含Prisma schema 里的 model 定义与枚举对应的 migration 文件Zod input / output schema至少两套创建和更新还不一样tRPC router包含 CRUD 五个方法加上分页、模糊搜索React Query 的一套 hooksuseQuery、useMutation还有缓存的 key前端表单组件、校验错误的展示、表格列定义把这些全写完熟练的人也要 30 到 60 分钟而且大部分时间是花在“把上一份代码的字段 A 改成字段 B”这种毫无技术含量的机械替换上。机械替换的问题在于它恰好是人最容易犯错的地方。我统计过团队里代码评审中被点名的修改意见有接近四成都是“类型没同步”“字段少了一处”“枚举值拼错了”。t3code 正是冲着这部分成本去的。它把“定义一个领域模型”和“生成整套代码”这两件事拆开模型定义是你写的、需要人审的后续所有层的代码全部由生成器依据同一份定义推导出来。只要定义没错出来的代码各层之间天然一致。1.3 它的工作方式一份定义多处产出用一句话概括 t3code 的工作方式读取领域模型文件按模板批量产出代码文件。领域模型文件可以是一个符合 Zod 语法的 TypeScript 文件也可以是一个 YAML 描述团队可以按习惯选。生成器解析出字段、类型、校验规则、关联关系然后套用内置的 tRPC 模板、React Query 模板、Prisma 模板、表单模板把文件写到项目对应目录里。这个“一份定义多处产出”的模式带来的好处不只是省时间还顺手解决了一个更隐蔽的问题各层之间的不一致。手工开发时Prisma 里字段名叫createdAtZod 里写create_timetRPC 返回又改成createTime前端接的时候小心翼翼但总有漏网之鱼。而生成器的所有产出都来源于同一份定义名字、类型、校验规则天然统一这一类问题基本被扼杀在源头。2. 把 t3code 接进现有项目安装、初始化与配置逐项说明2.1 环境准备与版本选型先说环境。t3code 是一个 Node.js CLI 工具要求 Node 18 以上的运行环境因为解析 Zod schema 和 TS 类型依赖新版 TypeScript 编译器 API。包管理器我用的是 pnpm项目锁文件是 pnpm-lock.yml如果你的团队用 npm 或 yarn命令上大同小异后面我会提到对应写法。版本选型上有一点值得注意t3code 的版本号跟 TypeScript 深度绑定0.x系列会标明它支持的 TS 版本区间。项目里用的是 TS 5.4我装的就对应t3code0.3.x。不建议直接装最新版最好先看一眼 peerDependencies 和你的项目 TS 版本是否匹配不然生成出来的类型文件可能出现types不兼容的情况。这个坑我后面会详细说。安装命令很简单# pnpm pnpm add -D t3code # npm npm install -D t3code # yarn yarn add -D t3code装完之后运行npx t3code --version能正常输出版本号就说明装好了。2.2 首次初始化t3code init 做了什么接下来是接入现有工程的关键一步运行npx t3code init这个命令会在项目根目录生成一个t3code.config.ts配置文件同时创建一个默认的模板目录.t3code/templates。它不会像某些脚手架那样强行改动你的项目结构也不会生成一堆你根本不需要的文件——这算是我对它初始好感度较高的原因。init 过程中它会自动探测你项目里已有的目录约定。比如你的 Prisma schema 在prisma/schema.prisma它就会把这个路径写进配置你的 tRPC router 在src/server/api/routers它也能识别出来。探测不到的目录会使用默认值后续可以手改。也就是说接入一个已有的 T3 项目不会破坏现有结构生成文件的位置由配置决定。2.3 配置文件逐项解读初始化生成的t3code.config.ts大致长这样import { defineConfig } from t3code; export default defineConfig({ // 领域模型文件所在目录 modelsDir: ./src/models, // 生成物输出到哪个目录 generatedDir: ./src/server/generated, // 使用文件头标记标记过的文件不要手工改 managedFileHeader: generated by t3code, // 各个层级的输出路径按需覆盖 outputs: { prisma: { schemaPath: ./prisma/schema.prisma, append: true }, tRPC: { routerDir: ./src/server/api/routers, indexPath: ./src/server/api/root.ts }, reactQuery: { hooksDir: ./src/hooks }, forms: { componentDir: ./src/components }, }, // 自定义模板目录覆盖内置模板 templatesDir: ./.t3code/templates, // 生成文件后缀 fileExtensions: [ts, tsx], });逐个说下重点modelsDir你的领域模型放哪。我习惯把模型文件按业务域拆开比如user.model.ts、order.model.ts每个文件可以定义多个 model。generatedDirt3code 默认把所有生成物放一块儿的兜底目录。如果你不想逐层指定输出位置它会按层级子目录往里写。managedFileHeader这个字段很关键。它会往每个生成文件的开头注入一行标记类似// generated by t3code。配合编辑器插件或 CI 检查就能识别“这个文件是机器生成的别手工改”。我见过团队因为这个没配置导致生成文件被手工改出分叉最后 diff 都不敢看的惨状所以强烈建议保留。outputs各层的覆盖路径。T3 工程里目录约定各家不同有的用src/server有的用server/这里按实际路径填就行。templatesDir自定义模板目录后面第 6 节细讲。配置完成后跑一遍npx t3code check --config可以验证配置里填的路径是否存在、有没有权限写入。这一步别跳过我见过有人路径写错生成时文件散落到了src和server两个地方排查了半天。3. 从模型定义到全链路代码一条业务功能的生成实战3.1 定义领域模型以“商品标签”为例说再多不如跑一遍。我拿项目里一个真实的“商品标签”ProductTag模块当例子完整演示 t3code 的工作流。首先在src/models下新建一个product-tag.model.ts文件import { z } from zod; export const ProductTag z.object({ id: z.string().uuid().describe(主键 ID), name: z.string().min(1).max(20).describe(标签名称), color: z.enum([blue, green, orange, red]).default(blue), sortOrder: z.number().int().min(0).default(0), isActive: z.boolean().default(true), createdAt: z.date().nullable(), updatedAt: z.date().nullable(), }); export const ProductTagCreateInput ProductTag.omit({ id: true, createdAt: true, updatedAt: true, }); export const ProductTagUpdateInput ProductTagCreateInput.partial();这里有个设计上的细节t3code 直接复用 Zod 语法不搞一套新的 DSL。好处很明显——团队不需要额外学习成本Zod 的min、max、enum、default这些都是现有技能栈里的东西。describe()里的中文描述会被一并带到生成的注释、表单 label 和表格列头里这一招在生成前端代码时特别省事。3.2 生成 tRPC Router 与 React Query Hooks模型文件写好后运行npx t3code generate product-tag生成器会解析ProductTag相关的 Zod schema然后批量产出文件。以 tRPC 层为例它会在outputs.tRPC.routerDir下生成一个productTag.ts大致长这样import { createTRPCRouter, protectedProcedure } from /server/api/trpc; import { ProductTagInput } from /models/product-tag; import { prisma } from /server/db; export const productTagRouter createTRPCRouter({ list: protectedProcedure .input(ProductTagListInput) .query(async ({ ctx }) { return ctx.prisma.productTag.findMany({ orderBy: [{ sortOrder: asc }], }); }), create: protectedProcedure .input(ProductTagCreateInput) .mutation(async ({ ctx, input }) { return ctx.prisma.productTag.create({ data: input }); }), update: protectedProcedure .input(ProductTagUpdateInput.extend({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) { const { id, ...data } input; return ctx.prisma.productTag.update({ where: { id }, data }); }), delete: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) { return ctx.prisma.productTag.delete({ where: { id } }); }), });同时它会把 router 自动注册进root.ts新增一行productTag: productTagRouter。如果你用的是 NextAuth session 保护生成器默认按protectedProcedure生成未登录用户没有调用权限某些公开接口可以改模型配置为publicProcedure。React Query hooks 的生成也在这一步完成。它会为每个 tRPC 方法生成对应的 hook比如export function useProductTagList() { const utils api.useUtils(); return api.productTag.list.useQuery(undefined, { staleTime: 30_000, }); } export function useCreateProductTag() { const utils api.useUtils(); return api.productTag.create.useMutation({ onSuccess: () utils.productTag.list.invalidate(), }); }注意它带了一个默认的缓存失效策略创建、更新、删除成功后会 invalidate 列表缓存。这个细节比我预想的贴心因为实际开发中十个人有九个会漏写invalidate导致新增数据不刷新。3.3 生成 Prisma 模型、表单与表格接下来是数据层和 UI 层。t3code 会在prisma/schema.prisma里追加对应的 modelmodel ProductTag { id String id default(uuid()) name String db.VarChar(20) color String default(blue) sortOrder Int default(0) isActive Boolean default(true) createdAt DateTime? updatedAt DateTime? }注意db.VarChar(20)是由 Zod 的max(20)推导出来的Zod 层的约束和数据库层的约束保持一致这是手工开发时最容易被忽略的一环。模型追加后你需要自己跑npx prisma migrate dev生成 migrationt3code 只改 schema 不替你执行迁移命令这点设计合理迁移这种操作理应保留在人的控制范围内。表单和表格组件也会被生成到componentDir。生成的表单组件会集成 react-hook-form 和 zodResolver校验规则直接来自模型定义表格组件会渲染所有字段的列定义。之后你可以在组件基础上微调样式和布局核心的校验逻辑、数据绑定、缓存刷新不用重写。跑完这一步一个模块的 CRUD 链路就齐了。我用计时器比过一次手工模式从查旧代码到改完整个模块大约 45 分钟用 t3code 从写模型到全部生成完大约是 8 分钟其中 6 分钟是在纠结color枚举要不要加一个purple。4. 生成物管理与团队协作入库、模板与漂移检查4.1 生成物要不要提交到 Git我的结论是“要”用代码生成器最常见的争论就是生成的文件要不要提交进仓库。反对的人会说生成物是冗余攒个干净仓库直接忽略掉支持的人会说生成物进库之后代码评审和 diff 才有抓手。我在几个项目里尝试下来结论是T3 场景下生成物应该进 Git。原因有三第一tRPC router、React Query hooks 这些文件是运行时直接导入的它们和手写代码无缝混在一起强行 ignore 会让 IDE 跳转失效团队其他成员 checkout 后还得先跑一遍生成命令才能跑起来门槛抬高了不少。第二生成物进库后代码评审时能直观看到“模型改动对应哪些代码变化”这对新人理解项目结构特别有帮助——他们看着生成的代码就能倒推出模型定义的含义。第三为实现“生成物可评审”我要求生成器产出的文件风格与手写代码完全一致统一的缩进、单引号、尾分号、import 排序。t3code 生成完会自动跑 Prettier这算是最低限度的体面。当然提交的前提是约定好绝不手工修改生成文件。文件头有generated by t3code标记评审时看到指定范围外的改动会立刻打回。4.2 团队模板约定把特殊需求沉淀成标准t3code 内置模板覆盖了标准的 CRUD但真实业务里总有“不走寻常路”的场景。比如我们项目里的所有实体都必须有createdBy字段用于记录创建人并且删除一律用软删除deletedAt置时间戳不做物理删除。这些统一约定不该靠团队成员自觉遵守而是应该写进模板里成为生成的默认行为。我们在.t3code/templates里自定义了 tRPC router 模板把createdBy的赋值、软删除的 filter、以及findMany里默认排除deletedAt不为空的记录都内建进去。从那以后新成员即使完全不熟悉这些约定生成出来的代码也天然符合团队规范。模板目录结构类似这样.t3code/templates/ ├── router.hbs ├── react-query.hbs ├── form.hbs └── helpers/ └── format-label.ts模板的写法留到第 6 节细讲这里先强调团队层面的两个约定模板变更必须走 Pull Request 评审因为模板影响的是所有后续生成模块一个变量写错就是大面积事故。模板里禁止写死业务关键词比如“订单”“用户”这类字眼模板要保持领域无关性业务语义全部来自模型定义和describe()。4.3 CI 里的漂移检查t3code check团队协作最大的隐患不是生成的时候出错而是有人绕过生成器手工改了生成文件或者改了模型定义但忘了重新生成。这两种情况都会造成“模型定义”和“实际代码”的漂移。对付漂移我在 CI 里加了一个检查步骤npx t3code check --drift它会对所有带generated by t3code标记的文件做一次“先比对再生成”的校验把模型定义重新生成一遍代码和当前工作区文件做 diff不一致就返回非零退出码导致 CI 失败。这样“改模型忘生成”的提交会在流水线里被拦住而不是上线前夜被队友发现。实际配置到 GitHub Actions 里大概是这样的- name: Check generated code drift run: | pnpm install npx t3code check --drift跑完这个检查我会再单独跑一次npx t3code generate --all并让 CI 自动提交回生成结果这样即使有人忘了跑生成合并后代码仓库也会自动收敛。用了这套机制之后团队里“生成文件和模型不一致”的 bug 基本绝迹了。5. 排错实录我在使用 t3code 时踩过的四个坑工具再好用起来总得踩几个坑才踏实。下面四个问题是我实际遇到并且完整排查过的每个都说说现象、根因和最终解法希望能帮你跳过。5.1 坑一tRPC Router 重复注册导致类型爆炸现象第一次用t3code generate生成两个模块后npm run dev报了一长串类型错误错误信息指向 tRPC 的根路由文件说某个 router 的泛型类型不匹配。排查过程我先是以为是 Prisma 模型没迁移跑了一遍prisma generate无效。又怀疑是 Zod schema 版本问题折腾了半天。最后打开root.ts逐个对比才发现生成器往root.ts里追加注册语句时把productTagRouter注册了两遍——第一次运行时注册了一次第二次生成另一个模块时它重新扫描了目录把已有的注册行当成“新输出”又追加了一次。根因当时项目改造前手写过一部分 router文件头没有generated by t3code标记导致生成器的注册器识别不到“这段是我生成的”理所当然地再次追加。解法把root.ts恢复干净然后给文件里 t3code 管理的注册区域加上标记注释。这类区域型管理是 t3code 内置的一种机制的标配标记形如// start-managed和// end-managed。从那以后每次生成前如果改动过root.ts我会先跑一次npx t3code check --drift确认区域标记还在。教训总结一句话手动改过生成文件又没保留区域标记是生成器出各种诡异问题的头号来源。5.2 坑二Zod 枚举和 Prisma 枚举的顺序不一致现象生成的表单下拉框选项顺序和我们设计稿里要求的顺序不一致。模型里color枚举我是有意按“蓝、绿、橙、红”排列的但生成的下拉框里“红”跑到了第一位。排查过程我给模型加了console.log想看看生成器解析出的枚举顺序发现解析结果确实是我定义的顺序。那问题很可能出在 Prisma 层。打开schema.prisma一看enum Color的定义顺序被生成器排成了按字母序而表单组件是直接读取 tRPC 的枚举 schema 来渲染选项的——tRPC 的运行时会从 Prisma 客户端读取枚举顺序自然变成了 Prisma 侧的字母序。根因t3code 内部有两套枚举表示Zod 侧按模型定义顺序Prisma 侧按字母序优化因为数据库 migrate 文件更稳定而生成的表单组件在运行时调度的枚举顺序是从 Prisma 侧来的两者没对上。解法一是在模型定义里明确枚举值顺序并在生成的组件里禁用“自动按字母序排序”通过模型配置enumOrder: preserve解决二是如果枚举顺序只影响展示可以直接在表单组件的生成模板里写死一份选项顺序映射。两个方案里我选了第一个因为它的约束在源头不会因为后续手改组件而丢失。5.3 坑三Windows 环境下 glob 匹配路径分隔符现象团队里一位同事在 Windows 上跑t3code generate --all生成器报“找不到匹配模型文件”但同样的路径在 macOS 上一切正常。排查过程远程连到他的机器上看模型文件明明在src/models下文件权限也没问题。对比了两边跑的命令日志发现路径打印出来不一样macOS 打印的是src/models/product-tag.model.tsWindows 打印的是src\models\product-tag.model.ts。根因t3code 内部扫模型文件用的 glob 库在 Windows 上返回反斜杠路径而后续解析器按正斜杠匹配规则做处理正反斜杠混在一起后匹配失败。这个问题的排查难点在于错误信息里的路径看起来“没什么不对”但实际就是匹配不上。解法当时有两个选择一是改代码把反斜杠统一转成斜杠这也是我给上游提的修复建议二是在配置文件里给 modelsDir 传相对路径绕开绝对路径的反斜杠问题。我们团队先在配置里统一了路径写法做临时缓解等新版本发布后升级解决。这里给个实用建议如果团队里有 Windows 开发者生成类工具的路径配置尽量统一写相对路径能少踩很多坑。5.4 坑四React Query 的缓存 key 类型收窄问题现象生成的useProductTagListhook 在用data.map()遍历列表时TypeScript 报错说data可能是undefined。我确认staleTime和路由都配置正确请求也确实有返回但类型就是过不了。排查过程我把生成的 hook 文件摊开看发现useQuery的返回类型没问题问题出在 t3code 生成 react-query 模板时默认加了initialData相关的条件类型判断而项目里 React Query 版本是 5。React Query 5 的useQuery返回类型里data默认是undefined联合类型和 React Query 4 的行为不一致。根因内置模板是针对 React Query 4 写的升级到 5 后泛型签名变了生成代码里的条件类型推断失效导致data类型收窄失败。这本质是版本兼容问题不是业务代码问题。解法直接在自定义模板里更新 react-query 的 hook 写法改成显式接收 tRPC 返回类型而非依赖条件类型推断。改完后重新生成一次类型立刻正常。这件事给我的教训使用生成工具时工具版本的升级不是孤立事件它依赖链上的 React Query、Next.js、TS 版本都要整体对齐最好把版本矩阵写进团队的 README。这四个坑的根因归结下来其实就四类区域管理标记被破坏、模型与数据库枚举的语义不一致、跨平台路径差异、依赖版本升级导致模板失效。后两类属于环境的坑前两类则和工具的使用规范强相关。我从第一个坑之后就给团队补了一份简短的规范生成文件不过手改、模型枚举顺序要显式声明、CI 必跑漂移检查。6. 自己写模板扩展 t3code从一个审计日志需求说起6.1 模板语法核心别学新语言记住四个元素内置模板不够用时就得自己写这也是 t3code 这类工具真正的分水岭——能不能沉淀出团队自己的生成体系。它的模板语法不是重新发明的语言而是类似 Eta/Handlebars 的标签式写法上手成本很低。核心只要掌握四样东西变量插值% it.modelName %输出模型名% it.fieldList %输出字段列表表达式% if (it.useSoftDelete) { %条件分支% for (const f of it.fields) { %循环遍历帮助函数t3code.helpers.camelCase(...)、t3code.helpers.pluralize(...)处理命名、单复数、首字母大小写数据上下文it对象里装着解析后的模型信息包括 modelName、fields、relations、configs 等我们用一个实际需求来说明模板怎么用。项目要求所有删除操作不真正删数据而是置deletedAt时间戳同时在audit_log表里记录操作人和操作类型。6.2 一个自定义模板的完整示例我们在.t3code/templates/router.hbs里维护自己的 tRPC router 模板关键的remove方法是这样写的remove: protectedProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ ctx, input }) { const now new Date(); await ctx.prisma.% it.modelLower %.update({ where: { id: input.id }, data: { deletedAt: now }, }); await ctx.prisma.auditLog.create({ data: { entity: % it.modelName %, entityId: input.id, action: DELETE, operatorId: ctx.session.user.id, occurredAt: now, }, }); return { success: true, deletedAt: now }; }),同时所有列表查询的模板里统一追加一行where: { deletedAt: null }并且select字段列表自动排除deletedAt之外的软删除相关字段展示层不需要。这样一来团队里所有模块的软删除行为完全一致不会出现“这个模块删了但列表还有旧数据”的怪现象。表单模板我们也改了。项目里所有模型都要求字段分组展示如“基础信息”“高级设置”。我定义模型时用describe(group:基础信息)这样的约定标记分组模板里读取字段描述的分组前缀把字段按组渲染成多个表单 section。这个约定实现后设计稿里的分组要求从“评审时叮嘱”变成了“生成自带”。6.3 生命周期钩子与生成后的自动处理模板之外t3code 还支持在生成流程里挂两个钩子beforeGenerate和afterGenerate。beforeGenerate我用来做模型文件预检。比如检查模型里是否包含名为id的主键字段、是否包含createdAt/updatedAt没有就直接报错终止生成把问题暴露在生成之前。这比生成一个残缺模块然后运行时报错要便宜得多。afterGenerate我用来做格式化与索引更新。生成完后自动执行 Prettier、ESLint 修复另外把新增的 router 在根文件里的 import 顺序按字典序排好保证提交出来的 diff 干净好看。我们甚至把 Prisma 的 migration 生成提示也挂在钩子里生成完跑一次prisma format输出变更摘要引导开发者自己执行prisma migrate dev。写模板的这一个月里我的体会是**模板不是一次性投资它是团队规范的“可执行版本”。**手写规范文档贴 wiki 上没人细看但模板写在代码里每次生成都会强制执行。这段时间我们把团队里原先零散在 PR 评论里的各种要求逐条沉淀进了模板新成员上手的速度明显加快因为他们能看到一套前后一致、自带约束的代码生成体系。最后再分享一个我个人的习惯每次生成完新模块我会先跑git diff看一遍生成物再对照模型定义走一遍核心链路。这既是给生成器“验货”也是让自己清楚工具生成的边界在哪里。生成器能替你省掉重复劳动但对业务语义的理解和校验该自己把关的还是不能省。如果你也在 T3 技术栈里被 CRUD 样板代码折磨过不妨试着把 t3code 这类增量生成器接进团队的工作流里从一个小模块开始验证跑顺之后你会明显感觉到那些曾经耗在机械替换上的时间终于能花在真正有挑战性的业务问题上了。