资讯动态

T3Code:schema驱动的全栈代码生成器,破解样板代码困局

发布时间:2026/10/27 7:22:48 来源:尧图企业网站定制
上个月接了一个内部管理系统的重构业务逻辑不算复杂但我在项目初始化上耗掉了整整两天。搭Next.js、接Prisma、配tRPC路由、写zod校验、再给每个实体建一套react-query的hooks……这些基础架子重复了不知道多少次之后我做了T3Code一个以T3技术栈为底座的全栈代码生成器。这篇文章把我从设计到踩坑再到实测的过程完整记录下来想用生成器解放双手、又不想被生成器绑架的人应该能从中找到点参考。1. 起点被搭架子逼疯之后我决定自己写一个代码生成器1.1 先回答T3Code到底是什么这个问题T3Code是我基于T3技术栈做的一个全栈代码生成器。T3这个词在圈内一般指TypeScript、Tailwind CSS和tRPC这三件套的合称后来大家习惯把Next.js也算进去因为它几乎成了T3应用的默认宿主框架。T3Code做的事很简单你给它一份描述数据模型的配置它一次性生成Prisma数据模型、zod校验规则、tRPC路由、react-query的hooks以及基础页面组件。很多团队其实已经有类似的东西比如根据OpenAPI规范生成前端接口代码、根据数据库表反向生成CRUD。但T3Code的定位和它们不太一样它不追求生成100%的业务逻辑而是把所有模式化但容易出错的部分一次性搞定实体定义、字段校验、路由注册、类型引用、查询缓存。业务逻辑仍然手写但手写的时候你面对的不再是空白文件而是已经能跑通的骨架。我见过太多团队死在样板代码地狱里。一个User实体你要写Prisma模型写zod schema写tRPC router写前端hooks写列表页和表单页每个环节之间还有类型引用关系。少写一个字段前端报错类型对不上tsc直接罢工。人肉维持这套链路的成本极高而且是纯消耗——它不产生任何业务价值只是维持系统能编译而已。1.2 真正推动我的三个受够了时刻第一个受够的时刻是项目初始化。每次新开一个全栈项目光是搭目录结构、配置环境变量、接好数据库连接、跑通一个最小CRUD接口就要半天。而且每个人的搭法都不一样有人喜欢把router拆细有人喜欢集中在一个文件里后面接手的人光是理解目录结构就要花不少时间。第二个受够的时刻是实体变更。业务上改一个字段比如给订单表加一个状态字段你以为只是改Prisma schema错了。你要改数据库模型、改zod校验如果要限定枚举值、改tRPC的输入类型、改前端表单的选项、改列表页的展示逻辑。五个文件联动漏一个就是线上事故。这种跨层联动用搜索-替换根本不可靠只有从源头生成才能保证同步。第三个受够的时刻是新人上手。团队里来了新人让他们照着文档搭一个CRUD功能熟悉技术栈他们搭出来的东西在风格上五花八门命名、注释、错误处理方式完全没法统一。代码评审变成教你做人现场。后来我想明白了与其靠评审纠偏不如把规范写进生成器里——生成器输出的就是规范本身。2. T3Code的设计骨架一份schema驱动四层代码生成2.1 配置驱动一份schema.yaml描述整个应用的实体关系T3Code的核心输入是一份schema.yaml。它不是编程语言就是数据描述团队里不懂TypeScript的人也能看懂。我拿一个典型的订单系统举例entities: User: table: users fields: id: type: uuid primary: true email: type: string unique: true validation: email name: type: string passwordHash: type: string hidden: true orders: type: relation target: Order relation: one-to-many foreignKey: userId Order: table: orders fields: id: type: uuid primary: true userId: type: uuid index: true status: type: enum values: [PENDING, PAID, SHIPPED, CANCELLED] default: PENDING totalAmount: type: decimal precision: 10 scale: 2 items: type: relation target: OrderItem relation: one-to-many foreignKey: orderId OrderItem: table: order_items fields: id: type: uuid primary: true orderId: type: uuid productName: type: string price: type: decimal precision: 10 scale: 2 quantity: type: integer validation: min: 1 max: 999这份配置里隐含了几个关键决策主键统一用uuid避免自增主键的暴露问题金额字段用decimal避免浮点精度坑关系通过外键字段显式声明。字段级别的validation会被映射成zod规则比如quantity就生成z.number().min(1).max(999)。hidden字段用于标记绝不允许通过API返回的数据比如密码哈希。配置驱动最大的好处是所见即所得。业务人员写配置生成器产出可运行的代码任何一层出了问题改配置重新生成就行。不需要知道TypeScript的类型体操不需要记得Prisma的关系语法也不需要考虑tRPC的procedure应该用query还是mutation——这些全由生成器的规则决定。2.2 模板分层基础层、业务层、适配层不能混在一起T3Code的第二个核心设计是模板分层。我见过有人写代码生成器把整个文件内容直接拼字符串生成出来的代码确实能跑但一旦要调整输出的目录结构或命名风格就得在字符串拼接逻辑里翻来翻去找。我把模板分成三层基础层fixed templatesPrisma schema的头部、数据库连接封装、环境变量读取、全局错误处理中间件。这些内容在每个项目里几乎一模一样直接硬编码在模板里就行。业务层entity templates围绕每个实体展开的zod schema、tRPC router、hooks、页面组件。这部分占模板工作量的八成通过循环entities列表填充。适配层render functions处理不同技术选型的差异。比如有人用React Query的useQuery函数有人不用有人用Tailwind的类名有人用CSS Module。我用render函数把差异隔离在独立模块里核心模板不感知。举个例子tRPC router模板是这样的import { createTRPCRouter, protectedProcedure } from /server/api/trpc; import { {{entityName}}Schema } from ./{{entityName}}Schema; import { prisma } from /server/db; export const {{entityName}}Router createTRPCRouter({ list: protectedProcedure .input({{entityName}}ListSchema) .query(async ({ ctx, input }) { const items await prisma.{{entityName.toLowerCase()}}.findMany({ where: buildWhereClause(input.filter), orderBy: input.orderBy, skip: input.page * input.pageSize, take: input.pageSize, }); return { items, total: await prisma.{{entityName.toLowerCase()}}.count() }; }), create: protectedProcedure .input({{entityName}}CreateSchema) .mutation(async ({ ctx, input }) { return prisma.{{entityName.toLowerCase()}}.create({ data: input }); }), update: protectedProcedure .input({{entityName}}UpdateSchema) .mutation(async ({ ctx, input }) { return prisma.{{entityName.toLowerCase()}}.update({ where: { id: input.id }, data: input, }); }), delete: protectedProcedure .input({{entityName}}DeleteSchema) .mutation(async ({ ctx, input }) { return prisma.{{entityName.toLowerCase()}}.delete({ where: { id: input.id } }); }), });这里用了{{entityName}}这种Mustache风格的占位符模板语言我用的是Handlebars但核心思想不重要任何一个模板引擎都能做到。重要的是把固定部分和可变部分严格分开这样生成器的维护成本会大幅下降。2.3 为什么代码生成不仅仅是字符串拼接很多人对代码生成器的理解就是拿模板填变量再写文件这个理解只对了一半。T3Code真正复杂的地方在于依赖解析——生成的文件之间存在引用关系而生成顺序错了类型系统就崩了。还是用上面的例子。OrderRouter引用了OrderSchemaOrderSchema引用了Prisma的Order类型而Prisma的Order类型又是在prisma generate之后才从schema.prisma中生成的。这形成了一条完整的依赖链schema.yaml - prisma/schema.prisma - prisma generate - Prisma Client类型 - zod schema - tRPC router - react-query hooks - 页面组件如果生成器按照yaml里实体的顺序逐个生成文件一个靠后的实体引用了靠前实体的类型可能没问题但如果某个实体的router引用了另一个实体的类型而那个实体的zod schema还没生成tsc就会给你一长串让人欲哭无泪的报错。所以T3Code的生成流程不是遍历实体生成一次完事而是分阶段执行的准备阶段先把所有实体的元数据解析成内存对象基础层阶段生成Prisma schema并立即执行prisma generate类型层阶段生成所有zod schema路由层阶段生成所有tRPC router视图层阶段最后生成hooks和页面。每个阶段结束之后都要跑一遍tsc --noEmit或prisma validate来验证当前状态是否自洽。这个阶段化生成的设计我在初版T3Code里完全没有是踩了坑才加进去的后面专门有章节讲。3. 排错实录生成顺序引发的类型地狱我的完整排查链路3.1 现象生成的代码编译不过去报错指向不存在的模块第一次用T3Code生成一个包含三个实体的项目时我满怀期待地跑到命令行里执行npm run typecheck结果屏幕刷了一屏红色。印象最深的是这条src/server/api/routers/order.ts:2:33 - error TS2307: Cannot find module ./orderSchema or its corresponding type declarations.当时我的第一反应是生成器漏了文件。我立刻去src/server/api/routers/目录下查看orderSchema.ts明明就在那里和order.ts待在同一个目录下。既然文件存在为什么TypeScript找不到模块进一步看错误列表发现不只是order这一个实体所有实体的报错都有类似指向。我意识到这大概率不是模块缺失而是另一个更隐蔽的问题。3.2 排查思路从文件是否存在转向类型是否可用我把目光从文件系统转向了TypeScript的模块解析机制。TS2307还有一个常见的触发原因目标文件虽然存在但在TypeScript的编译上下文里因为某种原因被排除掉了。但我的tsconfig.json没有配include或exclude排除任何src目录下的文件这个猜测也被排除。然后我注意到一个细节报错信息出现在npm run typecheck里但我直接在编辑器里打开order.tsVSCode并没有报错。编辑器用的是tsserver它能找到这个模块命令行tsc却找不到。这个矛盾点很关键——同样的文件同样的TypeScript版本为什么结果不同我想到去翻生成的文件列表发现一个明显的时序问题orderSchema.ts是倒数第二批生成的而order.ts在它之前就生成了。虽然最终生成出来的目录是所有文件都在但tsc可能是增量编译模式下用了缓存的旧文件索引。也就是说命令行实际上拿到的还是orderSchema.ts不存在时的状态。验证方法很简单删掉node_modules/.cache和.tsbuildinfo文件重新跑typecheck。神奇的事情发生了TS2307消失了但新的错误又冒出来了——这次是类型不匹配。3.3 根因不只是生成顺序还有中间产物的可用性新的报错长这样src/server/db/index.ts:12:14 - error TS2742: The inferred type of prisma cannot be named without a reference to ../../node_modules/.prisma/client. This is likely not portable.还有Type import(.prisma/client).Order is not assignable to type import(prisma/client).Order.这两个错误让我彻底明白了。问题的根源是prisma generate的输出类型和prisma/client包导出的类型对不上。prisma/client默认从node_modules里读取生成代码但如果生成器在生成完Prisma schema之后没有立即执行prisma generate后面所有依赖Prisma类型的文件都还在引用旧的甚至是第一次生成的类型定义。文件顺序看起来对但底层的类型定义是旧的引用链上全是过期的快照。为了确认这个判断我在生成的目录里执行了prisma generate再跑typecheck所有错误都消失了。但这只能说明手动补一步能救回来生成器本身必须把这个步骤自动化并且严格按照依赖次序。3.4 从这次踩坑里沉淀下来的三条硬性规范排查完这个问题我把T3Code的生成流程彻底重构了一遍定了三条规矩这些规矩后来成了T3Code最有价值的资产**第一禁止一次全量生成。**不管实体有多少个生成器都按阶段执行先写Prisma schema然后执行prisma generate并等待完成再写zod schema再写tRPC router最后写hooks和页面。每个阶段内部可以并行阶段之间必须串行。**第二每个阶段结束后做一次静态校验。**生成Prisma schema后跑prisma validate生成router之后跑tsc --noEmit --incremental false。这一步会拖慢一点生成速度但能立刻暴露跨文件引用问题比全部生成完再排查高效得多。**第三模板里禁止硬编码跨模块类型引用。**比如zod schema里要引用Prisma枚举类型必须通过Prisma.EnumName这种全限定命名而不是生成一个同名的字符串枚举去碰运气。这样即使prisma generate的类型有变动错误也会明确指向Prisma客户端而不是在一堆业务文件里东一个西一个地爆炸。这段经历让我确信一件事代码生成器本质上是一个编译过程的编译器如果你不认真对待中间产物的时序问题它生成的错误会比手写代码的更难排查。因为它把人写错了变成了生成器生成错了而后者往往还带着一层机器生成应该是对的的心理滤镜。4. 生成结果实测用手工搭建做对照组差距不只是时间4.1 对照实验同一套订单模型两种方式各建一遍为了给T3Code一个能拿得出手的评估我做了一次比较正式的对照实验。选了一个包含User、Order、OrderItem三个实体、外加一套简单鉴权逻辑的项目同一份需求分别用手工搭建和T3Code生成各做一遍。环节手工搭建耗时T3Code耗时项目初始化、目录结构、依赖安装35分钟8秒Prisma数据模型与数据库迁移60分钟15秒tRPC路由与zod校验3小时35秒react-query hooks封装2小时20秒列表页、表单页基础组件2.5小时3分钟联调修复类型报错2小时5分钟一次性通过总计约10小时约5分钟这个数据说明的问题很清楚手工那边的耗时大头不在写业务逻辑而在写样板代码、查文档、修类型报错。T3Code生成的代码还远达不到不用改就能交付的程度但从零到能跑这一步被压缩到了一个几乎可以忽略的量。4.2 生成代码的质量干净比能跑重要得多只看速度容易让人觉得生成的东西能跑就算赢这是很多生成器做得烂的根源。T3Code从设计第一天就有一个原则生成出来的代码必须像有经验的人手写出来的而不是机器批量吐出来的。具体体现在几个方面**命名一致性。**手工项目里经常出现createOrder、orderCreate、CreateOrderInput这种风格混乱的命名。T3Code通过模板层统一了动词前缀和名词顺序所有mutation统一用create/update/delete所有query统一用list/get类型名称以Schema和Input作为后缀。这个约定写在模板里任何实体生成出来都是同一套命名风格。**注释的克制。**生成的代码不写一大段本文件由生成器生成请勿修改这种废话也不写每行都来一句的流水账注释。只在三个位置加注释文件头标注生成时间方便对账Prisma字段上标注业务枚举含义方便合作方理解router里标注权限语义比如protectedProcedure与publicProcedure的区别。**可读的格式化。**模板里所有代码块都用Prettier的规则预先格式化好生成的产物打开就能直接看没有那种挤成一团、需要手动格式化才能读的生成器通病。我在实际使用中补了一个细节T3Code生成的所有前端hooks都统一返回isPending、error、data三个字段而不是有些人习惯的isLoading。因为新版React Query已经用isPending替代了isLoading的语义生成器用新规范项目一开始就是新写法省得以后大规模迁移。4.3 老项目迁移时最容易忽视的细节如果你想把T3Code用在一个已经存在的项目上而不是从空目录开始有几个坑我提前帮你踩了**文件编码问题。**Windows环境下生成的模板如果使用了带BOM的UTF-8Node.js读取时会把\uFEFF当作内容的一部分生成的代码第一行会藏着不可见字符导致lint报错。我的做法是生成器统一用writeFile的utf8选项写文件并在读取模板后做一次BOM剥离。**import顺序问题。**生成器的模板里import语句的排列是按照外部依赖、内部绝对路径、相对路径的顺序排的。老项目如果开了eslint-plugin-import的ordering规则部分文件可能会触发自动修复。这不是大问题但批量生成后记得全局跑一次eslint --fix。**不要急着改生成的文件。**我见过有人生成完代码后立刻手工去改生成的文件添了几行业务逻辑然后下次手贱又用生成器跑了一遍改动全没了。T3Code默认不追踪生成文件的历史版本所以我的建议是生成文件只作为基底业务修改尽量放在独立的业务代码层或者通过改模板来落地而不是在生成结果上打补丁。5. 用T3Code这段时间我最想建议你的三件事如果要说T3Code给我带来了什么改变不是省了多少时间而是它逼我把很多我以为我懂、其实没细想的东西想清楚了。写模板的过程就像给团队立了一本活的开发规范三个实体生成了三百行代码但每一行都在表达同一个约定。这里有三件我作为过来人最想嘱咐的事**第一先别急着追求一键生成的过程。**第一次使用生成器的人最容易陷入我要把所有东西都自动化的执念。实际上把crud生成、权限骨架、校验规则这三样搞定已经能覆盖绝大多数业务项目80%的样板代码。剩下那些高度定制化的业务逻辑硬塞进生成器里只会让模板越来越笨重最后变成没人敢碰的遗产代码。**第二把生成器当成团队的规范执行器而不是代码打印机。**如果生成器和团队规范脱节它产出的东西就只是能跑的代码和手写没有本质区别。T3Code的模板里我放了命名规则、错误处理方式、分页逻辑的默认实现新人的代码风格从第一行生成代码开始就是团队风格评审压力小了非常多。**第三记得给生成器留一个逃生门。**也就是在生成完成后允许开发者指定要覆盖或跳过的文件。我在T3Code里加了一个--skip参数可以跳过指定模板比如某个实体不需要生成前端页面就把页面模板跳掉。这个设计在最初版本里没有是后来在一个项目里发现好几个实体根本不需要页面组件、却每次都要手动删文件才补上的。给工具的日常使用留一点余地它才不会变成一个逼人做机械劳动的东西。

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

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

免费获取报价 →
↑