资讯动态

t3code:用代码生成消灭 T3 栈 CRUD 重复劳动

发布时间:2026/10/28 7:12:15 来源:尧图企业网站定制
t3code 这名字乍一看像某个开源组件库的编号其实是我给一套 T3 技术栈代码生成工具起的项目名。T3 栈TypeScript Tailwind CSS tRPC这几年在 Next.js 圈子里相当能打但真正用起来你会发现一个尴尬事模式化代码实在太多了。每加一个数据模型就要手动铺一遍 Prisma 定义、zod 校验、tRPC 路由、React Query 的 hooks再来一张 shadcn/ui 表格页面来来回回半小时起步。t3code 就是为了干掉这半小时存在的它用一个轻量 DSL 描述模型然后一条命令把整条链路从数据库到页面的代码全部生成出来。这篇就把我的设计思路、完整实现流程、踩过的坑全部摊开讲适合正在用 T3 栈做中后台项目、又受够了重复切片代码的同学参考想自己动手做代码生成器的也完全可以照着抄作业。1. 项目整体设计与思路拆解1.1 T3 栈的痛点在哪里先回顾一下 T3 栈的前后链路。一个典型的 T3 项目里数据从数据库到浏览器要经过这么几层Prisma Schema 定义表结构zod 负责输入校验tRPC 路由承接业务逻辑React Query 管理服务端状态最后 Tailwind shadcn/ui 渲染界面。这套链路的好处是类型安全贯穿始终坏处是它太重了——哪怕只是给系统加一张“客户表”你也得在至少六个文件里做手脚schema.prisma写 model、字段类型、关联关系zod schema定义输入输出的运行时校验tRPC router写create、list、getById、update、delete这些 handlerReact Query hooks封装useQuery、useMutation页面组件表格、表单、弹窗API 输入类型因为 tRPC 要求输入必须是 zod 推导出来的类型如果你追求工程规范还可能要补上权限校验、软删除、分页参数。这些代码百分之八十长得一模一样真正要动脑子的只有业务字段本身。我算过一笔账一个中等复杂度的后台模块手写完整链路平均要 40 到 60 分钟其中至少三十分钟是在复制粘贴改名字。t3code 的核心思路就是把这些模式化代码抽象成模板业务逻辑留给你自己写机械部分全部交给生成器。1.2 t3code 的核心定位只解决“模式化”这层在设计工具之前我先给自己划了条红线不能做成那种什么都管的“低代码平台”。低代码平台听起来美好但你自定义一个枚举类型、卡一个权限逻辑都要看平台脸色最后项目烂在平台里。t3code 的定位很窄——它只负责把“模型定义”翻译成“全套基础代码”生成出来的代码完全可读、可改、可删除和手写的一模一样。你完全可以跑完生成器之后手工改任何生成文件工具不会做任何二次接管。所以我把 t3code 设计成一个一次性 CLI 工具而不是框架或库。这意味着它不参与运行时项目里没有 t3code 的依赖生成的代码是普通 T3 项目代码用常规的pnpm dev就能跑可以随时改生成结果不需要刷新缓存或同步元数据这种“生成后撒手不管”的设计让 t3code 的工具风险降到最低。哪怕哪天这个工具不维护了你手上的项目代码依然是完整的、自洽的不会像有些脚手架产物那样离开工具就没法改。1.3 为什么选择“DSL 描述 模板渲染”架构t3code 的内部架构其实很简单就三层解析层、中间层、渲染层。解析层读取用户写的模型描述中间层把这些描述结构化成内存里的 Definition 对象渲染层拿 Definition 去填充各个模板。选择这个架构而不是“直接生成字符串”是因为中间层给了你最大的灵活度。比如你想判断“这个字段是不是主键”“这个模型有没有关联到 User 表做归属校验”这些都是结构化判断在中间层里一行代码的事在字符串拼接里就是一场灾难。DSL 我选的 TypeScript 本身而不是 YAML 或 JSON。理由很实际JSON 没法写注释YAML 的缩进错误又让人抓狂而 TypeScript 直接可以用内置类型定义 DSL 的结构。你在生成器配置文件里写得爽导航、重构、类型提醒全都有生成器跑之前 TS 编译器已经帮你做过一轮校验了。2. 核心细节解析与实操要点2.1 DSL 到底长什么样t3code 的模型定义是一个 TypeScript 文件默认叫t3code.config.ts。一个最简单的模型长这样import { defineConfig } from t3code; export default defineConfig({ models: [ { name: Customer, tableName: customers, fields: [ { name: name, type: string, required: true, label: 客户名称 }, { name: phone, type: string, label: 联系电话 }, { name: email, type: string, validators: [email] }, { name: level, type: enum, options: [basic, gold, platinum], default: basic, label: 客户等级 }, { name: notes, type: text, optional: true, label: 备注 } ], timestamps: true } ] });这里每个字段的type会映射到 Prisma、zod、界面输入组件三层。比如string在 Prisma 里是String在 zod 里是z.string()在界面上默认渲染成输入框text在界面上渲染成多行文本域enum在 Prisma 里生成枚举类型在界面上渲染成下拉框。标签label用来生成表格列名和表单 label这样生成的页面基本不需要再改文案。不要小看这个配置文件的“约法三章”。字段只有type、required、optional、default、validators、label这些约定意味着生成器能做非常干净的模板判断required的字段自动带*和空值校验optional的字段自动跳过初始化赋值validators里的email、url、min、max会翻译成 zod 的对应链式方法。你不需要在模板里写一堆if去嗅探字段形态中间层已经帮你归一化了。2.2 模板引擎与生成目录结构的考量模板渲染我选了 Eta 而不是 Handlebars主要原因有三Eta 支持纯 TypeScript 表达式可以写% it.field.type enum ? ... %这种内联逻辑写起来像写 JS 而不是学一门模板语言第二是性能好生成一个模型也就几十个模板文件渲染时间用毫秒计算第三是它可以继承布局头部的 import 引用能统一管理。生成目录结构我刻意模拟 T3 项目的手写习惯src/server/db/schema.prisma // 追加 model 定义不覆盖原文件 src/server/api/routers/customer.ts // 完整 CRUD 路由 src/server/api/root.ts // 自动注册 router只插入一行 src/server/api/validations/customer.ts // zod 校验 Schema src/server/api/routers/customer.hooks.ts // 业务钩子占位文件 src/hooks/useCustomer.ts // React Query hooks src/components/customer/customer-table.tsx // 表格页组件 src/components/customer/customer-form.tsx // 表单弹窗组件 src/pages/customers/index.tsx // 页面入口注意这里没有覆盖现有文件而是“追加 精确插入”。schema.prisma是追加模型定义root.ts是定位到export const appRouter router({这一行后面插入一行 import 注册。这种手术式插入能最大程度保护你已有代码我见过太多脚手架直接重写整个文件导致用户的认证逻辑被冲掉的惨案。t3code 选择只在明确的位置动刀其余文件一律新创建。2.3 “可配置的约定优于纯约定”代码生成器最容易犯的毛病是“纯约定”——规定一套规则然后让项目去适应它。纯约定对全新项目没问题但你的项目未必都从同一个模板创建。t3code 特意加了配置化退路prismaOutputPath、routerDir、pagesDir这些路径可以覆盖生成的代码风格也有几套预设可选比如函数组件风格、const 箭头函数风格。另外一个重要的可配置点是分页方案。T3 社区常用无限滚动和传统分页两种又各有 cursor-based 和 offset-based 的讲究。t3code 默认生成的是 offset-based 的传统分页因为中后台表格这个场景用传统分页最稳妥用户也最熟悉。你要是想做无限列表配置pagination: cursor就行生成器会输出带cursor参数和hasNextPage判断的版本。所有方案都是已经验证过的常见实现不是生成器自己发明的所以生成的代码看起来就是“团队里有人手写过”的样子。3. 实操过程与核心环节实现3.1 初始化与命令设计t3code 的命令行我用 cac 实现交互部分用 prompts。安装和初始化pnpm add -D t3code pnpm t3code initinit会问你几个问题项目里 Prisma schema 的路径、tRPC router 目录位置、是否启用软删除字段、React Query 版本的偏好。这些答案会被写进t3code.config.ts的顶层配置里。另外init会检测当前项目是否使用了 App Router这会决定页面模板输出到app/还是pages/目录。我见过不少人初始化时忽略这些检测选项统统用默认值等生成完才发现路由目录不对——所以 init 这一步值得认真走一遍它直接影响生成产物的落位。生成命令是pnpm t3code gen Customer如果你配置文件里只定义了一个 model也可以直接不带参数跑生成器会全量生成。如果配置了多个 modelgen Customer可以只生成某一个这个选择我用 prompts 的下拉选择实现不用记命令参数。3.2 从 DSL 到 Prisma 的映射逻辑这是生成器里工程量最大的一段。Prisma 的语法和 zod 不同枚举、JSON、关系字段都有特殊写法。我的映射逻辑核心如下string→String db.VarChar(255)VarChar 在 MySQL 下能省索引空间text→String db.Textinteger→Intdecimal→Decimal db.Decimal(10, 2)boolean→Booleanenum→ 生成独立的enum CustomerLevel { basic gold platinum }字段类型引用它date/datetime→DateTimerelation→ 额外配置relations数组生成外键和relation指令默认带上id String id default(cuid())和createdAt、updatedAt两个时间字段。这里插一句具体的生成代码model Customer { id String id default(cuid()) name String db.VarChar(255) phone String? db.VarChar(64) email String? db.VarChar(255) level CustomerLevel default(basic) notes String? db.Text createdAt DateTime default(now()) updatedAt DateTime updatedAt map(customers) }两个细节值得说字段默认加了map把表名映射成复数下划线这是 Prisma 团队推荐的命名实践避免模型名和表名歧义字符串类型区分 VarChar 和 Text 是性能习惯MySQL 下 Text 不走索引、占空间也大不是大段内容就尽量用 VarChar。工具默认多做了这一层生成结果比新手手写的更规范。3.3 tRPC 路由与 zod 校验的生成生成 tRPC 路由的模板是整个项目里最容易写错的部分因为 tRPC v11 的server/api/routers结构里createCaller的写法在不同版本里变过好几次。我锁定的目标版本是 tRPC v11 Next.js 14输入输出都用 zod 推导。核心路由代码长这样import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; import { customerValidation } from ~/server/api/validations/customer; export const customerRouter createTRPCRouter({ list: protectedProcedure .input(customerValidation.list) .query(async ({ ctx }) { const { page 1, pageSize 10, keyword } ctx.input ?? {}; const where keyword ? { OR: [{ name: { contains: keyword } }] } : {}; const [items, total] await Promise.all([ ctx.db.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: desc } }), ctx.db.customer.count({ where }) ]); return { items, total, page, pageSize }; }), create: protectedProcedure .input(customerValidation.create) .mutation(async ({ ctx, input }) { return ctx.db.customer.create({ data: input }); }), update: protectedProcedure .input(customerValidation.update) .mutation(async ({ ctx, input }) { return ctx.db.customer.update({ where: { id: input.id }, data: input }); }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { return ctx.db.customer.delete({ where: { id: input.id } }); }) });注意这里的几个细节。第一所有 procedure 都用protectedProcedure而不是publicProcedure这样默认就带上登录校验免得生成的接口裸奔在公网。第二list 接口返回{ items, total, page, pageSize }四元组React Query hooks 和表格组件都依赖这个结构我特意没有拆成两个接口getListgetCount因为一次往返拿全分页数据是这里的最稳妥方案少一次网络请求少一份出错可能。第三所有增删改都在 mutation 语义下配合 React Query 的缓存失效机制正好。zod 校验文件也是生成的比如import { z } from zod; export const customerValidation { list: z.object({ page: z.number().int().min(1).optional().default(1), pageSize: z.number().int().min(1).max(100).optional().default(10), keyword: z.string().optional() }), create: z.object({ name: z.string().min(1, 客户名称不能为空), phone: z.string().optional().nullable(), email: z.string().email().optional().nullable(), level: z.enum([basic, gold, platinum]).default(basic), notes: z.string().optional().nullable() }), update: z.object({ id: z.string().cuid(), ...customerValidation.create.shape }) };keyword实现的是最简单的模糊搜索就查 name 字段。字段一多你可能会想要更复杂的搜索条件那就在生成后扩展这个where对象。生成器给的是稳妥的起点不替你决定业务搜索逻辑这是刻意的。3.4 React Query hooks 与页面的协作React Query 的部分我生成一对 hooksexport function useCustomers(params: CustomerListParams) { return api.customer.list.useQuery(params, { placeholderData: keepPreviousData }); } export function useCreateCustomer() { const utils api.useUtils(); return api.customer.create.useMutation({ onSuccess: async () { await utils.customer.list.invalidate(); } }); }核心经验在keepPreviousData和invalidate。分页表格翻页时只要用了keepPreviousData切页的时候界面不会闪空白是老表格体验和新前端体验的分水岭。而每次 mutation 成功后list.invalidate()保证新增、编辑、删除后列表自动刷新。这两行是我在所有手写项目里都会加的所以模板默认带上。表格组件则是直接调用useCustomers拿数据渲染列定义也是根据 DSL 字段自动生成的enum字段会有字典映射label成为列标题。页面入口就是一层Card Table的壳表单弹窗组件在编辑时回显数据。说实话页面生成这块是最受噪音影响的。别人的设计偏好比如要不要在表格里直接编辑千差万别所以我收敛成最简单也最常见的“右侧弹窗表单 底部保存”模式这是中后台项目里接受度最高的交互不接受反驳的时候最少。3.5 依赖与安装的保障生成完成后t3code 还会做两步收尾检查项目依赖里是否有tanstack/react-query和 tRPC client 相关包缺失会直接提示pnpm install命令给出再跑一次prisma format和prisma generate确保生成的 Prisma schema 已经格式化且 client 类型是最新的。这两步是很多人脚手架产物跑不起来的头号原因——代码文件生成了但依赖没装、Prisma client 没重新生成类型一塌糊涂。4. 常见问题与排查技巧实录4.1 tRPC 路由注册后白屏或 404这是最高频的问题。root.ts的自动插入虽然把 router 注册进去了但如果你项目里的root.ts和标准 T3 模板结构有差异比如做了模块化拆分、用了文件夹索引插入位置就可能不对。我后来给gen命令加了一个--dry-run参数只打印将要修改的文件和插入位置不实际写文件。检查root.ts的 diff 永远比事后调试快。另外生成完一定要重新跑pnpm prisma generate之前手写 Prisma 时形成的肌肉记忆在生成场景里照样有效——Prisma client 没有刷新十有八九报的内联类型和prisma/client不一致。4.2 zod 和 tRPC 的“输入类型巧妙不一致”tRPC v11 里mutation 的input是从.input()推导的。如果你给 update 接口传了create的 shape 合并 id前端 hooks 里就要保证传入的对象也有 id。这个看起来简单但生成代码中表格行编辑时表单只拿了当前行数据字段名若对不上就会野蛮报错。所以表单组件的初始化值我统一做了pick处理只取 zod schema 里需要的字段多余字段全部丢掉。这一条写成模板后再也没有出现过“TypeScript 不报错但运行时校验炸掉”的诡异问题。4.3 枚举字段在页面上显示原始值生成的enum字段在 React Query 里是字符串表格组件直接渲染就是basic、gold这种英文原始值。第一次生成后我看着满屏英文无语。后来在 DSL 里给枚举加了labels映射{ name: level, type: enum, options: [basic, gold, platinum], labels: { basic: 基础客户, gold: 黄金客户, platinum: 铂金客户 }, label: 客户等级 }表格列和表单下拉框都会用这个映射渲染。生成器还自动生成一个CustomerLevelLabel字典常量页面上做统计图表之类也能复用。这个看似小功能其实是生成页面“能直接用”和“还要改一圈”的分界线。4.4 模板调试的笨办法也最有效模板出问题时我第一反应不是去查 Eta 的文档而是直接在中间层打印Definition对象。所有模板的数据源都来自这一个对象它长什么样模板里就能拿到什么。写一个临时脚本把 config 解析后的 JSON 打出来对照着改模板比用眼睛硬读Eta模板快得多。后来我把这个调试脚本固化成了t3code inspect命令跑一下就把中间层结构导出成 JSON模板开发效率直接翻倍。4.5 常见问题速查表现象可能原因处理办法生成的页面 tRPC 报错“input is undefined”React Query hooks 没传参数或传了多余 key对照生成的 list schema 只传page/pageSize/keywordPrisma 迁移报字段类型冲突已有表结构和生成的 Prisma 定义不一致先prisma db pull同步再手动补齐差异表格分页跳第 3 页数据是对的但 URL 没有页码分页状态只在内存里没有同步到 URL在页面模板里把 page 状态并入 router query 参数即可生成的代码格式化风格和项目不一致项目用的是 prettier 的不同配置gen命令后自动跑prettier --write或统一pnpm format软删除字段不生效DSL 没开softDelete: true打开后生成器会自动在所有 query 里加deletedAt: null条件5. 实测数据与个人心得5.1 三周时间、六个模块的体感对比我在一个真实的后台项目里用 t3code 生成了六个模块客户、订单、商品、分类、优惠券、操作日志。每个模块都是标准的 CRUD 加字段校验。记录下来的对比很直观纯手写一个完整模块大约 45 分钟用 t3code 生成加必要的手工定制大约 8 到 12 分钟。省出来的时间主要花在 Prisma 迁移调试、表单细节样式这些真正需要人判断的地方。类型安全也没有打折扣生成产物和手写代码一样共享同一套类型定义编辑器里的跳转、重构体验完全一致。5.2 我做对和做错的事做得对的把“生成后撒手不管”立成铁律t3code 不做运行时依赖大半年过去也没出现“生成器更新了旧项目怎么办”的问题。做错过的最早一版想支持 MySQL 和 PostgreSQL 两套 Prisma 方言结果枚举、自增、Decimal 的差异把模板拖得很复杂。后来砍到只支持 MySQL其余靠配置覆盖复杂度立刻降下来。吸取的教训是代码生成器的核心复杂度应该集中在“结构转换”上而不是到处给数据库方言打补丁。另外一句掏心窝的话生成器代码本身不值得过度设计但要非常重视“失败路径”。我遇到过用户把模型定义里的字段写错类型生成器直接崩在模板渲染中段留下半截文件。后来在渲染前加了一层校验把所有模型定义全部 parse 完才开始写盘并保证写盘是事务式的——先写临时目录全部成功后再移动到目标位置。这样哪怕定义写错了也不会污染现有项目。做脚手架的底线就是就算用户输入再离谱也不能弄坏人家的原项目。5.3 下一步的扩展方向目前 t3code 还只覆盖了资源型 CRUD我接下来的计划是支持更复杂的关联字段比如一对多的时候在表单里自动生成可选择的下拉框多对多的时候生成穿梭框组件。再往后想做的是一键生成 tRPC 的protectedProcedure权限装饰配置让不同角色跑不同的生成策略。代码生成器这个方向最大的乐趣在于你每抽出一种模式整个团队以后就不用再重复一次劳动。把日常最高频的十几种 CRUD 形态固化下来比单独写任何一个小页面都值。

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

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

免费获取报价 →
↑