ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

T3 Stack 实战:从 Prisma 到 tRPC 的类型安全全栈开发指南

T3 Stack 实战:从 Prisma 到 tRPC 的类型安全全栈开发指南

t3code这个项目名,懂行的人一眼就能猜到路子:T3 Stack。去年我给自己小组搭内部样板仓库时取了这个代号,本意是验证“TypeScript 到底能不能从前端一路打到数据库”。结果这套组合拳打下来,把团队前后端联调的时间砍掉了至少三分之一,很多以前要靠文档和口水战才能对齐的接口契约,现在全被编译器管住了。这篇文章就把我在 t3code 上从初始化、建模、写业务到部署的完整实操过程和踩坑记录摊开聊,看完你应该能直接照着复刻一套属于自己的类型安全全栈基建。

1. 为什么拿t3code当模板:T3 Stack的全栈选型逻辑

1.1 “端到端类型安全”到底解决了什么

先聊个很多团队都有的痛:接口文档和代码不同步。REST 模式下前端要调一个列表接口,先得看文档、找参数名、抄类型定义再手动写一遍interface。后端改了字段名,前端类型定义仍然是旧的,不是运行时炸,就是编译期长期不报错、上线后生产环境开始疯狂报 undefined。t3code 这套 T3 Stack 最主要的价值就是把“契约”这件事交给计算机去保证。

T3 Stack 的核心玩法是端到端类型安全:从数据库模型(Prisma Schema)到 API 层(tRPC),再到前端组件里调用的函数,全部共享同一份 TypeScript 类型。tRPC 的机制很简单,它不生成协议,也不搞 SDL,而是直接把你后端定义好的 router 对象导出类型,前端用这个类型做createTRPCProxy,调用api.post.list.query()的时候,IDE 能直接提示入参和返回值。这就像你点外卖的时候,菜单不是一张纸质照片,而是全电子化的数据源,后厨改了菜名,你手机上的菜单同步变,永远不存在“下单时还看到韭菜炒蛋,后厨已经换成蛋炒韭菜”的错位。

实际跑起来之后,体感特别明显。以前写列表页,我要么去看后端的 DTO,要么自己猜字段,现在trpc.post.list.query()一敲,返回的Post[]类型直接来自 Prisma 模型,前端想写错都难。t3code 仓库里的代码量并不多,但没有一行是“为了补类型而补”的,每一处类型都能追溯回数据库字段,这就是样板间该有的状态。

1.2 Next.js + Tailwind + Prisma + tRPC 的职责划分

T3 Stack 不是一把梭塞一堆库,而是四件套各管一摊,分工极其清楚。放一张表格感受一下:

技术职责类比
Next.js应用框架、路由、SSR/SSG、前后端同仓托管房子的承重墙和管线
TypeScript全链路静态类型约束施工图纸
PrismaORM、数据库建模、迁移装修时的水电预埋方案
tRPC前后端 API 调用协议房子里对讲电话
Tailwind CSS原子化样式方案涂料和软装模块

选这套组合前,我其实也纠结过要不要用 GraphQL。GraphQL 的 schema 定义能力很强,但要引入 codegen、客户端缓存、schema 漂移管理等一大堆配套;而 T3 的核心逻辑是“既然前后端都是 TypeScript,为什么不直接把类型当协议来用”。尤其是一个团队内部、业务闭环程度高的中小型项目,tRPC 这种零代码生成、运行时零依赖的方式,比 GraphQL 轻太多。

当然也要说清楚,T3 Stack 不是银弹。如果你的项目需要对外提供 OpenAPI 给第三方,或者有多个非 TypeScript 的客户端(比如 iOS/Android 原生),tRPC 就不好用了,这种场景还是得老老实实上 REST + 文档。T3 最舒服的战场是:全栈 TypeScript 团队、内部业务系统、快速迭代的 Web 应用。t3code 定位就是解决这一类问题。

2. 搭好t3code的地基:初始化与工程配置

2.1 一条命令拉出完整工程骨架

T3 Stack 官方提供了create-t3-app脚手架,这一下解决了 90% 的初始配置工作。我在创建 t3code 的时候直接跑了:

npm create t3-app@latest t3code

脚手架会交互式问你选哪些模块。我从实际使用角度给一份建议勾选清单:

模块是否启用原因
Next.js必选整个项目的宿主
Tailwind CSS建议启用原子类写 UI 效率高,和后端类型解耦
tRPC必选类型安全的核心链路
Prisma必选数据模型就是类型源头
NextAuth.js按需需要账号体系就开,不需要就关
Drizzle备选与 Prisma 二选一

当初最让我意外的是脚手架把 ESLint、Prettier、tsconfig 的 strict 全开好了,省掉了大量讨价还价的精力。项目生成后的目录结构大概是这样的:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ └── root.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── trpc/ │ │ ├── server.ts │ │ ├── client.ts │ │ └── react.tsx │ └── app/ │ ├── layout.tsx │ └── page.tsx └── package.json

一个容易忽略的点:脚手架默认用@/作为 src 别名,最终编译时会映射到./src/*。如果你后续要加自定义路径别名,千万别忘了去tsconfig.json和next.config.js两处都改,否则 TS 检查和 Next.js 打包器会存在路径解析不一致的诡异问题。我在 t3code 早期调试一个页面时遇到过明明tsc通过了,但 Next.js 运行时报模块找不到,排查半天就是路径别名只改了一半。

2.2 数据库模型设计:从 Prisma Schema 到真正的表

t3code 里我搭的是一个偏内容管理的业务场景,拿“文章 + 标签”来当示例最直观。prisma/schema.prisma里定义两个模型:

generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Post { id String @id @default(cuid()) title String content String published Boolean @default(false) tags Tag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique posts Post[] }

这里有个我在实际项目里踩过然后想明白的坑:主键的类型选择。很多人会直接用自增数字Int @id @default(autoincrement()),看着简单,但一旦业务涉及多个环境的数据合并、离线写入或备份恢复,自增 ID 很容易发生冲突。t3code 里我统一用cuid(),它既保证分布式环境下的唯一性,又不像 UUID 那样把 URL 路径拉得老长,字符串索引在大多数数据库里性能也够用。

定义好 schema 后,跑迁移:

npx prisma migrate dev --name init

这条命令会做三件事:在prisma/migrations下生成一份带时间戳的 SQL 迁移文件、把这个迁移应用到数据库、然后自动重新生成 Prisma Client。迁移文件长什么样值得翻开看一眼。以 Post 表为例,T3 生成的就是标准的CREATE TABLESQL,这意味着你在纳管数据库变更历史,不管到第几次迭代,只要有迁移文件就能把任意环境的库结构拉到最新。很多新手会图省事直接npx prisma db push同步结构,完全不生成迁移文件,这在原型阶段可以接受,一旦上了生产,历史结构就变成一团黑盒,后续想审计字段变更连查的地方都没有。

2.3 环境变量与基础服务启动

T3 脚手架会生成一个.env.example,你得复制成.env并把DATABASE_URL配好。SQLite 场景下是这样:

DATABASE_URL="file:./db.sqlite"

如果是 PostgreSQL(比如部署到线上用 Neon 或 Vercel Postgres),则类似:

DATABASE_URL="postgresql://user:password@host:5432/t3code"

环境变量这块一定要养成的习惯是:敏感信息一律只放进.env,不要提交进 Git。T3 默认在.gitignore里把.env排除了,但从别的模板拷项目时要反复确认,我见过不止一个团队把生产数据库密码提交进 GitHub 私有仓库,最后泄露成安全事故。

配置好之后,本地起服务:

npm run dev

访问localhost:3000,如果能看到脚手架默认页面,说明 t3code 的骨架已经能转了。这时候整个链路还没有业务代码,但 Prisma Client 已能连上数据库,tRPC 的健康检查路由也处于监听状态,后面加业务就都是顺水推船。

3. 打通业务链路:路由、数据库、tRPC 三合一

3.1 用 App Router 组织页面,服务端组件直接吃数据库

Next.js 从 13 开始用 App Router,t3code 完全围绕新架构来组织页面。核心思路是:能用 Server Component 的地方绝不上 Client Component,数据拿取尽量坐在服务端完成。

一个典型的文章列表页长这样:

// src/app/page.tsx import { api } from "~/trpc/server"; export default async function HomePage() { const posts = await api.post.list.query(); return ( <main className="container mx-auto py-10"> <h1 className="text-2xl font-bold">t3code 文章列表</h1> <ul className="mt-6 space-y-4"> {posts.map((post) => ( <li key={post.id} className="rounded border p-4"> <h2 className="text-lg font-semibold">{post.title}</h2> <p className="text-sm text-gray-500">{post.createdAt.toLocaleDateString()}</p> </li> ))} </ul> </main> ); }

关键一步是~/trpc/server这个封装。它是 tRPC 提供的一个服务端调用器,让你在 Server Component 里就像在写后端函数一样调用查询。这里我特别想分享一个体会:很多人一开始会被“前端”和“后端”的思维定式困住,总想着 fetch 一个 API,再处理 loading 状态,再 setState。但在 App Router + tRPC 的模式下,你根本不需要这些仪式感。

但这并不代表客户端组件没有用武之地。只要页面里有交互、有状态、有用到浏览器 API,就必须加"use client"标记。t3code 里的规则很简单:展示用服务端,交互用客户端。比如“新建文章表单项”,它的职责是收集输入和提交,所以它是一个典型的 Client Component。从 Server Component 可以直接把客户端组件当作普通标签引入,数据通过 props 传入即可。

我见过很多后续接手 T3 项目的人在这里犯糊涂,把包含useState的组件不加"use client",结果启动直接报错。还有反过来,全页面都标成 client,服务端渲染的优势全丢了,等于把 Next.js 当成了纯 SPA 框架。边界问题没有银弹,唯一的判断标准就是:这个组件是否需要useState/useEffect/onClick这一类的浏览器交互能力。

3.2 用 tRPC 写一套类型安全的 API,少写几百行接口代码

tRPC 的核心是 router,相当于你后端的 API 总控制器。t3code 里我把业务按模块拆分,再合到根 router 上。

以文章模块为例,src/server/api/routers/post.ts大概是这样的:

import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/trpc/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ include: { tags: true }, orderBy: { createdAt: "desc" }, }); }), byId: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { tags: true }, }); }), create: publicProcedure .input( z.object({ title: z.string().min(3, "标题至少 3 个字"), content: z.string().min(10), tagNames: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, tags: { connectOrCreate: input.tagNames.map((name) => ({ where: { name }, create: { name }, })), }, }, }); }), delete: publicProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.post.delete({ where: { id: input.id } }); }), });

publicProcedure.query就是 GET 请求,mutation就是 POST/PATCH/DELETE 这些写操作。入参校验用 zod 在服务端入口就把脏数据拦下来,入参类型自动从 zod schema 推导为 TypeScript 类型,这又省了一层“手写 DTO + 手写校验”的重复劳动。

在浏览器端调用时,先建立 tRPC React Client:

// src/trpc/react.tsx import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();

然后在任意 Client Component 里直接使用:

"use client"; import { api } from "~/trpc/react"; export function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.list.invalidate(); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); createPost.mutate({ title: String(form.get("title")), content: String(form.get("content")), tagNames: [], }); }} > <input name="title" placeholder="标题" required /> <textarea name="content" placeholder="正文" required /> <button type="submit">发布</button> </form> ); }

这里最值得称道的细节是utils.post.list.invalidate()。它做的事是让 React Query 知道“列表数据已经过期了”,自动重新拉取最新列表。放在传统 REST 时代,你得手动在onSuccess里调用fetchList()再 setState,或者在全局事件总线上广播一下,非常容易漏。tRPC + React Query 的模式里,数据层的东西被抽象成一套自带状态机的缓存系统,业务代码只关心“我提交了什么”“成功后要刷新谁”,大量半技术半胶水的代码直接消失。

3.3 在服务端组件和客户端组件之间传数据

这块是我建议每个做 T3 项目的人单独花十分钟想清楚的环节。App Router 的模型下,数据传递分两种方向:

第一,服务端组件把查询结果通过 props 传给客户端组件。这很直观,比如你已经在服务端拿到了posts,然后<PostList posts={posts} />渲染成列表并支持客户端交互,props 直接传就行。第二,客户端组件需要触发数据变更,比如删除按钮。这个时候不是“传数据下去”,而是客户端组件自己通过api.post.delete.useMutation发请求,不需要父组件帮忙。

有一个细节容易踩坑:Server Component 传给 Client Component 的数据必须是可序列化的。意思是它不能包含函数、Date 实例这些特殊对象(虽然 Next.js 对 Date 做了特殊支持,但最好不要依赖)。你从 Prisma 查出来的字段都是字符串、数字、布尔和普通对象,够用了。如果你发现自己把一个包含了Decimal或自定义类实例的对象往下传,赶紧在服务端先 map 成普通结构,否则序列化时会直接报错。

从整体看,整个 t3code 的业务流就是:数据库表定义在 Prisma,查询逻辑写在 router,页面渲染在 Server Component,交互逻辑在 Client Component 里通过 tRPC 完成。链路短、边界明确,类型从数据库一路贯穿到onSubmit里的那行createPost.mutate(),这种感觉一旦体验过会非常上瘾。

4. t3code实战中一定会遇到的坑与排查技巧

4.1 Prisma Client 提示类型过时或找不到

这个东西几乎每个用过 Prisma 的人都会撞上。场景通常是:改了schema.prisma之后,npx prisma migrate dev明明执行成功了,但一写代码,类型提示还是旧的,或者是 IDE 红波浪线提示findMany不存在。这大概率是 Prisma Client 没有重新生成,或者你的开发服务器还在用旧的内存缓存。

解决流程按顺序来:

# 1. 确认 schema 语法没问题 npx prisma validate # 2. 重新生成客户端 npx prisma generate # 3. 重启 Next.js dev server npm run dev

因为 Next.js 在 dev 模式下会对模块做缓存,prisma generate生成的.prisma/client可能没有被热替换识别到。有时候还需要单独Ctrl + C停掉 dev server 再启动,才能让新的类型被正确加载。还有一个隐蔽场景:如果你在package.json的 postinstall 脚本里写死了某个 Prisma 版本,而本地全局 npx 缓存里是另一个版本,生成的 Client 会对不上。解决方法是给prisma命令加@latest或者锁定版本后重新npm install。

4.2 服务端组件与客户端组件边界导致的水合错误

Next.js App Router 在水合(hydration)阶段最经典的报错就是:

Text content does not match server-rendered HTML.

t3code 开发后期遇到最多的是时间格式化问题。服务端渲染时用的是 Node.js 环境的时区,客户端经水合后用的浏览器本地时区,如果页面直接输出post.createdAt.toLocaleString(),两端结果不一致,就报水合错误。我的处理方式是:

第一,如果时间显示不重要,就在服务端统一格式化为 ISO 字符串再传给客户端。第二,如果你确实需要在客户端按用户时区显示,就把组件标记为客户端渲染,同时用mounted状态延迟渲染首帧。

还有一个非常隐蔽的坑:把 localStorage 或 window 访问放在组件顶层。Server Component 在服务端根本没有window,如果代码在模块顶层执行window.localStorage.getItem,整个构建都会崩。正确做法是放到useEffect里,或者加一个typeof window !== "undefined"的判断。

这类错误看起来是“小问题”,但排查起来特别耗时间。我的经验是:先在页面里二分注释代码,把一半组件注释掉,如果不再报错,说明问题在下半部分,再用同样的思路继续缩小范围。半小时内一定可以定位到具体组件。

4.3 部署上线时必看的环境变量、数据库与构建配置清单

t3code 的部署我首选 Vercel,因为它跟 Next.js 的适配可以说是无痛的。但有几个点不提前 check 的话,上去就是 500。

第一,环境变量要全。.env里的DATABASE_URL、NEXTAUTH_SECRET(如果开了鉴权)都要在 Vercel 项目的 Environment Variables 面板重新填一遍。本地.env不会自动同步到云端,很多团队本地跑得飞起,一部署就连不上库,99% 是环境变量漏了。

第二,数据库位置。Vercel 本身的 serverless 函数是无状态的,连传统的常驻数据库连接池不合适。我建议用 Neon(Serverless PostgreSQL)或者 PlanetScale 的连接池模式,连接字符串里带?pgbouncer=true&connection_limit=1这类参数。原因是 Serverless 环境会并发启动多个 lambda 实例,每个都开自己的数据库连接池,瞬间就能把数据库连接数打满。用 pgbouncer 类型的连接池之后,短连接被复用,连接数压力就小很多。

第三,构建命令检查。create-t3-app默认 build 脚本是next build,但如果你在代码里用了环境变量来条件编译某些逻辑,Vercel 构建时这些变量也得存在,否则构建期就会因为 undefined 报错。我建议在本地先完整跑一次npm run build,确认没问题再 push。

4.4 常见问题速查表

症状原因处理方式
tRPC 接口调通,但前端类型报错修改 router 后没重启 dev server重启npm run dev,让类型重新生成
Prisma 查询方法不存在(如findMany报错)Client 与 schema 版本不同步npx prisma generate后重启项目
Browser 里调用window报错组件被当作 Server Component 渲染加"use client"或移到useEffect内
Vercel 部署后 API 全部超时serverless 连接池配置不匹配换 Neon/PgBouncer 或调connection_limit
页面水合报错服务端/客户端渲染结果不一致统一时间/随机数输出,或延迟客户端渲染
env变量 undefined.env与部署环境的变量不一致检查 Vercel 环境变量面板

最后分享一点个人折腾 t3code 的体会

t3code 这个样板仓库前前后后攒了小半年,最大的收获倒不是某个具体库用得多熟,而是重新理解了“工具链的一致性”这件事。TypeScript 本身不是银弹,但如果从数据库到 API 到 UI 全部走在同一条类型链路上,编译器就能帮你挡掉一大批低级错误,你把省下来的精力放到业务模型和交互设计上,这才是 T3 Stack 真正的价值。

想提醒一句的是:这套方案适合从零开始的新项目,或者你准备把老项目的接口层整体翻新一遍再去接。如果是一个已经有几百个 REST 接口的系统,想逐步迁到 tRPC,成本会大不少,建议先切一个小模块验证,再决定要不要全量推进。另外,如果你的项目马上要对外开放 API 或有非 Web 客户端,T3 这套“端到端类型安全”的优势会受到削弱,到时候可能还是 REST 或 GraphQL 更合适。

对我个人来说,t3code 不只是一个代码仓库,更像是一套团队协作的“插件”。新成员进来,照着仓库里的页面和 router 抄一抄,半天就能写出符合规范的模块。而每次看 IDE 里那些从一个 Prisma 字段延伸出来的类型提示,心里都会默默觉得:这坑,值得踩。

返回列表