说个真实体会:全栈项目最磨人的从来不是业务代码本身,而是每次开新项目都要把脚手架重搭一遍。装依赖、配 ESLint、配 Prettier、连数据库、写鉴权、约定路由结构……一轮下来大半天就没了,真正要写的业务还没动笔。所以我花了一段时间打磨了一个内部命令行工具,取名叫 t3code,专门解决“从零到一”和“增量加模块”这两件重复劳动。
t3code 不是一个简单的模板下载器,它是一套围绕 T3 全栈技术栈(Next.js + TypeScript + tRPC + Prisma + Tailwind)的工程化脚手架和代码生成器。你只要一行命令,就能得到一个目录规范、类型安全、开箱即跑的 Web 全栈项目;再跑一条命令,就能新增一个带数据库模型、API 路由、前端调用 hook 的完整业务模块,字段、类型、分页这些都不用手写。这篇文章我想把这套工具的设计思路、核心实现、实操过程以及我在维护过程中踩到的坑,原原本本拆给你看。
1. 整体设计与思路拆解
1.1 为什么是 T3,不是“全家桶”
现在开新项目,可选的技术栈其实非常多,Next.js、Nuxt、Remix、Rails、Laravel 都有现成的脚手架。但几乎没有一个脚手架会默认帮你把端到端类型安全这一整套链路搭完整。T3 技术栈的核心卖点不是用了某个框架,而是从数据库到 API 再到前端组件,所有类型都严格约束住。
举个例子,传统 REST 接口写完后,前端调用时还要自己手动定义响应类型,后端字段一改,前端经常默默返修。而 tRPC 的思路非常反直觉:它让前端像调用本地函数一样调用后端方法,类型直接从 router 定义中推导出来,字段、参数、返回值在编辑器里就有完整提示。我见过很多团队选型时纠结“要不要上 tRPC”,真正用上之后基本都回不去了,因为那种“跨端类型不断裂”的开发体验是有粘性的。
t3code 要做的就是把这条类型安全的链路固化到项目结构里。它不是把几十个文件丢给你就完事,而是让每个文件都遵循同一套约定:Prisma schema 里定义一个模型,tRPC router 里自动出现对应操作,前端 hook 自动绑定好类型。这样新成员加入项目时,不需要读几十页文档,看一眼目录结构就知道什么文件放哪里。
1.2 两个核心能力:初始化与增量生成
t3code 的设计里,最核心的两个能力是 init 和 generate,一个负责“从零搭项目”,一个负责“在已有项目里增加业务模块”。
init 做的事情,是在你选好项目名、数据库类型、是否启用鉴权等选项后,把一组经过验证的模板文件复制到目标目录,同时完成依赖安装和 Prisma 初始化。这听起来和 create-next-app 没什么区别,但差异点在于:模板里已经集成了 tRPC、Prisma、NextAuth 的完整配置,而且版本之间互相匹配,不会出现“装个新包把整个项目搞崩”的情况。
generate 是更值钱的部分。它的使用场景很典型:你现在要做一个博客系统,需要新增一个 Article 资源,包含标题、正文、发布时间、封面图等字段。传统做法是手动写 Prisma Model、写 Zod Schema、写 tRPC Router、注册到根 Router、再写前端的 React Query hook,一套下来基本十几二十个文件。但在 t3code 里,一条 generate 命令就能把这些文件全部生成出来,而且生成结果不是一堆无意义的模板,而是能直接跑起来的代码。
我的一个想法是:脚手架工具不能只做一次性复制,它更重要的是把团队里反复出现的“样板活”变成命令。代码生成器就是脚手架的自然延伸。从实际使用效果来看,团队里的后端同学和前端同学对这个工具的好感度都很高,因为需求变更时改字段的成本变得非常低。
1.3 工具链选型:CLI、模板引擎与配置约束
决定做 t3code 时,我给自己定的技术约束是:CLI 本身用 TypeScript 写,这样内部逻辑和生成出来的代码在类型风格上保持一致。
CLI 解析部分用的是 commander,它很成熟,子命令、参数解析、帮助输出都很方便。交互式提示用了 @clack/prompts,这个库做出来的选择框、输入框和取消逻辑比原始 readline 舒服太多。用户按下 Ctrl+C 时,它也默认返回一个 cancel 信号,生成器就可以优雅退出,不会留下一半文件。
模板引擎选了 Handlebars,选它主要是轻量、覆盖率广,而且表达式语法好理解。遇到循环、条件判断时我会在 Handlebars helper 里做封装。比如生成分页查询代码时,需要根据字段列表生成 Prisma 的 where 条件,这种逻辑写在 helper 里会比在模板里堆条件判断清晰很多。
还有一个容易被忽略的点是配置约束。t3code 没有把配置散落在各个命令参数里,而是在项目根目录生成一个.t3code/config.ts文件,里面保存了技术栈偏好、模块开关、目录别名等信息。generate 命令执行时会读取这份配置来决定生成策略。这样做的好处是:代码生成行为是确定性的、可复现的,换台机器跑同一份配置,生成结果完全一致。
2. 核心细节解析与实操要点
2.1 命令设计与交互流程
t3code 的命令并不多,核心就四个:
t3code init:初始化项目t3code generate:增量生成业务模块t3code doctor:诊断项目环境,检查配置、版本、依赖是否匹配t3code upgrade-template:升级模板基线,用于团队维护模板时同步改动
init 的交互流程我是这样设计的:先问项目名,再问包名(因为 npm 包名和项目目录名不一定一致),然后选择数据库类型(SQLite / PostgreSQL / MySQL),再勾选需要启用的模块(tRPC、Prisma、Tailwind、NextAuth、Docker、ESLint 等)。最后确认生成的目录结构,确认后开始写入和安装依赖。
最重要的一步是在写文件之前展示生成清单。我会用列表把所有将要创建的文件路径列出来,路径后面标注是“新增”还是“覆盖”。这样用户能清楚知道 t3code 会动哪些文件,避免脚本在背后悄悄覆盖已有代码。
generate 的交互则是:
- 输入模块名,比如 article
- 输入字段列表,用字符串语法描述,比如
title:string,content:text,published:boolean,createdAt:date - 选择需要生成的操作,也就是 list、create、update、delete、getById 这些
- 选择是否需要生成前端 hook
- 确认后生成代码
为什么字段要用字符串语法而不是交互式逐步添加?我试过后者,真的太慢了,创建一个带十个字段的实体要点二十多次回车。字符串语法虽然后悔了,但它快,而且配合文档写清楚合法类型名,学习成本很低。
2.2 模板目录约定
T3 全栈项目最怕的是目录结构乱。t3code 在生成项目时,会把目录结构定得死死的,团队成员写代码时有明确去处。核心结构如下:
src/ pages/ api/ trpc/ [trpc].ts server/ api/ routers/ root.ts post.ts trpc.ts db.ts auth.ts styles/ globals.css prisma/ schema.prisma这个结构看似简单,实际是很多 tRPC 项目踩坑之后沉淀下来的稳定形态。比如src/server/api/trpc.ts负责创建 tRPC 实例、声明 middleware,src/server/api/routers/root.ts负责合 root,src/server/db.ts负责导出 PrismaClient 单例。如果这些文件的位置不统一,代码生成器就不知道往哪里塞新 router,手动写的时候也容易出现重复创建实例的问题。
很多新手项目会犯一个错误,就是在每个使用数据库的文件里都new PrismaClient(),这在开发和测试时会导致大量连接耗尽。T3 模板里强制用全局单例模式,并且导出一个 db 对象供所有 router 使用。这个细节我在模板里做了注释说明,防止后面接手的同学改坏。
2.3 从 Prisma Schema 到 Zod 类型的自动映射
这是整个 t3code 里技术上最硬核的部分,也是收益最大的部分。
业务模块的字段定义最终要落到 Prisma Schema 里,同时还要生成对应的 Zod Schema 用于 tRPC 的入参校验。如果这两份东西靠手工维护,字段一多必然出现两边不一致的情况。我的方案是让 t3code 当“翻译器”:generate 命令拿到字段列表后,第一件事是构建一个内部字段模型,然后从这份模型同时生成 Prisma 列定义和 Zod 校验规则。
比如字段语法里的published:boolean,映射到 Prisma 就是published Boolean @default(false),映射到 Zod 就是z.boolean().default(false)。字符串会映射成String @db.VarChar(200)和z.string().min(1).max(200)。遇到 text 类型就映射成@db.Text,Zod 只做z.string(),不做长度限制。
这里要特别说明一个坑:Prisma 的关系字段不能简单映射到 Zod。比如 Article 模型有一个 author 关系字段,如果你把 author 直接加进 create 或 update 的入参 schema,用户就能在创建文章时随意传一个 author 对象进去,这大概率不是你想要的。t3code 的处理方式是默认过滤关系字段,只在返回类型中保留对应外键字段。
另一个容易出问题的是 Prisma 枚举。如果在字段语法里指定status:enum, t3code 会读取 Prisma Schema 里的枚举定义并生成对应的z.enum([...])。这里必须和数据库枚举值拼写保持一致,否则一校验就报错。我在生成器里做了自动校验,枚举值不匹配时直接抛错,不让用户带着坏代码去调试。
映射逻辑里还包括默认值处理。布尔类型默认值是 false,时间类型默认是当前时间,数字类型如果带autoIncrement就自动设置@default(autoincrement())。这套规则虽然简单,但为团队省下了大量敲样板代码的时间。
2.4 生成器的可审查性设计
代码生成器最忌讳的是黑盒。如果一条命令生成三百个文件,然后用户根本不知道该改哪里,那工具就变成了负担。t3code 在设计时重点做了三件事保证可审查性。
第一,所有生成代码都经过模板渲染,模板里可以直接看到变量和逻辑。这样用户打开生成的文件,不会看到一坨毫无规律的内容。
第二,generate 执行前会先做 dry-run。你可以在命令后面加--dry-run,它只会输出将要创建的文件列表和关键内容片段,不会实际写盘。这样在确认之前,你能看到生成的是不是自己想要的。
第三,写文件时,如果目标文件已经存在,t3code 不会静默覆盖。它会对比新旧内容,如果相同就跳过;如果不同就提示冲突,并生成一个.t3code/conflicts/xxx.js文件保存旧版本,让用户决定怎么处理。曾经就是因为没这个设计,我在一次团队演示时把同事一个改了一半的 router 文件覆盖了,那场面至今记忆犹新。
3. 实操过程与核心环节实现
3.1 环境准备与安装
t3code 本身是 Node.js CLI,安装很简单,我推荐用 npx 方式运行,这样可以保证每次拉取的都是最新稳定版:
npx t3code --version如果你在团队里经常使用,也可以全局安装:
npm install -g t3code运行环境要求 Node.js 18 及以上。为什么是 18?因为 tRPC 的现代版本、Next.js 14+ 都要求这个基线太低了会遇到兼容问题。生成的项目默认使用 pnpm 作为包管理器,确保依赖安装速度和磁盘占用都可控,同时减少 node_modules 空间浪费。
如果你所在团队有自己的 npm registry,需要在安装前配置好指向。不然下载依赖那一步会卡在网络问题上,这不是 t3code 能控制的。
3.2 完整初始化项目实操
现在我来演示一个完整初始化流程。假设我要建一个名为 my-blog 的全栈项目:
npx t3code init my-blog接着交互界面会问:
- 包名:我输入 my-blog
- 数据库:选择 SQLite(开发最省事,后面可以迁移到 PostgreSQL)
- 启用模块:勾选 tRPC、Prisma、Tailwind、NextAuth、Docker
确认后,t3code 会开始拉取模板、写入文件、安装依赖,最后执行prisma migrate dev初始化数据库。整个过程大概三分钟,结束后你会看到一个可以直接运行的项目。
生成后的.t3code/config.ts大致长这样:
export default { projectName: "my-blog", packageName: "my-blog", databaseProvider: "sqlite", modules: ["trpc", "prisma", "tailwind", "next-auth", "docker"], srcDir: "./src", };启动开发环境:
pnpm dev打开http://localhost:3000,首页已经带了一个 t3code 的欢迎页,里面会显示当前项目启用了哪些模块。这个欢迎页其实是个“自检报告”,如果某个模块配置有问题,首页会直接提示出来。
我建议初始化完成后立刻跑一遍t3code doctor。它会检查:
- Next.js 版本是否满足要求
- Prisma CLI 和客户端版本是否匹配
- 所有环境变量是否已定义
- tRPC 相关依赖是否存在重复安装
实测里大概三分之一的项目在首次运行时会有环境变量问题,doctor 一句话就能定位到。
3.3 增量生成一个完整业务模块
现在,我真正想演示的是 generate 的用法。接着上面的 my-blog 项目,我要新增一个 Article 业务模块,假设字段有 title、content、published 三个:
npx t3code generate article \ --fields "title:string,content:text,published:boolean" \ --operations list,getById,create,update,delete这条命令执行后,t3code 会做以下几件事:
- 在
prisma/schema.prisma里追加 Article 模型:
model Article { id Int @id @default(autoincrement()) title String @db.VarChar(200) content String @db.Text published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }- 生成
src/server/schemas/article.ts中的 Zod 校验:
import { z } from "zod"; export const articleInput = z.object({ title: z.string().min(1).max(200), content: z.string(), published: z.boolean().default(false), }); export const articleUpdateInput = articleInput.partial(); export type ArticleInput = z.infer<typeof articleInput>; export type ArticleUpdateInput = z.infer<typeof articleUpdateInput>;这里 create 和 update 的输入约束是不同的。创建时必须传 title,更新时可以只传部分字段。所以 t3code 会生成两种类型的 Input,不是简单复用同一个 schema。
- 生成
src/server/api/routers/article.ts:
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; import { z } from "zod"; import { articleInput, articleUpdateInput } from "~/server/schemas/article"; export const articleRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), }) ) .query(async ({ ctx, input }) => { const where = {}; const [items, total] = await Promise.all([ ctx.db.article.findMany({ where, orderBy: { createdAt: "desc" }, skip: (input.page - 1) * input.pageSize, take: input.pageSize, }), ctx.db.article.count({ where }), ]); return { items, total }; }), getById: publicProcedure .input(z.object({ id: z.number().int() })) .query(async ({ ctx, input }) => { return ctx.db.article.findUnique({ where: { id: input.id } }); }), create: protectedProcedure .input(articleInput) .mutation(async ({ ctx, input }) => { return ctx.db.article.create({ data: input }); }), update: protectedProcedure .input(z.object({ id: z.number().int(), data: articleUpdateInput })) .mutation(async ({ ctx, input }) => { return ctx.db.article.update({ where: { id: input.id }, data: input.data }); }), delete: protectedProcedure .input(z.object({ id: z.number().int() })) .mutation(async ({ ctx, input }) => { return ctx.db.article.delete({ where: { id: input.id } }); }), });更新
src/server/api/routers/root.ts,把 articleRouter 注册进根 router。生成前端调用 hook,例如
src/hooks/article.ts,里面的 list 函数已经绑定好了后端推导出来的类型,调用时智能提示完整。
这个过程生成的代码直接可用,但我不建议把它当成“什么都能自动生成”的银弹。它生成的更多是链路层面的样板代码,至于业务里复杂的权限判断、多表关联筛选、统计数据查询等,仍需要自己修改。它省掉的是连接数据库、写 schema、建 router、注册根路由、写类型声明这些必须重复且毫无创造性的部分。
3.4 共享团队模板
t3code 还提供了自定义模板仓库能力,这一块对团队协作价值极高。
如果你的团队内部有统一的技术规范,比如必须使用公司内部的组件库、必须带监控上报、必须启用特定的日志规范,你可以把 t3code 的默认模板复制到自己的 Git 仓库,然后把默认模板地址改掉。
具体做法是在~/.t3code/config.json里配置模板源:
{ "templates": { "default": "git@github.com:your-org/t3code-template.git" } }然后执行:
npx t3code init my-project --template default团队模板的优势在于,任何规范更新只需要改模板仓库,然后跑一次t3code upgrade-template就能把增量改动同步到已有项目。当然这里要提醒一句:模板升级是高风险操作,强烈建议在干净的 Git 分支上执行,并且仔细 review conflict 文件。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
我在开发和维护 t3code 的过程中,收到过不少反馈。其中一部分是 t3code 本身的 bug,另一部分是使用方式问题,还有一部分是底层框架变动导致的问题。我整理成了一张表,看起来更直观:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 生成代码后编辑器报找不到 Prisma Client 类型 | Prisma Client 还未生成 | 执行pnpm prisma generate,t3code 会在 generate 里自动做 |
| tRPC 路由在页面里调用时返回 404 | 使用了 pages router 却没在pages/api/trpc/[trpc].ts配置转发 | 检查文件是否存在,确认导出方式与模板一致 |
| create 操作提示外键字段不可为空 | Prisma 的关系字段未正确处理 | 检查生成时是否过滤了关系字段,手动修改数据模型 |
| generate 提示字段类型不合法 | 字段语法里写了未定义的类型名 | 查看文档,合法类型包括 string, text, int, boolean, date, enum |
| 重复执行 generate 产生重复代码 | 生成器没有检测到已有同名 router | 老版本 bug,新版本会对已有文件做存在性检查和覆盖确认 |
init 后pnpm dev报环境变量缺失 | 未配置.env,或启用了 NextAuth 却未提供密钥 | 查看.env.example,复制为.env并补全 |
| 生成项目后 Tailwind 样式不生效 | Tailwind 版本与 PostCSS 配置不匹配 | 用 doctor 检查版本矩阵,按提示升级 |
这张表里的前四条是最高频的问题,其中“Prisma Client 未生成”几乎每个新用户都遇到过一次,因为 tRPC 的类型推导依赖 Prisma 的运行时类型信息,而这一步不会在pnpm install时自动完成。后来我在模板里加了一个postinstall脚本,每次装完依赖后强制执行prisma generate,这个问题才基本绝迹。
4.2 排查思路与 doctor 命令
遇到问题先别急着删 node_modules,第一件事是跑:
npx t3code doctordoctor 命令的输出是一份诊断报告,会列出:
- Node.js 版本是否满足要求
- 包管理器是 pnpm、npm 还是 yarn
- Next.js、React、Prisma、tRPC 各自版本号
- 是否存在重复或过期的依赖锁定文件
.env文件里关键变量是否齐全- Prisma Client 是否已生成
- 模板版本与当前 t3code 版本是否兼容
多数情况下,诊断报告会直接给出问题文件路径和建议命令。遇到版本兼容性问题,它会建议锁版本范围,因为 T3 工具链迭代比较快,小版本之间也可能出现破坏性变更。
如果 doctor 一切正常但问题仍在,我通常会建议做一次最小复现:新开一个目录,用 init 建一个干净项目,只加业务代码,看问题是否还能出现。这个过程能快速区分是项目自身的问题还是 t3code 生成的问题。
4.3 一些保姆级避坑细节
最后再分享几个我实际维护中的细节经验,这些都不会写在官方文档里。
第一,字段命名尽量用单数和下划线风格。t3code 底层会按“模块名 + 驼峰”规则生成文件名,如果你输入 ArticleTag,生成的文件可能是 article-tag.ts,目录扫描和 Prisma Model 名会不一致。这里我做了自动转换,但复杂模块名依然可能出现意外。所以约定:模块名和字段名一律小写单词,不要用横线和空格。
第二,不要在生成目录里手动创建同名文件后再次运行 generate。很多人改完代码后发现自己文件被覆盖了,其实不完全是工具的问题。t3code 会检查已存在文件,但如果你把生成文件改了之后又用同一个模块名再跑 generate,它会认为这是一次更新操作,并尝试合并。合并策略目前是保守的,以已存在文件为基准,把新增代码片段追加进去。但为了安全,强烈建议每次生成前先git commit,至少也要git stash,生成后要 review 整个 diff。
第三,项目和目录名不要用中文和特殊字符。别看这是基础常识,真的有大佬栽在这里。某个团队的内网项目,因为包名里带了大写字母和横线,npm 安装时静默报错,排查了半天最后发现是包名不符合 npm 规范。t3code 在 init 时会对包名做正则校验,不合法的会直接拒绝,这一开始让人觉得有点烦,但事后看非常有必要。
第四,如果你启用了 Docker 模块,生成的Dockerfile里有个有意思的细节:它使用的是多阶段构建,并且在依赖阶段设置了 pnpm 的 store-dir。这个很多人会忽略,如果直接用单阶段构建,镜像体积会大得离谱。我这里默认开启 pnpm 的--frozen-lockfile,确保线上构建依赖和本地完全一致。
写在后面
t3code 从第一版能跑到今天,中间迭代了挺多轮。如果让我重做一遍,我会在第一天就加入“可升级模板”和 dry-run 机制,而不是等到用户踩坑后才补。工具类项目最值钱的往往不是那些花哨功能,而是“让你敢执行、敢回滚、敢升级”的安全感。
还有一点,我觉得工程化的本质从来不是把一切都自动化,而是把约定变成默认行为。t3code 生成代码不是替代你去思考业务逻辑,而是帮你省去那些重复的连接工作。真正有价值的精力,应该花在需求本身、业务流程和数据设计上。
如果你也在维护一个项目脚手架或者代码生成器,建议先小范围试错:拿一个真实业务模块,用生成器跑一遍,看看生成的代码和你手写有多接近,再决定要不要继续投入。对一个工具来说,能稳定解决几个高频痛点,就已经很值得了。