1. 项目概述与设计思路拆解
1.1 t3code 这个名字到底想表达什么
t3code 这个名字,我第一次看到的时候,脑子里同时蹦出三种解释:T3 Stack 全家桶、第三代的代码范式、三倍速写代码工具。后来转念一想,无论它是哪个意思,落点其实高度统一——用一套更现代、更类型安全、更自动化的方式,把"从数据库到前端页面"这条链路跑通,核心产出是代码,而不是一堆需要手工维护的胶水配置。
我接触到这类项目的场景,多半有两种。一种是团队里想搭全栈脚手架,嫌手写 CRUD 太啰嗦,于是做了一个以 T3 技术栈为底座的代码生成器,输入数据模型,输出完整的路由、API、页面和类型定义。另一种是面向个人开发者的效率工具,把 Next.js、tRPC、Tailwind、Prisma 这些"当红炸子鸡"组合在一起,用一套标准模板快速起步新项目。t3code 这个名字恰好把这两件事都点到了:T3 既代表那个经典的技术栈组合,也暗示"三要素(类型、路由、样式)一体贯通",code 则明确表态——最终交付物是可运行、可扩展的真实代码,不是拖拽出来的配置文件。
抛开名字的玄机,这篇文章真正想讲的是一套方法论:如果你要做这类代码生成/全栈脚手架项目,方案怎么选、骨架怎么搭、类型怎么打通、部署有哪些坑、线上出问题了怎么排。我在实际折腾这类项目时踩了不少坑,下面挨个拆开聊。这套思路适合三类人:一是想自己搭全栈脚手架提升团队效率的技术负责人,二是对端到端类型安全感兴趣的前端/全栈开发者,三是刚入门想找一条不绕路的学习路径的新手。接下来所有内容都是围绕"把 t3code 这类项目做得能真正落地"来展开的。
1.2 为什么选 T3 技术栈作为底座,而不是别的主流方案
做代码生成类项目,底层技术选型几乎决定了项目的成败。我在对比过几套方案之后,最终认为 T3 这套组合是最顺手的组合之一。它并不是什么新框架,而是一组本来就各自优秀的工具按"类型安全优先"的原则拼出来的套餐:Next.js 负责页面与服务端能力,tRPC 负责 API 调用与类型推导,Prisma 负责数据库访问与建模,Tailwind 负责样式,TypeScript 在整个链路里做粘合剂。
主流替代方案我也认真琢磨过。REST + Axios 的问题在于:接口定义和前端调用是两份独立代码,后端改了字段名,前端经常是在运行时才报错,编译期根本发现不了。GraphQL 的方案,像 Apollo 那一套,类型推导能力很强,但要引入 schema 定义、resolver 分层、缓存策略、codegen 流程,配置复杂度对一个小型脚手架来说过于沉重。至于传统的 Express + 任意前端框架,开发自由度最大,但代码生成器本身需要约定,自由度过高反而让生成器难以结构化成模板。
T3 这条路线最大的魅力在于"类型零断层":从 Prisma model 定义出发,数据库表结构是一个类型来源;tRPC 的 router 接受这些类型并对外暴露 Procedure;前端组件通过 React Query / tRPC client 调用时,入参和返回值的类型直接从服务端推导出来。我写前端代码的时候,根本不需要去翻接口文档——鼠标悬停上去,字段类型、可选性、返回值结构全部展示出来。这种体验一旦习惯了,再回到"前后端各写一份类型定义"的老路,会非常难受。
当然 T3 也不是银弹。它对团队的要求是必须全员使用 TypeScript,如果有人习惯于在 JS 和 TS 之间来回切换,类型安全链条就会出现人为裂口。另外它默认的部署目标偏向 Node.js 运行时的 Serverless/容器环境,如果一定要部署到纯静态托管平台上,tRPC 的服务端部分就没办法跑,得另想方案。但对 t3code 这类以"快速搭建全栈应用"为核心目标的代码生成器来说,T3 的默认选择正好切中需求重心。
1.3 三个核心设计目标:类型零断层、约定优于配置、可插拔扩展
打磨这类项目时,我给自己定了三个目标,任何一个达不到都说明设计还不够好。
第一个目标是类型零断层。从数据库 model 到 API 层再到页面组件的 props,类型必须是一路自动推导出来的,不能出现任何"手工复制粘贴类型"的环节。原因很简单:类型一旦需要手工维护,就必然会漂移。改一次表结构,得同步改 Prisma Schema、改 API 入参校验、改前端 interface,改到最后一定会忘掉某一个环节,然后线上就是经典的 undefined is not a function。t3code 这类工具的价值,就是让这个周期缩短到"只改一处,其余自动完成"。
第二个目标是约定优于配置。代码生成器最怕项目生成出来之后,每个模块结构千奇百怪。用户加了三个实体,三个页面的代码风格完全不一样,维护起来就是灾难。所以在设计生成模板时,我倾向于把目录结构、Router 命名、查询 key 规则、组件文件组织方式写成强约定,代码里不留"自由发挥"的口子。新项目生成后,一个刚入职的实习生也能在十分钟内按着既有模式新增一个完整模块。
第三个目标是可插拔扩展。T3 脚手架默认集成了 NextAuth、tRPC、Prisma、Tailwind,但不是每个项目都需要全部能力。有的项目不要鉴权,有的项目不用数据库,还有的项目想把手伸进 Redis 做缓存。所以我在生成器里做了 feature 开关:初始化时可选哪些模块进入,未选中的模块不生成对应代码,也不产生多余依赖,避免模板项目里的死代码。
这三个目标聊起来轻描淡写,实际落地时每一步都涉及具体而微的技术细节,接下来慢慢展开。
2. 核心细节解析与实操要点
2.1 项目骨架与目录结构:一个生成器项目应该长什么样
拿到 t3code 这类项目,第一件事是看懂目录结构。我比较推荐的标准组织方式如下(基于实践中踩过的坑整理):
project/ ├── app/ │ ├── (auth)/ │ │ ├── login/page.tsx │ │ └── register/page.tsx │ ├── dashboard/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── api/ │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts │ └── layout.tsx ├── components/ │ ├── ui/ │ └── entities/ # 按业务实体拆分的组件 ├── server/ │ ├── api/ │ │ ├── routers/ │ │ │ ├── post.ts │ │ │ └── user.ts │ │ └── root.ts │ ├── db.ts │ └── auth.ts ├── prisma/ │ └── schema.prisma ├── styles/ │ └── globals.css ├── .env.example ├── package.json ├── tsconfig.json └── tailwind.config.ts这个结构里的核心设计原则是:app 目录只存放路由页面,server 目录单独存放服务端逻辑,两边通过 tRPC 的 router 连接。千万不要把业务逻辑直接写进 app/page.tsx 里,否则随着页面增多,服务和视图会搅成一锅粥。
我在实际项目中吃过亏的地方是 components 目录的划分。早期我把所有组件平铺在一个 components 文件夹里,项目规模一大,找组件全靠文件名硬记,后来才改成 ui 和 entities 两层。ui 层放通用组件(按钮、输入框、弹窗),entities 层放业务实体组件(帖子卡片、用户头像、评论区),这样代码生成器在生成新模块时,也更容易定位模板文件的位置。
这个约定对代码生成器尤其重要,因为生成器本质是"模板 + 上下文数据"的渲染器。上下文数据是数据模型定义和字段信息,模板则是预先写好的代码片段。目录划分越稳定,模板的路径映射就越简单,生成的代码也越规整。
2.2 端到端类型安全是怎么做到的:理解类型如何从数据库一路长到组件里
端到端类型安全是 t3code 这类项目最核心的卖点,但很多新人第一次看到 tRPC 时,根本搞不清楚它是怎么把类型"变魔术"一样传到前端的。我用大白话解释一下这个链路。
一条数据从数据库到页面,正常会经历四层:数据库表、服务端 API 函数、网络传输、前端组件。传统写法里,每一层都需要单独定义一份数据形态,这就是类型漂移的根源。t3code 的做法是让这四层共用一个类型源。
以 Prisma 为例,schema.prisma 里定义了一个 Post 模型:
model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }运行npx prisma generate之后,Prisma Client 会根据这段定义生成完整的 TypeScript 类型,包括 Post、PostCreateInput、PostUpdateInput 等。这套类型就是整条链路的源头。
接着 tRPC 的 router 定义:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20) })) .query(async ({ ctx }) => { return ctx.db.post.findMany({ take: 20, orderBy: { createdAt: "desc" }, include: { author: true }, }); }), });关键点在这:list 这个 Procedure 的返回值类型就是Prisma.PromiseReturnType<typeof postRouter.list>,tRPC 会自动推导这个类型。前端调用时:
import { api } from "~/trpc/react"; const { data } = api.post.list.useQuery({ limit: 20 });此时 data 的 TypeScript 类型,已经精确到了{ id: string; title: string; content: string; published: boolean; author: { ... 完整字段 ... } }[]。前端不需要 import 任何 Prisma 类型,不需要手写 interface,网络传输的 JSON 结构在编译期就被完全确定了。
表达式"类型从数据库一路长到组件里",说的就是这个机制。数据库 schema 一变,重新跑一次 generate 和类型检查,所有使用该类型的前端组件都会在编译期高亮报错。这个特性做代码生成器时价值巨大:生成器只需要保证"数据库模型 → 生成代码"这一步正确,类型系统会帮忙兜住实现中的大部分错误。
2.3 数据模型设计与迁移:schema 决定了生成代码的上限
代码生成器的数据模型设计,核心要诀可以浓缩为一句话:在 schema 层把关系、索引、默认值都想清楚,后面生成的 CRUD 代码才不至于到处打补丁。
以常见的博客场景为例,如果只定义一个孤零零的 Post 模型,生成出来的列表页、详情页都太单薄。我会建议至少定义 User、Post、Tag 三个模型,并建立多对多关系:
model User { id String @id @default(cuid()) name String? email String @unique posts Post[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }这里有几个我在实际项目中反复被问到的设计细节。
主键类型我建议优先用 cuid 或 uuid 而不是自增整数。自增 id 有暴露业务数据量级的风险(竞品看到你注册用户 id 是 3 位数就知道你的存量),而 cuid 在分布式环境下可以安全地由客户端生成,不需要每次插入都回表查 id。当然如果团队有强制的性能指标、表行数上亿,用自增 bigint 也不是不行,但代码生成器面对的是大量中小型项目,cuid 的通用性更好。
关系字段务必在模型上双向标注。Prisma 的 relation 需要两边都写出来,漏掉一边直接报错,而且报错信息对新手不太友好。多对多关系使用隐式的 join table 就好,不需要手动创建 PostTag 模型。
索引的取舍在生成器里要克制。默认情况下,外键字段(authorId)建议加索引,因为按作者查询是高频操作;按创建时间倒序排列的列表页,可以给 createdAt 加一个复合索引(@@index([published, createdAt]))。但不要一键把所有字段都加上索引,写入性能会下降,存储占用也会涨。
Schema 定稿后,执行迁移命令:
npx prisma migrate dev --name init这条命令会创建迁移文件并同步数据库。如果是在多人协作环境,迁移文件和代码文件一样要进版本库,不要忽略。数据库结构变更历史是最难手工重建的信息之一。
2.4 样式方案与 UI 细节:Tailwind 的约定化与组件抽离
t3code 这类全栈脚手架,样式层一般都选 Tailwind。它的好处是类名即样式,组件和样式在同一文件里,代码生成器输出的模板不需要单独维护一套 CSS 文件,生成体验会清爽很多。
但 Tailwind 也有一个著名的坑:类名拼接方式稍微一变,编译结果就不一样。比如动态拼接类名:
const classes = `bg-${color}-500`; // 错误示范Tailwind 的内容扫描器只认完整类名字符串,这种动态拼接生成的类名在最终产物里根本找不到。正确做法是维护一个完整的类名字典,用映射方式选择:
const colorClasses = { red: "bg-red-500 hover:bg-red-600", blue: "bg-blue-500 hover:bg-blue-600", } as const;组件抽离上,我强烈建议起步阶段就引入一套现成的无头组件库。以 T3 社区最常用的 shadcn/ui 为例,它的组件不是 npm 包,而是直接拷贝到本地 components/ui 目录,这意味着代码生成器可以把这些组件纳入自己的模板体系,生成页面时直接引用本地组件,不必担心外部包的版本漂移。
样式约定的最后一条建议:尽早定义好 theme 变量。Tailwind 的 design token 不是选配,而是刚需。至少把 primary 色、前景色、背景色、圆角、边框厚度定义成一组变量,后续生成的所有页面都从这组变量取样式。这样当客户要求"换个品牌色"时,只需要修改 tailwind.config 里的一个 token,所有生成页面自动换肤。
3. 实操过程与核心环节实现
3.1 初始化项目:从零开始跑出一个可运行的 t3code 骨架
说来也巧,T3 官方提供的脚手架create-t3-app正是这类项目的典型模板。初始化一个新项目可以这样操作:
npx create-t3-app@latest my-t3-app交互式命令行会让你选择要包含的模块,我的建议是:第一次先全选(Next.js、Tailwind、tRPC、Prisma、NextAuth、ESLint),把完整链路跑通一遍,后续再针对具体需求做减法。全流程走完后,项目会自动安装依赖并生成基础目录结构。
这里有一个新手常犯的错误:中途选的模块越多,初始化速度越慢,有些人耐不住性子 Ctrl+C 中断,结果生成出残缺模板。我的经验是让它一次跑完,不管花几分钟;中断后重新初始化的时间成本,远高于一次性等完的时间成本。
初始化结束后,第一步就是配置环境变量。检查.env.example里的变量名,复制为.env并填入真实的 DATABASE_URL、AUTH_SECRET 等值。tRPC 和 NextAuth 在运行时如果缺环境变量,不是给你一个警告,而是直接进程崩溃——这个设计在开发环境看似暴躁,实际是为了避免带着错变量上线的灾难。另外 T3 使用 zod 在启动时校验环境变量,你配置的值如果格式不对,会看到清晰的校验错误提示,比运行时层层报错好排得多。
启动项目:
npm run dev浏览器打开 localhost:3000,能正常渲染首页、tRPC 健康检查接口返回正常,就说明骨架已经跑通了。
3.2 通过数据模型自动生成一个带鉴权的示例功能模块
这一步是 t3code 的核心使用场景:从数据模型开始,生成完整功能代码。我用一个"带鉴权的帖子管理"作为示例,带你完整走一遍流程。
第一步,在 schema.prisma 里定义好 Post 模型。后续所有代码都由这个模型驱动生成。
第二步,执行迁移并生成 Prisma Client:
npx prisma migrate dev --name add_post npx prisma generate第三步,在 server/api/routers 下添加 post.ts。tRPC 推荐按业务领域拆分 router,而不是把所有接口堆在一个文件里。以鉴权为例,把"需要登录才能创建帖子"和"任何人都能看帖子列表"的权限区分做得明明白白:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(50).default(10) })) .query(async ({ ctx }) => { return ctx.db.post.findMany({ take: 10, orderBy: { createdAt: "desc" }, where: { published: true }, include: { author: { select: { name: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(120), content: z.string().min(1), }) ) .mutation(async ({ ctx }) => { return ctx.db.post.create({ data: { title: ctx.input.title, content: ctx.input.content, authorId: ctx.session.user.id, }, }); }), });注意 protectedProcedure 与 publicProcedure 的区别。前者会先执行鉴权中间件,session 不存在直接抛错,路由代码里不需要再手写判断if (!ctx.session);后者允许匿名访问。这种表达方式非常贴合"约定优于配置"的原则。
第四步,把 postRouter 注册到根路由:
export const appRouter = createTRPCRouter({ post: postRouter, });第五步,前端页面调用生成器生成的 hook。tRPC 客户端会自动生成带类型的 React Query hooks,列表页大概长这样:
"use client"; import { api } from "~/trpc/react"; export default function PostListPage() { const { data, isLoading } = api.post.list.useQuery({ limit: 10 }); if (isLoading) return <div>加载中...</div>; return ( <div> {data?.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content}</p> <span>作者:{post.author.name}</span> </article> ))} </div> ); }创建帖子的 mutation 里,最有价值的细节是缓存失效策略。tRPC 提供了utils对象来手动触发某条查询的失效:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { void utils.post.list.invalidate(); }, });这个invalidate是 t3code 这类脚手架里最常见的模式——数据变更后,让相关查询缓存失效并重新拉取。很多新手写到这里,容易手滑写成void utils.invalidate(),把所有缓存全部刷新。这在数据量小的 demo 项目里看不出问题,一旦业务复杂起来,全量失效会导致大量无意义请求,生产环境千万不要这么干。
3.3 构建到部署:本地能跑不代表线上能跑
本地开发环境一切正常,不代表部署就一帆风顺。我在这里把部署链路中必须检查的环节列一遍,全是血泪经验。
执行构建命令:
npm run build这一步会执行 TypeScript 类型检查与 Next.js 编译。最常见的构建失败原因有三类:一是服务端代码引用了浏览器专有 API(如 window、document);二是环境变量没设全,zod 校验在构建阶段就报错;三是 Prisma Client 没有正确生成,@prisma/client 的导入路径找不到类型定义。逐个排查的顺序,我一般先从终端里最顶部的报错开始,因为 Next.js 的报错信息经常是层层嵌套的,最上面的才是根因。
部署目标如果是 Vercel 这类平台,有一个细节要特别留意:Prisma 的二进制文件在安装时需要下载对应平台的引擎,本地是 macOS 就下载 macOS 版本,线上是 Linux 就下载 Linux 版本。这个一般由 prisma 安装脚本自动处理,但如果你用了自定义的构建镜像或缓存机制,最好在 Dockerfile 里显式执行一次npx prisma generate,避免线上找不到引擎的尴尬。
如果是自建服务器 + Docker 部署,基础 Dockerfile 可以参考:
FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npx prisma generate RUN npm run build FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma EXPOSE 3000 CMD ["npm", "run", "start"]这个 Dockerfile 的关键在于多阶段构建。deps 阶段只装依赖,builder 阶段生成 Prisma Client 并完成编译,runner 阶段只保留产物。这样镜像体积能小很多,部署速度也快。有人图省事把三个过程塞进一个阶段,镜像体积动辄 1GB 以上,启动慢不说,CI 排队时间也让人崩溃。
4. 常见问题与排查技巧实录
4.1 类型不同步:明明改了 schema,前端还是旧类型
这类项目里最高频的困惑,就是"我改了 Prisma schema,为什么前端报错还是旧结构"。
先说结论:Prisma schema 变更后,必须依次执行两步——npx prisma migrate dev更新数据库表结构,npx prisma generate重新生成 Client 类型。迁移和类型生成是两个独立动作,只做其中一个都不完整。
如果没有跑prisma generate,哪怕你把 schema 里的字段已经加好了,TypeScript 编译器读取的依然是 node_modules/@prisma/client 里上次生成的旧类型,前端引用新字段必然报错。我记得有一次折腾了半小时,最后发现是 tRPC 的本地缓存把旧的类型信息缓存住了,连 dev server 都热更新了但类型却没刷新。这种情况重启一次 dev server 基本都能解决。
tRPC 的类型推导偶尔也会出现"卡住"的问题。原因通常是 tRPC 的 router 文件没被重新引入,类型图里还挂着旧节点。我在实践中的处理顺序是:先跑prisma generate,再重启 dev server,如果还不行,删除.next目录重新构建。这套组合拳能解决 95% 以上的类型不同步问题。
4.2 数据库迁移失败:migrate 卡住、外键约束、迁移冲突
prisma migrate dev在本地使用 SQLite 或 PostgreSQL 时都比较顺滑,但在真实项目里还是会遇到几个经典问题。
最常见的是"迁移文件冲突"。多人协作时,两个人各自基于同一个迁移文件 A 创建了迁移文件 B1 和 B2,数据库状态就直接错了。解决方案是npx prisma migrate resolve:把已经应用到数据库的迁移标记为已应用,或者把不再需要的迁移标记为已回滚。操作前务必确认数据库当前的实际状态,别盲目 --rolled-back,否则会破坏数据一致性。
另一个高频报错是外键约束失败。以 SQLite 为例,SQLite 默认不强制外键约束(需要在连接时开启 PRAGMA),但 Prisma 在创建或删除记录时如果涉及关联表,仍会按标准行为检查。报错信息通常长这样:Foreign key constraint failed on the field:authorId``。排查思路很简单——创建记录时,关联的外键值在目标表里不存在。常见原因是在复杂页面里没等用户选择关联对象就提交了表单,或者是关联数据被先删除了。给外键字段设置默认值或改造成级联删除,可以减少这类问题的发生面。
还有一类结构性的坑是"修改字段类型时迁移卡死"。比如把 String 改成 Int,如果这张表里已经有存量数据且内容不是纯数字,迁移过程就会在中途中断,而且中断之后数据库处于中间状态,非常难恢复。我的经验是:小心处理类型变更,在迁移前先备份数据,并人工检查存量数据的格式兼容性。如果团队里没人愿意担责,宁可改成"新增字段 → 编写数据迁移脚本 → 删除旧字段"的三步走方案,也别直接原地改类型。
4.3 样式失效与构建崩溃:Tailwind 扫描不到类名、Prisma 与 Edge Runtime 冲突
Tailwind 类的动态拼接问题前面已经说过,这里补充一个更隐蔽的坑:类名写在组件库(node_modules)里,但项目的 tailwind.config 内容扫描器没有把组件库的路径包含进去。如果你给自己写了一个内部组件库,生成页面引用了里面的按钮,但按钮的样式就是死活不生效,第一时间检查这段配置:
content: [ "./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./node_modules/@your-design-system/**/*.{ts,tsx}", ],构建崩溃方面,我遇到最多的问题是"Runtime 不支持 Prisma"。Next.js 默认对某些中间件和 API 路由尝试使用 Edge Runtime,但 Prisma 依赖 Node.js 的二进制引擎,在 Edge Runtime 里根本跑不起来。报错信息里通常会出现PrismaClient is not instantiable。解决方案是给对应的 route 文件加一行配置,强制使用 Node.js Runtime:
export const runtime = "nodejs";凡是使用了 Prisma 或 tRPC 的 API 路由,我都建议加上这一行,宁可显式声明也不用赌 Next.js 自动选择正确运行时。
4.4 联调过程中的诡异行为:缓存不刷新、方法被拦截、序列化报错
前端代码跑起来之后,问题往往集中在联调阶段。第一个典型问题是"我明明调用了 invalidate,数据还是不刷新"。仔细检查后发现,invalidate 的 key 路径写错了。tRPC 的失效路径必须和查询路径完全一致,api.post.list.useQuery对应的是utils.post.list.invalidate,如果写成了utils.post.getList.invalidate,前端不会报错,但缓存也不会失效——这是静默失败里最坑的一种情况。
第二个典型问题是"POST 请求被浏览器拦截"或 "CORS 报错"。tRPC 的 mutation 默认使用 POST,query 默认可以使用 GET。如果使用了 NextAuth,并且前后端跨域,需要配置 NextAuth 的 cookies 跨域策略。这类问题在本地代理模式下通常不会出现,上到测试环境才暴露,排查时除了看 Network 面板,先看一眼后端日志里有没有 CORS 相关的中间件输出。
第三个问题是序列化报错。tRPC 返回数据时,如果返回值里有 Date 对象,JSON 序列化后会变成 ISO 字符串,前端拿到的类型里虽然标注了 Date,但实际运行值已经是 string。Prisma 的 DateTime 字段在 tRPC 返回时默认就是这个行为,很多新手会在前端调用post.createdAt.getTime()直接炸掉。这个问题有两种解法:一是在 schema 里就不返回 createdAt,只返回格式化后的字符串;二是用超轻量的 superjson 作为 tRPC 的 transformer,这样 Date 类型能在网络传输后保持真实 Date 对象。T3 脚手架默认集成了 superjson,所以纯 t3code 项目不会遇到这个问题,但如果你去掉 superjson 配置,就要自己注意了。
4.5 问题排查速查表
| 症状 | 可能原因 | 排查顺序 |
|---|---|---|
| 类型还是旧的 | Prisma Client 未重新生成 | 跑npx prisma generate→ 重启 dev server |
| 迁移报外键错误 | 关联记录不存在 | 检查目标表数据 → 检查关系字段顺序 |
| 样式不生效 | Tailwind 未扫描对应路径 | 查 content 配置 → 确认类名完整 |
| 构建时 Prisma 报错 | Edge Runtime 不兼容 | 确认文件顶部是否声明runtime = "nodejs" |
| 页面数据不刷新 | invalidate 路径不匹配 | 核对 tRPC router 路径 |
| 请求 405 或 CORS | 跨域问题 | 检查代理配置、请求头、cookies |
| 运行时返回 Date 类型异常 | 序列化丢失类型 | 启用 superjson 或自定义输出格式 |
5. 个人经验与扩展建议
5.1 从生成器到团队协作工具的延伸
如果你把一个能生成 CRUD 代码的脚手架打磨成熟了,下一步自然的方向是把它扩展成团队协作工具。我在实践中最大的体会是:生成器本身的技术难点,远不如"变更流程"让人头疼——模型改一次,所有已生成项目里的代码要不要跟着改?怎么通知到各个项目 owner?这个问题的答案直接决定了工具是锦上添花还是添乱。
我试过一种相对靠谱的做法:把数据模型文件本身当作契约,通过 CI 校验已生成项目与最新模型的偏移量。具体方案不太复杂——每个生成项目里保留一份.t3code/context.json,记录生成时用的模型版本和模板版本。CI 阶段跑一个 diff 脚本,发现有偏移就自动建 issue,把这些 diff 的迁移建议列出来。这个机制不需要把生成器做成云端 SaaS,也能让团队在"版本漂移"成为灾难之前就收到警报。
另外,生成器最好能生成"带测试的代码",哪怕是最基础的冒烟测试。我在早期版本里只生成功能代码,不生成测试,结果交付出去的模块工程质量完全取决于使用者的自律性。后来我改成默认生成一个基于 vitest 的冒烟测试文件,验证 API 路由能被正常调用、鉴权中间件能拦截未登录请求。这个小小改动,对新同学带团队尤其管用。
5.2 后续版本可以加的三个方向
如果 t3code 要继续往下做,我脑子里有几个方向排序比较靠前。
第一个方向是接入 AI 辅助生成。用自然语言描述一个实体("帮我加一个带标题、摘要、封面图的文章模型"),模型通过结构化输出直接生成 Prisma schema,再走现有的生成链路输出全套代码。这个方向的成本比想象中低,因为生成器本身已经有 schema 到代码的映射表,AI 只解决 schema 怎么来的问题,不需要把整条链路塞进大模型提示词里。
第二个方向是不依赖 Next.js 的轻量化导出。把 tRPC + Prisma 部分单独拆出来,导出成 Express 或 Fastify 版本,适配那些不想用 Next.js 的团队。这个方向的工作量主要在于模板抽象层,如果一开始就把模板分层做好,导出目标只是加一套新模板的问题。
第三个方向是设计系统联动。把生成的页面 UI 与公司的设计系统打通——不是简单套一个主题,而是让生成器读取设计系统的 token 文件,自动渲染对应风格的组件。这个方向的价值在规模化交付场景里非常明显:多个项目保持视觉一致性,不再需要前端团队手工对齐样式。
5.3 我个人在实际折腾中的几个体会
项目做到后面,我对"代码生成器"的心态发生了变化。刚上手时总想着代码覆盖率越高越好,什么代码都想生成。后来发现,生成器更适合做"标准化程度高、逻辑重复性强"的部分——CRUD、鉴权、列表页、表单页——而真正复杂的业务判断和交互细节,还是得留给人类亲手写。生成器最好的角色是脚手架和基础层,不是取代开发者。
另一个体会是关于代码质量的取舍。生成出来的代码必须保证"打开就能看懂",不要为了炫技使用太高级的类型体操。使用者很可能不是原开发团队的人,代码晦涩会让工具的口碑断崖式下跌。我见过精力旺盛的工程师写出来的生成模板里翻出五层泛型嵌套,业务同事当场崩溃。实用主义在这里体现得淋漓尽致:生成器的每一行模板代码,都按"小学四年级能看懂"的标准来写。
最后想分享的是运维意识。t3code 这类生成器项目自己也要运维,依赖升级、安全漏洞扫描、模板版本的兼容性测试一个都不能少。我会把核心模板代码纳入常规的依赖 Dependabot 流程,并在发版时用自动化脚本把所有生成目标跑一遍编译,任何模板修改都不能引入编译错误。这个守护机制看似朴素,却是这类工具能长期被信任的基石。