☰
t3code实践:用tRPC+Prisma打造类型安全的全栈应用
2026/10/28 15:29:48 网站建设 项目流程

t3code 是我最近在维护的一个内部项目的代号。搞过全栈的人看到这个名字,基本能猜到它和 T3 技术栈脱不了干系——没错,这个项目就是用 Next.js 加 tRPC 加 Prisma 这一套搭起来的,目标是做一个团队内部的任务与周报管理平台。写这篇文章不是要宣传某个工具,而是想把我在这个项目里踩过的坑、验证过的方案、以及“为什么这么选”的过程整理出来。如果你正准备用 create-t3-app 起一个新项目,或者正在评估 tRPC 和 Prisma 的组合,这篇应该能帮你省不少时间。

项目本身不复杂,核心就是三件事:登录认证、任务管理、简单看板。但正因为业务简单,技术细节和编码体验的差异才特别明显。两个核心模块、七张表、十八个 tRPC 接口,从初始化到部署上线,前后大概花了两周零碎时间,中间大部分时间都花在“类型怎么打通”和“部署配置怎么调整”上。下面按实际操作时的顺序,从选型、建模、编码到排坑,一条条讲清楚。

1. 项目设计与技术选型思路(为什么要用这一套)

1.1 从 REST 到 tRPC:省掉的不只是接口文档

以前我做前后端分离,习惯用 REST 加手写接口文档,或者直接上 OpenAPI 生成 client,这样总会有一个讨厌的中间成本:改一个字段,前端模型、后端模型、文档、mock 数据要同步更新。t3code 这个项目人数不多,我不想为接口维护再养一套流程,所以核心诉求很直接:让 TypeScript 类型成为前后端唯一的约定。tRPC 做的事情,本质上就是把这个约定变成编译器的一部分——后端 router 的函数签名定了,前端调用的入参和返回值就自动推导出来,改一个字段,编译期就能暴露所有相关调用点,而不是等到联调才发现字段名打错了。

这个选择也意味着我放弃了一些 REST 的灵活度,比如不太适合做完全公开的 API、需要精细控制缓存头的场景。但内部管理平台恰恰是最典型、也最受益于 tRPC 的场景:没有第三方消费者,调用方全是同一个代码仓库里的客户端,类型安全的价值能直接转化成开发速度。tRPC 的查询和 mutation 本身也自带缓存失效机制,前端只需要关注业务状态,不用去手动维护一套请求缓存。

1.2 create-t3-app 脚手架里每个选项的取舍

创建项目时,create-t3-app 会弹出好几个确认项。t3code 实际启用的是 Next.js App Router、TypeScript、tRPC、Prisma、NextAuth、Tailwind。没用的是 ESLint 之外的一些附加项,比如 Drizzle——因为我更熟悉 Prisma 的 migrate 和 studio 工作流。这里有个容易忽略的点:如果用 App Router,项目默认是开启 RSC(服务端组件)的,而 tRPC 的调用天然适合在服务端组件里跑,所以“App Router + tRPC”这个组合比“Pages Router + tRPC”更顺,后面第 3 部分会展开说生产环境的调用方式。

综合下来,技术矩阵是这样的:

层次选型解决的问题
全栈框架Next.js App Router路由、SSR、部署路径最成熟
API 层tRPC v10端到端类型安全,替代手写 REST 层
数据层Prismaschema 驱动建模、迁移、类型生成
认证NextAuth邮箱/OAuth 登录、Session 管理
样式TailwindCSS快速迭代内部工具 UI
校验Zod与 tRPC input 校验紧密结合

注意:create-t3-app 默认生成的代码风格偏保守,比如会把 API 封装在 src/server/api 下、客户端封装在 src/trpc 下。这套约定很值得保留,尤其当项目从 demo 长成生产项目时,目录即边界,团队新人看着结构就能找到“服务端接口”和“客户端调用”的位置。

2. 核心细节解析与实操要点(建模、路由、认证的关键)

这个部分不是泛泛而谈最佳实践,而是 t3code 中真正需要反复确认、容易出错的地方。

2.1 Prisma schema:把关联先建模对,后面能少改一半

数据模型是整个项目的地基。t3code 里用户(User)和任务(Task)是最核心的两个实体,我再加了标签(Tag)表。直接看 schema 的核心部分:

model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? accounts Account[] sessions Session[] tasks Task[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Task { id String @id @default(cuid()) title String description String? status String @default("todo") priority String @default("medium") dueDate DateTime? createdById String createdBy User @relation(fields: [createdById], references: [id]) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([createdById]) } model Tag { id String @id @default(cuid()) name String @unique tasks Task[] }

几个细节想多说两句。第一,ID 用 cuid 而不是自增 int:内部多实例扩展或同步数据时,cuid 不会撞,而且 Prisma 生成 cuid 不需要请求数据库,对写入性能和分布式环境都友好。第二,status 和 priority 先用字符串,不急着建枚举。内部工具的需求变化很快,枚举在建表后修改成本高;字符串加 Zod 枚举校验,既保留灵活性,又能在 API 边界卡住非法值。等真的稳定了再迁移成 Prisma enum 也不迟。第三,外键必填(createdById 非空)但具体归属用户时用 relation,这是 Prisma 的常规套路,不会漏建索引。

2.2 tRPC 路由组织:按领域拆分,输入必须用 Zod 校验

t3code 的 tRPC 路由没有一把梭放在一个文件里。根 router 里通过 mergeRouters 拼装领域子路由,每个子路由只负责一个领域:

// src/server/api/root.ts import { taskRouter } from "./routers/task"; import { userRouter } from "./routers/user"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ task: taskRouter, user: userRouter, });

每个 mutation 的 input 必须用 Zod 对象定义,这是 tRPC 社区里最容易偷懒但其实最不能省的一步。举个例子:

// src/server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; const createTaskSchema = z.object({ title: z.string().min(1, "标题不能为空").max(80), description: z.string().max(2000).optional(), status: z.enum(["todo", "in_progress", "done"]).default("todo"), }); export const taskRouter = createTRPCRouter({ create: protectedProcedure .input(createTaskSchema) .mutation(async ({ ctx, input }) => { return ctx.db.task.create({ data: { title: input.title, description: input.description, status: input.status, createdById: ctx.session.user.id, }, }); }), });

这样做的价值,在联调阶段非常明显。前端传错了字段类型,本地直接报 TS 错误;传的值不合法,tRPC 会在请求进入业务逻辑之前用 Zod 拦截,接口层天然带校验。不用在业务代码里写 if/else 判断参数,代码干净很多。除了 create,update 和 delete 也按同样的套路写,比如 update 的 input 里通常带一个id字段和一组可更新字段,用.partial()可以快速生成“所有字段都可选”的校验对象,省事且不容易漏字段。

2.3 认证与鉴权:protectedProcedure 的边界

NextAuth 和 tRPC 的集成,是 create-t3-app 默认帮你做好的。但如果你是从零搭,最容易犯的错是在每个 router 里手动判断 session,然后返回错误。t3code 的做法是定义两个基础 procedure:

  • publicProcedure:不需要登录就能调用的接口,比如用户注册前的预检。
  • protectedProcedure:先检查 ctx.session,取不到就直接抛出 UNAUTHORIZED 错误,后续逻辑不用再关心“当前用户是谁”。

关键在于 createTRPCContext 里要把 session 挂上去。create-t3-app 生成的是:

export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, headers: opts.headers, }; };

这里有个用户体验细节:如果 session 过期,tRPC 默认会返回内部错误。我在前端的一层封装里把 UNAUTHORIZED 错误统一拦截,然后跳转到登录页。这样错误处理对用户是透明的,后端不用在几十个接口里重复处理登录态。另外,权限控制不要只依赖前端按钮的显示隐藏,真正要拦截的是 tRPC 的 procedure——比如只有任务创建者能更新任务,这个判断必须写在 mutation 的业务逻辑里,而不能靠 UI 层做到。

3. 实操过程与核心环节实现(从初始化到部署)

3.1 初始化:用 create-t3-app 还是手动搭?

t3code 是用脚手架初始化的,命令是:

pnpm create t3-app@latest t3code

官方交互式 CLI 会问你用哪个数据库、需不需要 tRPC、NextAuth 等。按自己的需求勾选就行。有一点必须提醒:如果生产环境要用 PostgreSQL,初始化时可以选 SQLite 作为开发库,但要留好迁移路径。实际上 create-t3-app 生成的 Prisma schema 在 SQLite 和 PostgreSQL 之间切换并不困难,只需要改 provider 和连接串,重跑一次prisma migrate dev。t3code 开发阶段用的是 SQLite,部署前切到了 PostgreSQL,整个切换过程大概花了不到半小时。

初始化完成后的目录结构大约是这样的:

src/ app/ 页面与路由 api/auth/[...nextauth]/route.ts api/trpc/[trpc]/route.ts layout.tsx server/ api/ routers/ root.ts trpc.ts auth.ts db.ts trpc/ react.tsx server.ts env.js prisma/ schema.prisma

记住一个边界:src/server 里的模块不能从客户端组件 import,否则会把服务端代码打包进浏览器端 chunk。这块靠 ESLint 规则也能拦住,t3code 里给 src/server 目录加了server-only包标记,从根上防止误用。项目里如果用"use client"的组件,建议统一放在一个_components或components目录里,这样边界更清晰。

3.2 数据库迁移:prisma migrate 的日常流

模型建好后,执行:

pnpm prisma migrate dev --name init

migrate dev 会自动比对 schema 与数据库的差异,生成迁移 SQL 并应用,同时重新生成 Prisma Client 类型。日常迭代中,改动 schema 后一定要记得重新生成类型,否则 Prisma Client 里还是没有新字段。这里最常见的失误是:改完 schema 直接跑 dev server,然后报“字段不存在”——实际上只是 Client 类型没重新生成。

开发期我还特别喜欢用pnpm prisma studio,在浏览器里直接看表数据,特别是排查 seed 数据和 session 记录时,比 SQL 客户端快很多。seed 脚本可以放在prisma/seed.ts,用 tsx 运行,里面写几条测试用户和任务,方便前端联调。t3code 的 seed 脚本主要生成了一个管理员用户和十几个不同状态的任务,这样在看板视图上能直观看到各列分布。

3.3 实现一个完整任务 CRUD

以“创建任务”为例,完整链路是这样:

  1. 在 taskRouter 里定义 create mutation(代码见 2.2)。
  2. 在客户端组件里使用:
"use client"; import { api } from "~/trpc/react"; export function NewTaskForm() { const utils = api.useUtils(); const createTask = api.task.create.useMutation({ onSuccess: () => { void utils.task.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); createTask.mutate({ title: "示例任务" }); }} > <input name="title" /> <button type="submit">创建</button> </form> ); }

这里最值得注意的点在于 invalidate:调用成功以后自动把相关查询标记为失效,然后重新拉取,前端不用写“手动刷新”逻辑。REST 时代得手动维护状态、重新请求列表,在 tRPC 里一行搞定。列表查询就直接用api.task.list.useQuery(),返回的数据自带类型。如果要在服务端组件里拿数据,走api.task.list()(需要引入 server caller),避免把组件改成客户端组件。

删除和更新操作也是一样的套路,唯一多出来的是更新时可能需要把旧数据默认值回填到表单里。由于 useQuery 返回的数据本身就带类型,回填过程几乎不会出字段名写错的问题。

3.4 部署:Vercel + PostgreSQL 的注意事项

t3code 部署在 Vercel 上,数据库用托管 PostgreSQL。整个部署过程有 3 个地方比较敏感:

第一,构建命令。Vercel 上建议用类似这样的命令:

pnpm prisma migrate deploy && pnpm prisma generate && pnpm build

注意不能用prisma migrate dev,因为生产环境不是开发环境,migrate dev 会尝试打开交互式 prompt,构建直接卡住。migrate deploy是专门给生产环境用的,它只应用已有迁移文件,不生成新迁移。

第二,环境变量。NextAuth 的AUTH_SECRET、数据库连接串DATABASE_URL都要在 Vercel 项目配置里提前配好。尤其注意 Vercel 的 serverless 环境下,Prisma 连接池和直接连接串的区别,连接池串用于运行时,而有些托管数据库需要单独配置一个直连串给迁移命令用。如果只有一个 DATABASE_URL,部分连接池服务在迁移时可能连不上,所以我会单独配置一个DATABASE_URL_DIRECT给迁移命令用。

第三,Serverless 函数的超时。如果有超过 10 秒的长任务,默认的 serverless 配置会掐断,所以内部工具里尽量把重活拆短或者放到后台任务,不要在 mutation 里做大量同步计算。t3code 里有一个“导出周报”的功能,一开始直接在 mutation 里生成 Excel,后来改成先写入导出任务表,再由后台异步处理,效果好很多。

3.5 页面路由与服务端组件里的 tRPC 调用

在 App Router 里,很多页面天然是服务端组件。t3code 的首页用了异步服务端组件直接查数据:

import { api } from "~/trpc/server"; export default async function Dashboard() { const tasks = await api.task.list.query({ limit: 20 }); return <TaskList initialTasks={tasks} />; }

这里api.task.list.query是 server caller,不走网络请求,而是直接调用函数体,所以数据库查询延迟很低。唯一要注意的是,它访问的是当前请求下的 request context,所以必须在请求范围内调用,不能在模块顶层调用。这个坑我一开始踩过,报错信息提示“headers is undefined”,排查半天才发现是在模块顶层调用了。

4. 常见问题与排查技巧实录

这一节是 t3code 开发过程中真实遇到的坑,我把它们整理成了一个速查表,并逐个说下排查思路。

症状常见原因解决方案
前端调用 tRPC 返回 500Router 里引用了服务端独有模块使用 server-only 标记,拆分公共类型
改动 Prisma schema 后报字段不存在没重新生成 Prisma Client跑pnpm prisma generate,必要时重启 dev server
部署时构建卡在 prisma prompt误用prisma migrate dev改用prisma migrate deploy
Session 在 tRPC ctx 里为空authOptions 和 NextAuth 配置不一致确认getServerSession(authOptions)使用的是同一份配置
RSC 里调用 tRPC 报 headers undefined在模块顶层调用了 server caller移到组件或 request 生命周期内调用
数据库连接超时连接池耗尽或迁移用了错误的连接串区分 DATABASE_URL 与 DATABASE_URL_DIRECT
类型错误 TS2742 等嵌套类型无法推断某些库的类型导出粒度较粗显式标注返回类型,或拆分中间类型

4.1 “类型爆炸”:tRPC 类型推导失败时的处理

tRPC 大部分时候推导很顺畅,但在遇到复杂泛型、条件类型时,TypeScript 偶尔会报“类型实例化过深”。t3code 里一个较复杂的统计查询就踩到了。我的处理经验是:不要试图强行写一行流式代码,把中间类型拆成显式变量声明,或者干脆给 tRPC 的查询返回一个显式接口类型,底层再用符合该类型的实现,既保留类型安全又绕开深层推导。类型工具是给人用的,不是用来炫技的。

还有一个容易让人蒙圈的点:tRPC 的 error 类型默认是一个联合类型,直接用 message 没问题,但要精确区分是校验错误还是鉴权错误,可以用TRPCClientError的data.code字段。我在前端的统一错误处理里就是这么判断的。

4.2 NextAuth 和 tRPC 会话丢失

有一个比较容易忽略的场景:如果你在中间件里做了路由保护,比如export { default } from "next-auth/middleware",注意这套中间件跑在 Edge Runtime,而 Prisma 默认不能跑在 Edge。所以中间件里不能碰数据库,只能做基础的路由拦截。真正的数据权限判断放到 protectedProcedure 里,两边各管一段。

我在 t3code 里还遇到过一个诡异的现象:本地开发时 session 正常,部署后 tRPC 接口里 session 偶尔为空。最后发现是 NextAuth 的AUTH_SECRET没固定在 Vercel 环境变量里,而是用了默认生成的临时值,导致多实例间 session 签名不一致。把这个值固定下来之后问题立刻消失。

4.3 多环境变量与迁移脚本

生产迁移时,如果数据库连接串和跑迁移命令时用的连接串不是同一个(尤其用了连接池服务),经常会出现“表已经存在”或者“连不上库”的诡异问题。我从 t3code 得到的经验是:让“迁移命令”和“应用运行时”各自拥有独立的环境变量。前者用DATABASE_URL_DIRECT指向直连地址,后者用DATABASE_URL走连接池。两者职责不同,千万别混。

另外,prisma migrate deploy不是幂等的,重复执行同一个迁移文件会报错。所以 CI/CD 里最好把迁移步骤放在构建之前,并且确保并发构建不会同时触发迁移。t3code 的 CI 配置里给部署任务加了锁,保证同一时间只有一个实例在跑迁移。

5. 性能、工程规范与可维护性(让项目走得更远)

5.1 查询优化:N+1 问题与 include 的选择

Prisma 对关联查询用 include 或 select,用起来方便,但同样会踩 N+1。t3code 的任务列表一开始直接include: { createdBy: true },列表一页五十条还好,但如果每条任务都带三五个标签,就会出现明显的查询放大。优化方式是把关联查询改成 include 时注意只用需要的字段,或者用 select 投影。比如:

const tasks = await ctx.db.task.findMany({ where: { createdById: ctx.session.user.id }, select: { id: true, title: true, status: true, createdBy: { select: { name: true } }, }, orderBy: { createdAt: "desc" }, });

这样生成的 SQL 只查需要的列,响应体也更小。另外,tRPC 层面可以用httpBatchLink合并客户端多个请求,把同一时间内的多个调用合并成一个 HTTP 请求,这也能减少 serverless 函数调用次数,属于性价比很高的优化。create-t3-app 默认就配了httpBatchLink,但你要注意开发时批处理缓存可能造成“改了数据但界面没刷新”的错觉,排查问题时可以先临时改用httpLink看是不是缓存问题。

5.2 ESLint 与目录边界约定

t3code 的 ESLint 配置基本沿用 create-t3-app 的默认,还额外加了两条自定义规则:不允许从客户端组件直接导入 src/server 的模块;不允许在非"use client"文件里使用 tRPC 的 hooks。这样可以让错误在 CI 阶段就暴露,而不是等到运行时 500。团队协作时,目录边界约定文档要写在 README 里,最好配一个 CONTRIBUTING.md。工程规范这东西,越早定越省心。

我还给项目加了一个简单的提交规范:feat、fix、docs、refactor 几个类型,配合 lint-staged 在提交时自动跑 ESLint 和 Prettier。内部工具虽然不强制 code review,但保证类型检查通过再合入主干,可以让每个人随时 checkout 到可运行的状态,不至于出现“我这台机器能跑,你拉下来就报错”的情况。

5.3 内部工具后续扩展的自然路径

t3code 做的是内部工具,所以性能压力不大,但维护性和扩展性还是要注意的。后续如果要加权限系统,可以在 User 模型上加一个 role 字段,在 tRPC 的 protectedProcedure 里按角色判断即可;如果要加操作日志,可以在 mutation 里统一写入一个 audit 表;如果多个项目要复用这套基础设施,可以把 tRPC router 和 Prisma schema 提取成 monorepo 里的公共包。这些都不需要推翻重来,因为类型边界已经划好了。

最后说点我自己的体会。t3code 这个项目解决的不是什么高深问题,它更像是验证了“类型安全全栈”在日常业务开发里确实能把心流打断的次数降下来。以前做一个小功能要前后端来回跳,现在大部分时间只在一个文件里操作,然后让 TypeScript 告诉我哪里漏了。这个体验的差异,比任何性能数据都更能说明问题。如果你也想试,建议先拿一个内部工具练手,比如把每天填的周报系统重构一遍,你会很快体会到它的价值。我给这个练手项目起的代号就是 t3code,你也可以给它起个更顺口的名字,但技术栈这套组合值得一试。

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

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

立即咨询