资讯动态

t3code:为T3技术栈自动生成功能代码的CLI工具

发布时间:2026/10/26 23:38:45 来源:尧图企业网站定制
先说结论我花六周业余时间做了一个叫 t3code 的命令行代码生成器专门服务 T3 技术栈TypeScript Next.js Tailwind CSS tRPC Prisma的日常开发。它解决的问题非常具体——在 create-t3-app 初始化完项目之后你每加一个功能模块都要手动在 tRPC router 里写 procedure、补 zod 校验、建 React Query hook、改前端类型导入这堆机械劳动完全可以自动化。这篇文章把我的立项思路、三层架构设计、核心实现和踩过的坑完整写出来适合正在做全栈 TypeScript 开发、或者想自己写 CLI 工具的人参考。1. 从复制粘贴模板到自动化生成t3code 要解决的真实痛点1.1 三个最典型的重复场景我先说三个我每天都在经历的场景你大概率也遇到过。第一个是新增获取用户列表这类查询接口。正常流程是在server/api/routers下找到对应 router 文件写一个getAllprocedure定义 input 的 zod schema写 Prisma 查询然后回到前端新建一个useUserListQueryhook再手动引trpc.user.getAll.useQuery()最后别忘了把 hook 挂到组件里。这套动作看起来简单但每一步都有出错空间zod schema 和前端类型对不上、Prisma 返回的Date类型没处理好、router 文件注册顺序错了导致循环依赖。第二个场景是给现有资源加一个更新接口。你需要在同一个 router 里追加updateOneprocedureinput 通常要复用之前定义的UserUpdateInput还得处理partial和required的交叉类型。手工改的时候最烦的是在长长的 router 定义里找到合适的位置插入稍不留神就把逗号写丢类型检查直接红一片。第三个场景是前端调用链。T3 项目里前端通过trpc.xxx.method.useQuery()调用返回类型是自动推导的这确实很爽。但爽的前提是你每次都写对了 procedure 的路径字符串——一旦 router 嵌套层级深了路径拼错这种低级错误会高频出现。这三个场景本质上都是同一件事一个功能链路里 80% 的代码是固定模板只有 20% 是业务差异。我统计了一下自己手动写一个带 zod 校验的完整 CRUD 链路平均要改 5 到 7 个文件花费 15 到 30 分钟。而机器生成只需要几秒钟。1.2 现成方案为什么不够用可能有人会问市面上不是有 Plop、Hygen 这些代码生成工具吗我也试过。Plop 和 Hygen 的核心模型是模板文件 用户输入变量它们擅长的是复制一个文件、替换占位符。但 T3 项目的痛点不是生成一个新文件而是在既有文件里精确修改。比如往router/user.ts里插入一段 procedure、往pages/api/trpc/[trpc].ts旁边确认没有遗漏、往hooks/index.ts里追加一行 export——这些操作需要理解代码结构而不只是替换字符串。更关键的是现成工具不懂 T3 的约定。create-t3-app 生成的目录结构有明确的惯例router 文件放哪、hook 文件怎么命名、输入输出类型怎么导出。一个通用的生成器不会知道这些约定它生成的代码要么风格不统一要么文件位置不对反而比手写更累。1.3 t3code 的定位不是脚手架是功能生成器所以 t3code 从一开始的定位就跟脚手架划清界限create-t3-app 负责项目的出生t3code 负责项目成长过程中每个功能的落地。它有两种使用方式。第一种是命令行交互模式你运行t3code add user-list它通过对话问你几个关键问题资源名、要查询还是增删改、是否需要分页然后自动完成所有文件修改第二种是定义文件模式你写一个 JSON 描述比如为用户模块生成一个带分页的查询接口和一个创建接口t3code 一次性生成整套链路。第二种方式特别适合用在团队里把业务定义沉淀成配置新人照着配置就能把后端接口和前端页面快速搭起来。2. 整体架构设计模板、AST 与文件补丁三层结构2.1 为什么不做纯字符串拼接t3code 第一版我确实用的是字符串拼接当时图快直接拼模板字符串往里塞变量。结果第一个用户反馈就来了生成的代码里出现了重复导入、空行乱跑、甚至语法错误。问题根源在于字符串拼接只解决生成不解决定位和修改——你不知道应该在哪个位置插入、怎么处理已存在的 import、怎么避免重复声明。后来我把架构重新设计成三层这是整个项目最值得分享的部分。第一层是模板层负责生成一段全新的、独立的代码。比如生成一个新 hook 文件、生成一个全新的 router 文件这类场景没有既有结构要合并模板直接产出完整内容就行。第二层是AST 定位层负责在既有文件里找到正确的插入点。第三层是补丁层负责把新代码以最小粒度的 diff 形式应用进去支持 dry-run 预览和回滚。2.2 模板层为什么我用 TypeScript 函数而不是 Handlebars第一反应大家都会想到 Handlebars 这类模板引擎但我在实际对比后放弃了理由很实际模板引擎在类型安全上是零。一个{{camelCase name}}写错了地方渲染出来的代码要通过人工肉眼检查才能发现错误。t3code 的模板层全部是手写的 TypeScript 函数每个文件对应一个render函数比如renderQueryHook.ts。这些函数接收一个类型良好的FeatureSpec对象包含资源名、字段、操作类型等返回字符串。类型检查器保证了你传入的参数结构正确而模板函数内部的逻辑可以用单测覆盖。效果是生成代码的形状在编译期就确定了运行时只需要处理业务变量。举个例子生成一个 React Query hook 的模板函数长这样// templates/renderQueryHook.ts import { FeatureSpec } from ../types/feature; export function renderQueryHook(spec: FeatureSpec): string { const hookName use${capitalize(spec.resource)}ListQuery; const trpcPath trpc.${spec.resource}.list.useQuery; return import { trpc } from ../utils/trpc; export function ${hookName}(input: ${spec.inputType}${spec.queryOptions ? , options : }) { return ${trpcPath}(${spec.queryOptions ? input, options : input}); } ; }这里的关键是所有标识符hookName、trpcPath都在函数里用工具函数统一生成不会出现模板引擎那种大小写没转对导致方法名错误的低级事故。2.3 AST 层用 ts-morph 而不是自己写 parser第二层我用的是ts-morph一个基于 TypeScript Compiler API 的上层封装。为什么用它因为 TypeScript 项目里的文件结构靠正则表达式是不可靠的——注释、字符串、模板字符串里的内容会干扰正则匹配而且文件一旦有格式化差异正则就崩了。ts-morph 把源文件解析成语法树你可以直接做这些操作找到某个 function declaration、在 namespace 下插入一个 method、重命名一个 identifier、读取 import 列表并去重。t3code 的核心修改逻辑全部建立在 ts-morph 之上。以向 router 文件插入 procedure为例关键代码是import { Project, SyntaxKind } from ts-morph; const project new Project(); const sourceFile project.addSourceFileAtPath(routerPath); const routerExpr sourceFile.getFirstDescendant( (node) node.getKind() SyntaxKind.CallExpression node.getText().includes(createTRPCRouter) ); // 找到 router 的定义对象 const routerConfig routerExpr.getFirstChildByKind(SyntaxKind.ObjectLiteralExpression); // 在对象里插入新属性 routerConfig.addProperty(list: ${procedureCode}); // 确保被标记为需要保存 sourceFile.saveSync();AST 层带来的最大收益是插入位置的确定性。无论用户项目的代码格式是 2 空格还是 4 空格、有没有分号ts-morph 都能按语法结构正确插入然后统一用 Prettier 格式化输出永远整洁。2.4 补丁层事务式写入与 dry-run第三层补丁层是最开始没做、后来补上的原因是一次事故生成器同时改了 router 文件、hook 文件、index 文件结果写到一半用户手动中断留下半个 router 文件里有了新代码、另一半文件没写入项目直接编译不过。所以我给 t3code 加了一个简单的事务机制所有文件修改先缓存在内存里全部生成并验证通过后再统一写入磁盘。写入前先输出一个 diff 预览用户可以用--dry-run只看不改确认无误后--apply才真正落盘。这样出问题最多是预览阶段发现不会把用户的项目搞得半残。3. tRPC procedure 生成器的核心实现3.1 如何定位 router 文件并确定 namespacetRPC 项目里router 文件的组织方式有两种一种是把所有业务 procedure 写在一个文件里另一种是每个资源一个文件再用router.ts合并。create-t3-app 最新模板默认是后者server/api/routers/下每个资源一个文件。t3code 定位 router 文件的策略是先读 tsconfig 的 paths 配置把/server这类别名解析成真实路径。这里有个细节很多人的 tsconfig 里同时有/*和/server/*两种映射直接读字符串会踩坑我写了一个专门解析 paths 的工具函数按 key 长度排序长的 key 优先匹配避免/server被/*吞掉。这个工具后来单独抽成了一个 npm 包就是怕大家重复造轮子。确定 namespace 的规则也简单文件名叫user.tsnamespace 默认就是user。但如果你在router.ts里手动改过合并时的 key比如const userRouter ...然后router: { users: userRouter }那 namespace 实际是users而不是user。t3code 的做法是读取合并文件里的 alias 映射表而不是硬猜文件名。3.2 procedure 模板与 zod schema 的配合生成一个查询类 procedure核心是三个部分input的 zod schema、业务逻辑主体、输出类型。t3code 默认生成的查询 procedure 长这样import { z } from zod; import { createTRPCRouter, protectedProcedure } from ../trpc; import { prisma } from ../../db; export const userRouter createTRPCRouter({ list: protectedProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(20), keyword: z.string().optional(), categoryId: z.string().cuid().optional(), }) ) .query(async ({ ctx, input }) { const where { ...(input.keyword ? { name: { contains: input.keyword } } : {}), ...(input.categoryId ? { categoryId: input.categoryId } : {}), }; const [total, items] await Promise.all([ prisma.user.count({ where }), prisma.user.findMany({ where, skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: desc }, }), ]); return { items, total, page: input.page, pageSize: input.pageSize }; }), });注意几个我特别设计过的点。第一分页参数直接作为默认模板因为大部分列表接口都逃不掉分页用户不想要可以让生成器去掉。这样生成出来的接口从第一天起就是可用状态而不是骨架状态。第二筛选条件用空对象展开的方式而不是if分支里拼字符串。这种方式在 tRPC 和 Prisma 的类型推导下是安全的where对象在编译期就能检查字段名拼写错误。第三保护类型默认用protectedProcedure而不是publicProcedure。原因很实际T3 项目几乎都会上 next-auth默认保护接口比默认公开安全得多。如果你生成的是登录注册相关的接口可以用--public参数覆盖。3.3 输出类型别写大对象用 Prisma 的 validator生成查询接口时最容易被忽略的是输出类型。很多人习惯手写return { id: item.id, name: item.name }这样的大对象字段一多就漏而且类型和 Prisma 实体脱节。t3code 的做法是生成 Prisma 的validator结果让 Prisma 自己推导完整类型import { Prisma } from prisma/client; // 放在 procedure 外方便其他地方复用 export type UserListResult Prisma.UserGetPayload{ include: { category: true }; };这样前后端共享时类型永远准确不会出现后端返回了name前端类型定义里写成了username这种尴尬事。这也是 T3 栈最值得骄傲的地方——类型的源头只有一个就是数据库 schema其他全是推导出来的。4. 前端调用链生成从 useQuery 到 hook 封装4.1 生成 query 和 mutation hook 的规则后端 procedure 生成完后前端 hook 是配套的一部分。t3code 为每个资源生成一个hooks/useResourceQuery.ts或者一个聚合文件里面包含三类 hookuseResourceListQuery对应list、useResourceDetailQuery对应getById、useResourceMutation对应create/update/delete。这里有一个经验hook 的入参不要直接透传 tRPC 的 input 对象而是要包一层。因为直接透传组件里调用时就要写{ page: 1, pageSize: 10 }这种裸对象可读性差而且以后你如果想在 hook 内部加默认值、做 loading 状态合并、统一错误处理就得回头改所有调用点。t3code 生成的 hook 是自带默认值的import { trpc } from ../utils/trpc; import { UserListInput } from ../types/user; export function useUserListQuery(input?: PartialUserListInput) { return trpc.user.list.useQuery( { page: input?.page ?? 1, pageSize: input?.pageSize ?? 20, ...input }, { keepPreviousData: true } ); }keepPreviousData: true这个配置我必须说一句很多项目里列表页切换页码时整页闪一下 loading就是因为没开这个选项。生成器直接把最佳实践写进去比让每个开发者自己踩坑后记得更可靠。4.2 自动改写 barrel 文件生成新 hook 文件之后还有一步容易漏hooks/index.ts里的 export。手动开发时每次新建 hook 都要记得加一行export * from ./useUserListQuery忘了的话其他文件 import 时就只能写很长的相对路径项目风格就开始混乱。t3code 在生成文件后会检查对应的index.ts如果没有包含这个 export就自动追加用前面说的 ts-morph 对 export 列表做去重。这一步看起来小但真的解决了团队协作里一个高频摩擦点代码风格不统一、import 顺序乱跳、文件找不到。4.3 一次完整的命令 Demo我把整个流程封装成一条命令。比如我想给订单资源加一个列表接口带分页和状态筛选只需要运行t3code add order-list \ --with-pagination \ --with-filterstatus \ --protected这条命令会依次完成这几件事检查server/api/routers/order.ts是否存在不存在则创建并注册进router.ts在 router 里插入listprocedure包含 zod 校验、分页查询、状态筛选并写好Prisma.OrderGetPayload输出类型生成hooks/useOrderListQuery.ts写入keepPreviousData和默认分页参数更新hooks/index.ts的 export对改动文件统一跑 Prettier 和 ESLint失败则中止并回滚。整个过程 5 秒左右生成的代码直接可以跑。我实际用这个工具开发了一个中等规模的管理后台6 个资源模块的 CRUD 链路全部用 t3code 生成省下的时间非常可观——至少省了两个整天。5. 我在六周里踩过的坑路径别名、Prisma 类型和热重载5.1 tsconfig 路径别名的坑第一个大坑就是 tsconfig 的 paths 解析。T3 模板里常见的有/*指向项目根目录、/server/*指向server/*还有~/*之类的自定义别名。t3code 一开始只处理了/*结果在用户项目里生成的前端 hookimport 路径写成了/types/user但实际类型文件在src/types/user下编译直接报模块找不到。排查了半天发现是 paths 配置里两个 key 有包含关系正则匹配到了第一个之后tsconfig 实际用的是最长匹配。我修复的方式是读 paths 后把 key 按长度降序排列/server优先于/*。另外所有的生成路径都转成相对路径而不是 aliased 路径因为相对路径在任何配置下都不会出问题而且 IDE 跳转更顺手。5.2 Prisma 生成类型的位置问题第二个坑跟 Prisma 的配置有关。prisma/client默认从node_modules/.prisma/client导出类型但很多项目会自定义output路径常见的是src/server/db/generated。前端代码直接 importprisma/client没问题可一旦生成器试图从 Prisma 类型里提取字段名就得知道实际类型文件在哪。t3code 通过读取prisma/schema.prisma里的generator client { output ... }配置来定位而不是硬编码。如果输出是相对路径就基于schema.prisma所在目录解析。这个细节极其重要因为我见过不少项目生成的代码大量引用了错误的 Prisma 类型路径全靠手工改回来。5.3 watch 模式下的写入时序第三个坑是热重载相关的。开发者在 Next.js dev server 跑着的情况下执行 t3code生成器写入文件后dev server 会立刻感知变化并重编译。问题在于多个文件是分次写入的重编译触发在第一个文件写入的时候此时其他文件还没更新到位编译先失败一次然后才被后续文件触发的重编译修复。这个先红后绿的现象很干扰开发体验尤其是 watch 模式比较敏感的项目。我的解法是写入前先对所有文件做语法预检查确认没有半成品状态同时把文件写入顺序调整成后端先写、前端后写因为前端 hook 依赖后端类型后端先落盘前端 hook 写入时类型已经可用重编译成功率更高。还有一个相关的小技巧生成器跑完不主动去触发额外的保存事件。如果用户用的是 vscode 加自动保存文件系统事件会重复触发几次重编译肉眼看到的就是连续闪烁。t3code 已经尽量只做必要写入但如果你遇到生成后 dev server 卡顿先检查是不是多个文件依次触发重编译考虑给编辑器关掉该目录的自动保存。6. 质量控制与扩展思路6.1 生成代码的验证策略不是能跑而是类型全绿代码生成器很容易出现看起来对了但类型报错的输出所以 t3code 把验证分为三层。第一层是语法树断言。每个生成场景都有对应的一组 ts-morph 断言测试比如确保 sourceFile 中存在名为list的 property、确保 import 没有重复。这一层在 CI 里跑覆盖每个模板的边界情况。第二层是类型检查。t3code 自带一个轻量的 tsc 调壳在写入文件后、落盘前对修改涉及的文件做一次快速类型检查。因为全量tsc --noEmit在大项目里可能要几十秒生成器等不起所以只检查涉及文件的依赖图用 Project 的getPreEmitDiagnostics只分析相关文件。这个速度一般能控制在 2 到 3 秒内。第三层是快照测试。每个模板的渲染结果都有快照防止某个不经意的改动把输出格式带跑偏。快照测试在代码生成器里价值特别大因为它能让你一眼发现这次改动了所有生成的代码这种全局性影响。6.2 从 t3code 扩展出去的三个方向t3code 目前的定位是 T3 栈功能生成器但它的架构其实可以扩展。我自己已经在规划三个方向。第一个方向是支持更多 ORM 和数据库。T3 栈现在默认 Prisma但很多人在项目中期会切到 Drizzle两者的 schema 语法和查询 API 完全不同。t3code 的模板层只要新增一套 Drizzle 渲染器AST 层和补丁层完全复用成本可控。第二个方向是生成测试文件。既然功能链路都自动生成了配套的单元测试和集成测试也可以跟着生成。比如 list 接口的测试用一个假的 prisma client 验证分页参数是否正确传递、空数据时是否返回空数组。这个方向做好了生成器就从前端提效工具变成全链路质量工具。第三个方向是和 AI 对话结合。现在t3code add order-list这种命令行模式已经足够快但要描述更复杂的业务逻辑多个关联表、权限分支、复杂筛选命令行参数就有点笨拙。我把 FeatureSpec 定义得足够结构化其实完全可以接一个自然语言到 JSON 的翻译层让用户说人话就能生成功能。技术上不难难的是把生成器的模板边界定义清楚不要让 AI 的自由发挥污染代码风格。最后说一点个人体会。做 t3code 这六周我最大的收获不是这个工具本身而是对代码生成这件事的重新理解好的生成器一定是对领域约定理解最深的工具它不该是万能脚手架而该是你团队编码规范的强制落地者。t3code 生成的代码就是我自己手动写代码时会写的样式——因为所有模板都来自我对真实项目里高质量代码的提炼。如果你也想写类似的工具我的建议是先用两周收集你自己项目里高频重复的代码片段再动手设计模板你会发现工具最后长什么样完全取决于你对重复劳动有多敏感。

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

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

免费获取报价 →
↑