☰
t3code:为T3技术栈自动生成功能代码的CLI工具
2026/10/26 23:38:31 网站建设 项目流程

先说结论:我花六周业余时间做了一个叫 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 里追加updateOneprocedure,input 通常要复用之前定义的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 文件并确定 namespace

tRPC 项目里,router 文件的组织方式有两种:一种是把所有业务 procedure 写在一个文件里,另一种是每个资源一个文件,再用router.ts合并。create-t3-app 最新模板默认是后者,server/api/routers/下每个资源一个文件。

t3code 定位 router 文件的策略是先读 tsconfig 的 paths 配置,把@/server这类别名解析成真实路径。这里有个细节:很多人的 tsconfig 里同时有@/*和@/server/*两种映射,直接读字符串会踩坑,我写了一个专门解析 paths 的工具函数,按 key 长度排序,长的 key 优先匹配,避免@/server被@/*吞掉。这个工具后来单独抽成了一个 npm 包,就是怕大家重复造轮子。

确定 namespace 的规则也简单:文件名叫user.ts,namespace 默认就是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/use<Resource>Query.ts或者一个聚合文件,里面包含三类 hook:use<Resource>ListQuery(对应list)、use<Resource>DetailQuery(对应getById)、use<Resource>Mutation(对应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?: Partial<UserListInput>) { 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-filter=status \ --protected

这条命令会依次完成这几件事:

  1. 检查server/api/routers/order.ts是否存在,不存在则创建并注册进router.ts;
  2. 在 router 里插入listprocedure,包含 zod 校验、分页查询、状态筛选,并写好Prisma.OrderGetPayload输出类型;
  3. 生成hooks/useOrderListQuery.ts,写入keepPreviousData和默认分页参数;
  4. 更新hooks/index.ts的 export;
  5. 对改动文件统一跑 Prettier 和 ESLint,失败则中止并回滚。

整个过程 5 秒左右,生成的代码直接可以跑。我实际用这个工具开发了一个中等规模的管理后台,6 个资源模块的 CRUD 链路,全部用 t3code 生成,省下的时间非常可观——至少省了两个整天。

5. 我在六周里踩过的坑:路径别名、Prisma 类型和热重载

5.1 tsconfig 路径别名的坑

第一个大坑就是 tsconfig 的 paths 解析。T3 模板里常见的有@/*指向项目根目录、@/server/*指向server/*,还有~/*之类的自定义别名。t3code 一开始只处理了@/*,结果在用户项目里生成的前端 hook,import 路径写成了@/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)。前端代码直接 import@prisma/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 生成的代码,就是我自己手动写代码时会写的样式——因为所有模板都来自我对真实项目里高质量代码的提炼。如果你也想写类似的工具,我的建议是:先用两周收集你自己项目里高频重复的"代码片段",再动手设计模板,你会发现工具最后长什么样,完全取决于你对重复劳动有多敏感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询