资讯详情

t3code:解决T3 Stack样板代码困局的增量代码生成器

📅 2026/10/28 23:24:18 | 华诺云谱 👁 阅读
t3code:解决T3 Stack样板代码困局的增量代码生成器
第一次听到 t3code 这个项目名是在一个全栈 TypeScript 交流群里。有人问T3 StackTypeScript Tailwind tRPC写起来确实爽但每加一个数据模型就要手动补 zod schema、tRPC router、服务端 handler、客户端 hook这套样板代码什么时候能自动生成我当时回了一句等有人把脚手架做到“增量”这个粒度。后来我自己动手写了 t3code就是为了解决这个痛点。它不是 create-t3-app 那种初始化工具而是一个面向已有 T3 项目的代码生成器——你给它一个数据模型它帮你把从数据库到前端 hooks 的整条类型安全链路补齐。这篇文章就把 t3code 的完整设计思路、实现细节、踩坑记录和团队落地方式都摊开讲。如果你正在用或者准备用 T3 全家桶这篇文章应该能帮你省下不少重复劳动。1. t3code 到底解决什么问题T3 Stack 的样板代码困局1.1 T3 Stack 的类型安全是双刃剑T3 Stack 最吸引人的一点是它把类型安全从数据库一路贯穿到浏览器端。Prisma 定义模型 → tRPC 自动推断输入输出类型 → React Query 在客户端拿到完全一致的 TypeScript 类型。写起来确实有种“全链路都是自己人”的踏实感。但这份踏实是有代价的。代价就是每新增一个业务实体你需要手动维护至少四层代码的同步Prisma schema 里的模型定义zod schema用于 tRPC 输入校验tRPC router包括鉴权、查询、变更逻辑客户端 hooksuseQuery / useMutation 的封装这还只是最基础的部分。真到了业务里你可能还需要为分页参数、排序字段、过滤条件各写一套类型并且确保它们和 Prisma 生成的类型完全对齐。一旦某层漏改一个字段TypeScript 编译器在 dev 阶段通常不会报错因为很多地方用了宽松类型或 any等到线上请求跑起来才开始抛运行时错误。这就是典型的安全感溢价类型系统本来是为了提前暴露问题但维护多层同步代码本身又成了新的问题来源。1.2 create-t3-app 解决不了的“中途增量”问题很多人会下意识反问不是有 create-t3-app 吗注意create-t3-app 是一个初始化工具它解决的是“从零到一”的问题。项目刚创建那一刻它确实帮你搭好了基础目录、数据库连接和示例 router。但项目进行到第三周、第六个月业务模型已经增加到十几个时create-t3-app 帮不上任何忙。我统计过自己一个中等规模项目里手动新增一个Post模型平均要花多少时间。清单如下在schema.prisma里定义模型执行prisma migrate和prisma generate新建src/server/api/routers/post.ts手写createTRPCRouter结构为每个入参手写 zod schema尤其是update接口要处理“部分字段更新”的类型在根appRouter里手动 import 并 merge 新 router在src/hooks或组件目录里封装 React Query hooks确认所有页面的调用路径都被覆盖这一套流程熟练的话也要 20 到 30 分钟不熟练的能磨蹭一晚上。更麻烦的是这个过程极容易遗漏第 4 步——router 忘了注册。忘了的结果不是编译报错而是运行时一行 “No router found” 提示排查起来很崩溃。1.3 t3code 的定位约定优于配置的增量生成器所以 t3code 的定位从一开始就非常清楚它不是初始化工具而是增量生成器。它不做 create-t3-app 已经做得很好的事情只负责解决“项目中间态”的重复劳动。核心思路是约定优于配置——按既定目录规则、命名规则、router 注册规则把样板代码自动补齐。对比维度create-t3-appt3code使用时机项目初始化项目全生命周期核心动作创建工程模板增量生成/注册/校验是否覆盖已有代码否是安全合并是否需要幂等不适用必须幂等解决的痛点冷启动配置繁琐新增模型需要手写全套链路一句话概况create-t3-app 让你能跑起来t3code 让你在持续加功能时不掉进样板代码的泥潭。2. 技术栈选型与整体设计为什么我用 Node 脚本而不是插件框架2.1 先定边界生成器只在开发时运行做这类工具第一步不是选模板引擎而是划边界。t3code 明确不做运行时的事情它不参与编译不生成任何在浏览器或服务端进程里执行的依赖全部代码只在你开发时跑一次输出普通文本文件。这个定位直接影响技术选型。我见过有人把代码生成器做成 VS Code 插件有人做成 web 界面还有人做成大型配置平台。都有价值但对 t3code 这个体量来说太重了。CLI 工具是最合适的形态安装简单、可脚本化、便于 CI 集成、没有 GUI 状态残留。你把它挂在 package.json scripts 里就是工作流的一部分。2.2 模板引擎的选择EJS 还是手写拼接生成代码本质上就是“数据 模板”。我一开始也想省依赖直接用字符串拼接。比如这样const routerTemplate import { createTRPCRouter } from ${apiTrpcPath}; import { ${name}Router } from ./${name};;写三五个模板还能忍一旦生成逻辑复杂起来——条件判断、循环遍历字段、处理可选类型——字符串拼接就变成一团乱麻。尤其是字段列表的循环渲染手写map().join()虽然能工作但可读性极差模板一长你就很难发现哪一行漏了缩进。后来我换成 EJS。理由很务实支持% %控制流和% %输出逻辑和文本分离语法足够简单团队任何人半天能上手单文件模板天然支持复杂的嵌套结构对比下来方案条件逻辑循环渲染可读性维护成本字符串拼接手动 if/ternarymap().join()差高EJS原生语法原生好低Handlebars需 helper原生较好中最终选了 EJS。文件扩展名.ejs配合ejs.renderFile使用代码量小逻辑集中。2.3 目录结构与命令设计t3code 的项目结构按命令拆分每个命令只做一件事t3code/ ├── bin/ │ └── t3code.ts # CLI 入口命令分发 ├── src/ │ ├── commands/ │ │ ├── init.ts # 初始化配置文件 │ │ ├── gen.ts # 核心代码生成 │ │ └── check.ts # 校验生成结果是否与模板一致 │ ├── generators/ │ │ ├── zodGenerator.ts # 生成 zod schema │ │ ├── routerGenerator.ts # 生成 tRPC router │ │ ├── hookGenerator.ts # 生成 React Query hooks │ │ └── registry.ts # 路由器注册、文件合并 │ ├── parsers/ │ │ ├── schemaParser.ts # 解析 prisma schema │ │ └── typeMapper.ts # Prisma 类型到 zod 类型映射 │ └── utils/ │ └── fileUtils.ts # 文件安全合并、幂等处理 ├── templates/ # EJS 模板目录 │ ├── zod.ejs │ ├── router.ejs │ └── hook.ejs └── t3code.config.ts # 项目自定义配置命令只有三个原则也三个init只生成配置gen只做增量check只读不改。任何复杂的交互式流程、可视化编辑一律不做保持工具足够小、足够专注。2.4 解析 Prisma Schema 的策略正则匹配加约定解析代码生成器最忌讳的就是对上游工具的内部结构做过度依赖。Prisma 官方有 AST抽象语法树解析能力但各版本之间接口变化很大直接依赖 AST 会把 t3code 锁死在特定 Prisma 版本上。所以 schemaParser 走的是“正则匹配 约定解析”的路线。核心逻辑如下用正则定位model 模型名 { ... }整体块在这个块内部逐行提取字段字段名、类型、可选标记?、数组标记[]过滤掉开头的块级配置把结果塞进一个ModelDefinition结构体这段解析逻辑用代码看更清楚export function parseModels(schema: string): ModelDefinition[] { const models: ModelDefinition[] []; const modelPattern /model\s(\w)\s*\{([^}]*)\}/g; const fieldPattern /^\s*(\w)\s([\w\[\]?])\s*(?:\w)?.*$/gm; let match: RegExpExecArray | null; while ((match modelPattern.exec(schema)) ! null) { const name match[1]; const body match[2]; const fields: FieldDefinition[] []; let fieldMatch: RegExpExecArray | null; while ((fieldMatch fieldPattern.exec(body)) ! null) { if (fieldMatch[1].startsWith()) { continue; } const type fieldMatch[2].replace(?, ); fields.push({ name: fieldMatch[1], type, optional: fieldMatch[2].includes(?), isArray: fieldMatch[2].includes([]), }); } models.push({ name, fields }); } return models; }这种方案对 95% 的常规模型完全够用。唯一处理不了的是特别复杂的嵌套关系——比如自关联、复合主键、视图模型。我的处理策略是生成器只处理标准 CRUD 链路遇到关系字段直接跳过或只生成一个占位注释提醒开发者手动补逻辑。约定好边界工具才可靠。3. 核心功能落地一条命令生成完整 CRUD 链路3.1 输入与产物清单t3code 的使用体验被刻意设计得非常简单。你在项目根目录执行npx t3code gen Post它会做四件事序号产物目标路径说明1zod schemasrc/server/schemas/post.zod.ts入参校验包含 create/update 两种形态2tRPC routersrc/server/api/routers/post.tsfindMany / findUnique / create / update / delete 五个标准接口3React Query hookssrc/hooks/usePost.tsusePostList、usePostDetail、useCreatePost、useUpdatePost、useDeletePost4router 注册src/server/api/root.ts自动 import merge 新 router注意gen命令还支持多个模型一起生成比如npx t3code gen Post Comment Tag内部会按依赖顺序逐个处理并做统一的注册更新。这一步对真实项目中“一次加三四个表”的场景很实用不用逐个敲命令。3.2 从模型到 zod 校验类型映射规则zod schema 生成的核心是一张类型映射表。Prisma 中的标量类型和 zod 校验器之间并不是一一对等需要精心设计const typeMap: Recordstring, string { String: z.string(), Int: z.number().int(), Float: z.number(), Boolean: z.boolean(), DateTime: z.date(), Json: z.unknown(), };这里最值得说两句的是Json类型。Prisma 的Json字段在数据库里可以是对象、数组、字符串甚至null运行时你根本不知道里面装了什么。早期版本我生成z.object({}).passthrough()效果不好因为调用方传什么乱七八糟的结构都校验不出问题。后来统一改成z.unknown()至少在类型层面逼着你显式处理它。可选字段的处理同样关键。Prisma 的?既可能代表“数据库可空”也可能代表“更新接口中不需要传”语义不对等。所以 create 和 update 用两套生成策略create接口数据库必填字段要求必传可空字段做成.optional()update接口所有字段都包一层.optional()但额外加一个.refine()校验禁止空对象提交这样生成出来的 schema 业务语义是清晰的不会出现“创建一个对象却一个字段都不允许填”的尴尬。3.3 router 与 handler 的生成逻辑router 模板里最核心的部分是五个标准接口的结构。由于 tRPC 的 input 直接接 zod schema代码会非常规整import { createTRPCRouter, publicProcedure, protectedProcedure } from /server/api/trpc; import { postCreateSchema, postUpdateSchema } from /server/schemas/post.zod; import { prisma } from /server/db; export const postRouter createTRPCRouter({ list: publicProcedure .input(postListSchema) .query(async ({ input }) { const { cursor, limit, orderBy } input; return prisma.post.findMany({ take: limit, skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor } : undefined, orderBy, }); }), detail: publicProcedure .input(postDetailSchema) .query(async ({ input }) { return prisma.post.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(postCreateSchema) .mutation(async ({ input, ctx }) { return prisma.post.create({ data: { ...input, authorId: ctx.session.user.id }, }); }), update: protectedProcedure .input(postUpdateSchema) .mutation(async ({ input }) { const { id, ...data } input; return prisma.post.update({ where: { id }, data }); }), delete: protectedProcedure .input(postDetailSchema) .mutation(async ({ input }) { return prisma.post.delete({ where: { id: input.id } }); }), });注意这里有两个设计细节。第一个list接口用 publicProcedure 还是 protectedProcedure不是拍脑袋定的而是读取t3code.config.ts里每个模型的访问策略配置。第二个create接口强制注入ctx.session.user.id作为authorId这依赖一个约定——每个模型定义里要有author关系字段。没有这个字段的模型走普通创建逻辑。约定可以缺省但缺省时必须给出明确提示。3.4 安全的文件合并幂等插入与标记注释生成器的难点不在“生成全新文件”而在“把内容安全地并进已有文件”。最典型的场景就是根root.ts里的 router 注册。如果每次执行都清空重写你的手工改动就全丢了如果用简单字符串替换又会重复插入。t3code 的做法是标记注释 幂等策略。生成的 router 文件顶部统一带一行溯源注释// t3code-generated — do not edit manually在合并root.ts时先检查目标文件里是否已有t3code-generated区块没有才插入。插入位置固定为// t3code: router-insertion-point标记之后。重复执行同一命令第二次会直接跳过输出一句“已存在跳过注册”。文件合并的核心逻辑如下export function insertRouterImport(rootContent: string, importStatement: string): string { const marker // t3code: router-insertion-point; if (!rootContent.includes(importStatement)) { return rootContent.replace(marker, ${importStatement}\n${marker}); } return rootContent; }这套逻辑配合的另一个保障是--dry-run参数。执行npx t3code gen Post --dry-run时工具只打印将要写哪些文件、做哪些修改不真正落盘。我先跑 dry-run再用git diff看一眼改动没问题再真正执行。手改的东西丢了git 还能捞回来。3.5 为什么必须强调幂等幂等不是技术洁癖是保证生成工具能进入工作流的底线。如果一条命令执行两次会产生两套重复代码你就永远不敢把它放到自动化流程里。t3code 从第一版就把幂等作为硬性测试用例同一输入执行两次第二次必须零 diff。这里的收益不只是“命令安全”更重要的是一旦你有了幂等能力就可以把生成动作挂到 pre-commit hook 上。每次提交前自动跑一遍发现模型定义和生成代码不一致就自动修复。后面第 5 章细说这里先记住一个结论没有幂等就别谈自动化。4. 实测踩坑类型推断失败与重复注入的排查全链路4.1 事故 A生成的 router 一直报“找不到类型”第一个比较严重的坑出现在 t3code 早期。我在一个分支上给模型加了一个Tag关联字段跑完prisma migrate后直接用 t3code 重新生成对应 routerTypeScript 编译直接报了一串错误Property tags does not exist on type GetResultPost, ...第一反应是 t3code 生成的代码有 bug。但我打开生成的文件逐个对比字段名、类型、大小写全部正确。然后我尝试自己手写一遍相同逻辑——照样报错。这时候才反应过来问题根本不在代码生成而在 Prisma Client 缓存。4.2 根因Prisma Client 没重新生成缓存模板快照过期排查链路是这样的确认schema.prisma里确实有tags字段确认prisma/schema.prisma和数据库迁移状态一致检查node_modules/.prisma/client的生成时间——发现它压根没更新手动执行npx prisma generate再跑tsc错误消失这个问题本质上是我的操作顺序问题。Prisma 的规则是schema 变了必须重新生成 client编译器才知道新类型。这和 t3code 没有关系。但这里暴露了 t3code 一个真正的设计缺陷——它读取模型定义时用的是缓存快照不是实时解析。具体说t3code 早期版本为了避免频繁解析 schema会把模型定义缓存到node_modules/.cache/t3code/modelCache.json。当你在一个分支执行生成切到另一个分支后schema 文件已经变了但缓存没失效生成器拿到的还是旧分支上的字段列表。这个问题在团队协作时特别致命因为每个人本地缓存的 schema 版本都不一样。修复方案生成器每次执行都直接从schema.prisma实时解析不做任何模型级缓存。缓存只用于编译产物这类确实需要加速的内容解析 Prisma schema 本身的耗时在毫秒级根本没有缓存的必要。4.3 事故 BappRouter 里重复插入两个相同 router第二个坑更隐蔽。我当时为了提高“兼容性”在registry.ts里保留了两套插入策略一套是标记注释插入另一套是兜底的字符串替换。结果就是同一个 router 被插入两次root.ts里出现两个一模一样的 import 和两个postRouter的 merge。复现过程很典型第一次执行t3code gen Post标记注释策略正常插入手动把某个区块的注释删掉比如 IDE 自动格式化弄丢了一行第二次执行标记注释策略找不到锚点走兜底替换逻辑兜底逻辑只判断 import 语句是否已存在忽略了 merge 语句的存在性结果 import 去重了merge 却被插了第二遍这个 bug 告诉我一个道理任何兜底逻辑都会在某天成为新的 bug 来源。与其维护两套合并策略不如把所有文件合并统一收敛到一套基于 AST 的插入逻辑上。后来我干脆把所有插入动作都改成了“先找标记注释找不到就报错并提示人工处理”彻底删掉了“智能兜底”。宁可报错不要猜猜错的后果远比报错严重。4.4 防回归措施快照测试与 diff 校验踩过这两次坑之后我把回归防线建了起来。第一层是快照测试。t3code 自己维护了一个测试集内置一个迷你示例项目。每次跑npm test会用最新代码在示例项目里执行完整的生成流程然后把所有生成结果和仓库里固定的快照文件做对比任何输出差异都会导致测试失败。这样一来模板、解析器、生成逻辑任何一处改动导致的行为变化都会被立刻发现。第二层是 diff 校验。t3code 的check命令会做这样一件事遍历所有已生成的t3code-generated文件用当前模板重新生成一遍再和磁盘上实际存在的文件做比对。有差异就列出 diff 明细并以非零状态退出。这个命令天然就是为 CI 准备的。作用我多说一句它本质上是把“生成代码不可手改”这条约定变成可机器执行的检查。手改过的生成文件下次check必亮红牌。5. 团队化落地把 t3code 嵌入日常工作流5.1 package.json scripts 与提交钩子工具只有进入工作流才能持续产生价值。t3code 推荐的做法是把三条命令挂到 npm scripts 里{ scripts: { gen: t3code gen, gen:dry: t3code gen --dry-run, gen:check: t3code check, postinstall: prisma generate t3code check } }注意postinstall这行。它保证任何人拉完代码、装完依赖立刻知道仓库里有没有“生成代码与模板不一致”的问题。配合 lint-staged 的话还可以把gen:check放进 pre-commit 钩子每次提交前自动检查涉及的生成文件有没有被手工改过。我用 lint-staged 的配置大致长这样{ lint-staged: { src/**/*.ts: [eslint --fix, prettier --write], prisma/schema.prisma: [t3code gen --dry-run t3code check] } }意思是修改了 schema 文件准备提交时先 dry-run 展示会发生什么再 check 确认没有问题最后才允许提交。5.2 自定义模板的扩展设计不同团队对代码风格、目录结构、注释规范的要求差异很大。t3code 从设计上就允许你覆盖它默认的模板。具体规则是项目根目录建一个t3code/templates/文件夹放入同名 EJS 文件即可覆盖内置模板。比如你希望 router 文件默认带/lib/logger的日志记录就往t3code/templates/router.ejs里加一段初始化逻辑。命名约定和内置模板一一对应zod.ejs、router.ejs、hook.ejs。配置层面则支持指定 schema 文件路径、router 目录、hooks 目录等基础参数。让团队可以只管自己的风格工具负责把重复的骨架搭好。5.3 CI 中强制执行的“不可手改”规范前面说过生成代码顶部有// t3code-generated标记这行注释不是装饰是给 CI 当证据用的。CI 里加一步npx t3code check如果某个开发者手改了生成文件又没有重新生成这条命令会直接失败。它可能影响发布流程。对团队来说“生成代码不可手改”这个约定比任何 coding style 都更硬。有人问手改生成文件和重新生成到底有什么区别——区别在于一致性。手改一次下次模板一更新手改的内容就消失了你还得重新手工补回来。正确做法是要么改模板要么改配置改完重新生成让所有代码永远出自同一个源头。5.4 从代码生成走向架构约束的设想t3code 目前只约束“这一层代码怎么生成”但我已经在规划它的下一阶段把检查能力从代码生成扩展到架构边界。比如可以增加一个t3code verify命令在工作流里做更多约束禁止服务端代码 importsrc/hooks/下任何内容杜绝 client hook 泄漏到 server禁止页面组件直接调用prisma必须走 tRPC router禁止prisma查询里出现大范围的select: true强制指定字段这些规则本质上比简单的代码生成更有价值。生成器保证“新增代码符合架构”验证器保证“所有人的代码始终符合架构”。一进一出架构纪律就闭环了。当然这些都是后续版本的事。目前 t3code 的核心能力已经足够在真实项目里用起来踩过的坑也基本填平了。最后聊点个人体会。最开始写 t3code 只是为了不让自己反复复制粘贴后来做深了才意识到这种工具真正发挥威力的场景不在个人项目而在一个五到二十人的团队里。团队越大手动同步样板代码的成本越高出错的概率也越大。把“重复劳动”交给机器把“模型设计”和“业务逻辑”留给工程师这才是 T3 Stack 类型安全链条真正能兑现价值的方式。如果让我给准备接入 t3code 的团队一条建议就是先跑--dry-run再开git diff确认生成的每一个文件你都看得懂再放它进自动化流程。你亲手检查过的生成结果比任何工具承诺都可靠。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑