1. T3 Stack整体设计思路拆解
1.1 什么是T3 Stack,为什么要关注它
第一次听到"t3code"这个名字,很多人会以为是个加密项目或者热门AI工具,实际上它是近年全栈Web开发圈子里热度非常高的技术栈组合——T3 Stack。这里的T3,取的是TypeScript、Tailwind CSS、tRPC三个核心技术的首字母,因为它们都以T开头。再加上Next.js作为框架底座、Prisma作为数据库ORM、NextAuth.js做认证,就组成了一套完整的、强调类型安全的现代化全栈开发方案。
T3 Stack最初由国外知名开发者Theo(t3.gg)在社区推广,核心口号是"简单、模块化、类型安全",目的是解决过去前后端分离带来的类型割裂、接口文档维护难、联调效率低等痛症。很多团队在开发中后台系统、个人作品集、SaaS应用时,经常面临一个问题:前端和后端各写各的,接口协议靠人肉对齐,改个字段名翻遍全项目。T3 Stack通过tRPC把前后端类型拉通,让TypeScript的类型定义从数据库一路延伸到浏览器端,开发体验确实干净利落。
在开始拆解细节之前,先给不同基础的同学画个像:如果你已经写过React组件,但没接触过tRPC或Prisma,这篇内容能帮你把整条链路串起来;如果你是资深后端,想了解现代Node全栈的工程组织方式,也可以看看T3这一套怎么把类型安全贯彻到底;如果你是纯新手,建议先跟着文中的最小示例走一遍,再回来理解背后的设计理由。
1.2 为什么是「Next.js + tRPC + Tailwind + Prisma」这套组合
T3 Stack选型背后有一个非常清晰的原则:能用类型系统搞定的,绝不靠约定。因为约定是靠记忆执行的,而类型是由编译器强制校验的。
具体到每层选型,理由如下:
- Next.js:提供文件路由、服务端组件、API Route、中间件,一个框架覆盖BFF(Backend For Frontend)层和渲染层,不需要额外搭Express服务器。对绝大多数Web应用来说,Next.js的App Router模式已经足够灵活,部署到Vercel也几乎零配置。
- TypeScript:贯穿全栈的语言基础,没有它后面的类型安全全部无从谈起。在这里不是可选项,而是地基。
- Tailwind CSS:原子化CSS方案,配合组件级Purge,避免无用的样式文件膨胀。开发时不用手动维护CSS类名和HTML结构的对应关系,改样式像写内联样式一样直接,而且主题定制靠配置文件完成,容易统一设计规范。
- tRPC:这是T3 Stack引以为傲的部分。它允许你在前后端共享类型定义,前端调用后端方法时自动获得完整的参数提示和返回类型推断。和RESTful API相比,它不做粗糙的URL语义化,而是把后端过程直接暴露成类型安全的方法调用;和GraphQL相比,它不需要学习额外的Schema语言,也不需要维护Resolver层和客户端查询语句的映射关系。
- Prisma:数据库客户端和迁移工具,Schema文件即数据库表结构的唯一真源。它的优势在于让你用声明式的方式描述模型关系,生成类型安全的数据访问客户端,从根源上减少写SQL时的低级失误。
- NextAuth.js:认证方案,内置大量OAuth Provider和JWT/Session策略。在T3 Stack里它和tRPC做了深度集成,可以在Router中直接读取会话数据,按用户身份控制接口权限。
用一个生活类比来说:T3 Stack像一套精装修交付的厨房,每个柜子都标好了用途,烤箱、洗碗机、储物架都预埋好了电路和水管。你不需要自己研究水电图纸,只需按固定位置摆好食材(业务代码),就能开火做饭。它帮你规避了大量"工具之间如何搭配"的隐性成本。
1.3 tRPC到底解决了什么问题
理解tRPC是理解T3 Stack的关键。传统前后端联调时,你会定义一套接口文档,前端按文档请求,后端按文档返回。问题在于文档是人类语言,总有滞后和歧义。比如后端加了一个字段但忘记通知前端,前端拿到多余数据时不会报错;后端改了字段类型,前端依旧按旧类型使用,运行到某个角落才爆出诡异的bug。
tRPC的做法是把你后端的函数变成可远程调用的"过程"。它的工作流程大致是这样:
- 后端定义一个Router,Router里挂载若干Procedure(可以理解为带输入输出校验的函数)。
- 每个Procedure声明输入Schema(用Zod校验)和输出类型(由返回值推断)。
- 前端导入Router的TypeScript类型,调用同一个函数名时,编辑器里的参数提示、返回值类型全部自动带出。
这里的关键是,tRPC没有发明新的传输协议,它底层依然是HTTP请求,但把类型描述序列化到了TypeScript类型空间里。编译期,前端能感知到后端的类型签名;运行期,Zod帮你做输入校验。所以tRPC不是一门新语言,而是TypeScript类型系统的极致运用。
当然,tRPC不是万能的。如果要做开放API给第三方开发者,或者需要纯粹的RESTful语义,tRPC并不合适。T3团队也明确说,tRPC适合"前后端同属一个代码仓库"的产品型应用。这个定位在后续选型时要想清楚。
2. 环境准备与项目初始化
2.1 用create-t3-app快速搭建基础工程
T3 Stack官方提供了一个项目脚手架工具create-t3-app,它会引导你选择需要的模块,生成一个可直接运行的工程模板。我实际操作下来,从零到跑起来大概只需要三分钟。
首先确保本机Node.js版本在18.17或以上(建议直接用最新的LTS版本20.x),然后执行:
npm create t3-app@latest my-t3-project命令运行后,会出现一个交互式选择界面,主要询问你使用哪个Next.js版本、是否启用TypeScript、Tailwind、tRPC、Prisma、NextAuth、是否使用ESLint和Prettier等。我的推荐组合是:
| 选项 | 推荐值 | 理由 |
|---|---|---|
| Next.js版本 | App Router | 新项目首选,服务端组件配合tRPC体验更好 |
| TypeScript | 是 | 没有它就失去了T3的意义 |
| Tailwind CSS | 是 | 开发速度快,定制方便 |
| tRPC | 是 | 核心依赖,后面细讲 |
| Prisma | 是 | 数据库建模和迁移都靠它 |
| NextAuth.js | 按需 | 有登录需求再开,没有可以先关掉 |
| ESLint | 是 | 保证代码风格统一 |
| Prettier | 是 | 减少格式争论 |
选择完成后,脚手架会在目标目录生成完整的工程结构。然后安装依赖并启动开发服务器:
cd my-t3-project npm install npm run dev只要看到终端输出"Ready in xx ms",访问http://localhost:3000就能看到T3 Stack的欢迎界面。
2.2 目录结构:每个文件夹的职责
初次看到T3生成的项目结构时,可能会被src/下面的一堆目录吓到。其实用久了会发现编排非常规整,核心路径只有三条:
src/app:Next.js的页面路由目录。每个.tsx文件对应一个路由,比如src/app/page.tsx对应/,src/app/about/page.tsx对应/about。API路由也在这里,src/app/api/trpc/[trpc]/route.ts是tRPC的HTTP入口。src/server:服务端专用代码。api/root.ts定义了根Router,api/routers/放各个业务模块的Router,db.ts统一导出Prisma客户端。这里的文件只允许被服务端引用,绝不能import到客户端组件里。src/trpc:tRPC客户端和服务端的类型工具。server.ts负责创建服务端调用上下文(比如读取当前会话),client.ts提供React Hooks形式的API调用方案,shared.ts管理Router类型的导出。
另外还有两个文件值得注意:prisma/schema.prisma是数据库模型定义,src/env.mjs用于环境变量校验(脚手架默认用Zod校验环境变量,这个设计在部署阶段能帮你提前发现问题)。
项目整体遵循"服务端代码与客户端代码物理隔离"的原则。前端要触发服务端逻辑,必须通过tRPC或Server Action,不能直接访问Node环境。
2.3 Tailwind样式定制的基本功
T3默认集成Tailwind,打开tailwind.config.ts,你会看到它的主题扩展点。日常开发中,我习惯把品牌色、圆角、阴影等设计变量先定义在这里,而不是在组件里到处写死颜色。
比如定义一个主色扩展:
import type { Config } from "tailwindcss"; const config: Config = { content: ["./src/**/*.{ts,tsx}"], theme: { extend: { colors: { brand: { DEFAULT: "#4f46e5", light: "#818cf8", dark: "#3730a3", }, }, }, }, plugins: [], }; export default config;之后在组件里直接写bg-brand、text-brand-light,语义清晰,而且后续品牌换色,只需改动配置文件。Tailwind的响应式前缀(sm:、md:、lg:)配合flex或grid,能快速做出适配不同屏幕的布局。它和传统CSS的差别在于:你不再维护一个语义化的类名体系,而是直接在HTML结构上组合原子类。刚接触时觉得写起来繁,但配合组件封装后,实际改动频率会低很多。
3. 核心业务实现——从数据库到前端
3.1 用Prisma设计数据库模型
进入业务开发的第一步,通常是定义数据模型。我们以一个简单的"待办事项管理"应用为例,来说明整条链路。
编辑prisma/schema.prisma:
generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? todos Todo[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Todo { id String @id @default(cuid()) title String done Boolean @default(false) userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) }这里定义了一个用户模型和一个待办事项模型,关联关系是一对多。数据类型上,我用了cuid()作为主键生成策略,它比自增整数更适合分布式场景和防止URL枚举猜测。@updatedAt会自动在每次更新记录时写入当前时间,省去你手动维护的心力。
写完Schema后,执行初始化迁移命令:
npx prisma migrate dev --name init这个命令会完成两件事:一是创建迁移SQL文件,二是重新生成Prisma Client。Prisma Client是类型安全的数据访问层,之后你在代码里调用prisma.todo.findMany()时,返回对象的类型已经完全绑定好了。
开发初期为了方便,我直接用SQLite数据库,零配置,单文件,适合快速验证业务模型。等需要部署到生产环境时,只需要把datasource换成postgresql或mysql,重跑一次migrate deploy即可。Prisma支持多数据库的平滑切换,这也是我们敢在开发阶段图省事的原因。
3.2 编写tRPC Router:业务逻辑的编排中心
数据模型确定后,业务逻辑层就在tRPC Router里落地。打开src/server/api/routers/todo.ts:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"; export const todoRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.todo.findMany({ where: { userId: ctx.session.user.id }, orderBy: { createdAt: "desc" }, }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { return ctx.db.todo.create({ data: { title: input.title, userId: ctx.session.user.id, }, }); }), toggle: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const todo = await ctx.db.todo.findUnique({ where: { id: input.id }, }); if (!todo || todo.userId !== ctx.session.user.id) { throw new Error("Todo not found"); } return ctx.db.todo.update({ where: { id: input.id }, data: { done: !todo.done }, }); }), });这个Router里有三个Procedure:
list是一个查询,返回当前用户的所有待办事项。create是一个变更操作,接收一个Zod校验过的title字符串,然后写入数据库。toggle演示了一个更新操作,前面先检查这条待办事项是否属于当前登录用户,不是就抛错。
注意protectedProcedure的使用,它会在执行具体逻辑前检查Session中是否存在用户。如果未登录,请求直接返回401错误,根本不会进入后面的数据库逻辑。这个特性非常适合中后台权限控制。
为什么每个Procedure都要声明输入Schema?因为tRPC受HTTP传输体积的限制,理论上客户端可以传任何东西。Zod在这里不是可选装饰,而是安全的第三道保险。第一道是Editor的类型提示,第二道是编译期类型检查,第三道是运行期的Zod校验。
把Router挂到根Router中,编辑src/server/api/root.ts:
import { createTRPCRouter } from "~/server/api/trpc"; import { todoRouter } from "~/server/api/routers/todo"; export const appRouter = createTRPCRouter({ todo: todoRouter, }); export type AppRouter = typeof appRouter;导出AppRouter类型后,前端客户端就能感知到这个Router的所有过程。
3.3 Next.js页面与服务端渲染的组合方式
T3 Stack支持三种数据获取方式:服务端渲染(SSR)、静态生成(SSG)、客户端渲染(CSR)。具体场景各有取舍:
- SSR:每次请求动态渲染页面,适合数据变化频繁的页面,比如待办列表首页。
- SSG:构建时生成静态HTML,适合内容基本固定的页面,比如文档、营销页。
- CSR:页面先渲染骨架,再通过客户端tRPC调用后端接口,适合需要丰富交互的面板。
App Router模式下,你可以灵活组合。比如整体页面用SSR,某个模块用客户端组件调用tRPC。在T3的模板里,tRPC客户端直接对接了React Hooks,调用方式很直白:
"use client"; import { api } from "~/trpc/react"; export function TodoList() { const { data: todos, refetch } = api.todo.list.useQuery(); const createTodo = api.todo.create.useMutation({ onSuccess: () => refetch(), }); const toggleTodo = api.todo.toggle.useMutation({ onSuccess: () => refetch(), }); const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); const formData = new FormData(e.currentTarget); const title = String(formData.get("title") ?? ""); createTodo.mutate({ title }); }; return ( <div> <form onSubmit={handleSubmit}> <input name="title" placeholder="新待办事项" /> <button type="submit">添加</button> </form> <ul> {todos?.map((todo) => ( <li key={todo.id}> <label> <input type="checkbox" checked={todo.done} onChange={() => toggleTodo.mutate({ id: todo.id })} /> {todo.title} </label> </li> ))} </ul> </div> ); }这段代码里,useQuery自动触发请求,数据加载期间显示之前的缓存或空列表;useMutation负责变更操作,成功后再拉取最新列表。整个过程没有手写fetch、没有手动处理loading状态,也没有拼URL,这就是tRPC带来的直接体验。
值得注意的是"use client"指令:这个组件必须在客户端运行,因为它使用了Hooks。在App Router中,默认组件是服务端组件,只有加了这个标记才会在浏览器端执行。这个区分要时刻记住,否则容易遇到"组件里用了Hooks但Next.js报错"的情况。
3.4 表单处理与服务端校验的衔接
表单是Web开发里的高频场景。T3 Stack推荐与react-hook-form配合使用,但如果你习惯原生受控组件,其实也不冲突。
关键点是前端校验和服务端校验必须同时存在,且以服务端为准。前端校验的目的是减弱交互摩擦,比如"标题不能为空"在用户点击提交后立即提示;服务端校验是安全底线。因为tRPC的输入经Zod校验,你在Router里设置的z.string().min(1).max(100)就是服务端防线,任何绕过前端按钮直接构造HTTP请求的客户端都无法逃过这道校验。
在实际开发中,我经常看到有人只在前端做校验,后端接口裸奔。这在T3 Stack里很容易被忽视,因为tRPC帮你省去了写接口文档的麻烦,但并没有替你省去安全设计。protectedProcedure加Zod输入,这是每个Router的必备组合。
4. 常见问题与排查技巧实录
4.1 tRPC类型报错与修复
tRPC在开发中常见的报错集中在"类型不匹配"上。比如前端调用api.todo.create.useMutation()时,编辑器提示参数缺少title字段,但你在后端已经定义了输入Schema。这种情况大概率是前端没有重新生成类型。
tRPC的类型是基于Router的typeof推导的,代码改动后,Next.js开发服务器会自动热更新类型。如果你的编辑器没有立刻识别,重启一下TS Server(VS Code中执行"TypeScript: Restart TS server"命令)。另一个常见坑是:如果你在后端Router里引用了前端文件,或者反过来,代码路径违反了目录规范,也会导致类型推断混乱。记住src/server只能被服务端引用,src/trpc/client.ts只负责类型导出。
如果需要临时排查类型链路,可以新建一个ts文件显式打印Router类型:
import type { AppRouter } from "~/server/api/root"; type TodoRouter = AppRouter["todo"]; type ListOutput = Awaited<ReturnType<TodoRouter["_def"]["procedures"]["list"]>>;这种方法能把输出类型具象化,查看列表接口的返回对象到底长什么样。
4.2 Prisma连接与迁移问题
开发中最容易遇到的Prisma问题有两类。
第一类是数据库连接失败。原因是.env里的DATABASE_URL不对,或者没加载。T3模板提供了src/env.mjs做环境变量校验,如果启动时提示缺失变量,先去检查.env文件。格式如:
DATABASE_URL="file:./dev.db"第二类是模型改完以后,数据库schema没有同步。如果你修改了schema.prisma但忘记执行迁移,Prisma客户端会生成"模型定义与实际数据库不一致"的报错。解决办法:
npx prisma migrate dev --name your_change_name如果只是临时调整字段类型,不想生成迁移记录,也可以执行npx prisma db push,直接把Schema同步到数据库。但正式项目建议走迁移流程,迁移文件是团队协作时追踪表结构变化的重要依据。
4.3 NextAuth认证配置中的坑
NextAuth在T3 Stack里默认接入了Discord、GitHub等Provider。配置Provider时有一个高发问题:回调地址写错。OAuth服务商要求回调地址精确匹配,如果应用部署在https://example.com,而NextAuth内部默认回调是https://example.com/api/auth/callback/github,你需要在Provider后台也填这个完全相同地址。
另外,Session策略的选择会直接影响tRPC中session的获取方式。在auth.ts的配置中,如果使用JWT策略,服务端Context读取session会依赖请求头中的Token;如果使用数据库Session,则依赖数据库记录。两种方式都可以,但团队里别混用。T3默认在trpc/server.ts中通过getServerSession来拿会话,所以每次HTTP请求中必须正确携带cookie。开发时如果遇到"session总是null",先确认浏览器开发者工具里有没有__Secure-next-auth.session-token这个Cookie。
4.4 部署到Vercel时的环境变量同步
T3 Stack推荐部署到Vercel。由于Next.js会在构建时读取环境变量,而tRPC的根Router定义又依赖这些变量,所以构建阶段必须保证所有需要的环境变量已设置。
在Vercel项目设置里,把本地.env中的变量逐个填入Environment Variables。记得区分生产环境和预览环境,尤其是DATABASE_URL,生产库地址绝不能误配成开发库。我有一个惨痛教训:预览环境用了生产数据库,导致测试数据写进了线上库,排查了半天才定位到。
另外,如果你的数据库提供商要求固定IP白名单,而Vercel的Serverless函数出口IP不固定,通常会遇到连接被拒。解决思路有两个:一是使用支持互联网访问的数据库托管服务;二是通过私有网络连数据库,比如Vercel Marketplace里的Neon或Supabase,它们都有针对于Serverless的兼容方案。
4.5 环境变量校验带来的"额外报错"
T3模板中的src/env.mjs会用Zod校验环境变量。好处是如果你忘了配置某个必填项,应用会直接启动失败并给出清晰提示。坏处是有些团队第一次接触时会被吓到。其实你只需要在.env中把模板里声明的变量全部补齐即可。注意这些变量在部署时也一个都不能少,否则构建会红屏。
这个设计背后有个务实理由:尽早失败优于运行到一半再崩溃。环境变量错配是部署事故的高频原因,把它前移到启动阶段是成本最小的拦截手段。
5. 从零搭建T3应用的最小闭环实践
5.1 需求场景设定
为了把前面几章的内容串成一个可运行的闭环,我建议你亲手做一遍下面这个最小项目——"博客文章发布系统"。功能只有两个:文章列表、文章详情。认证暂时不启用,这样能聚焦在核心链路上。
数据库模型挂在prisma/schema.prisma里:
model Post { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }执行迁移后,在src/server/api/routers/post.ts写Router:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; export const postRouter = createTRPCRouter({ list: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, }); }), });list返回最近20篇文章的标题和ID;detail根据传入ID返回完整内容。没有认证需求,使用publicProcedure就可以。
5.2 页面与客户端调用
首页src/app/page.tsx可以写成服务端组件,直接用Prisma读取数据:
import { db } from "~/server/db"; export default async function Home() { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, }); return ( <main> <h1>文章列表</h1> <ul> {posts.map((post) => ( <li key={post.id}> <a href={`/post/${post.id}`}>{post.title}</a> </li> ))} </ul> </main> ); }文章详情页在App Router下,需要结合params读取路由参数:
import { db } from "~/server/db"; export default async function PostPage({ params, }: { params: { id: string }; }) { const post = await db.post.findUnique({ where: { id: params.id }, }); if (!post) return <div>文章不存在</div>; return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ); }这样搭建的页面,连tRPC都省了。但不是所有场景都适合服务端直连数据库:当你需要在页面中频繁做动态交互(点赞、收藏、评论)时,客户端组件的tRPC调用才是首选。这正好印证了T3保持灵活性的态度——SSR和CSR各自有适合的场景,你不必全用同一个模式。
5.3 双向验证:类型到底在哪些环节保护了你
做完这个最小闭环,你可以做一个实验:把prisma/schema.prisma里Post模型的title字段改成heading,然后重新迁移。你会发现几个地方立刻报错:
- 服务端Router里
ctx.db.post.findMany()的返回类型发生了变化,如果你在代码里访问post.title,编译器会提示"类型上不存在title"。 - 如果某个Server Component也读取了该字段,同样会在编译期被拦截。
- 前端组件如果使用tRPC
detail查询并读取data.title,也会被TypeScript标记。
这个流程展示了T3 Stack的核心理念:模型结构变更时,所有依赖它的上游代码全部被编译器"推倒重查"。相比传统方式需要一个一个查接口文档,这个成本几乎降到了零。当然,代价是每次改Schema后的联动编译时间会增加,偶尔还需要清理一下TS缓存。但相比线上事故,这些代价完全值得。
6. 部署上线与日常迭代建议
6.1 选择部署目标
T3项目最省心的部署平台是Vercel。仓库推送到GitHub后,Vercel会自动识别Next.js项目,拉取代码并构建。你只需要在面板中完成两步:设置环境变量、添加域名。如果你的项目用了SQLite,请注意Vercel的Serverless文件系统是只读的。file:./dev.db这种模式在本地没问题,但线上完全不能写文件。所以生产环境务必换用PostgreSQL或MySQL托管数据库。
如果你偏好自托管,可以用Docker方式部署:
FROM node:20-alpine AS base WORKDIR /app COPY package.json ./ RUN npm install ENV NODE_ENV=production RUN npm run build CMD ["npm", "run", "start"]这里有几个要点:NODE_ENV=production必须在构建阶段就设置,否则Next.js不会启用生产优化;npm install建议换成npm ci,它根据锁文件精确安装依赖,更可靠。生产容器里不需要保留源代码和node_modules里的开发依赖,可以用多阶段构建来精简镜像体积。
6.2 数据迁移的上线流程
上线前数据库变更不要直接执行prisma db push。推荐流程是:
- 本地开发分支运行
prisma migrate dev生成迁移文件。 - 代码合并到主分支后,通过CI执行
prisma migrate deploy。 - 更新应用程序镜像,让新代码连接升级后的数据库。
migrate deploy和migrate dev的区别在于:deploy只执行尚未应用的迁移文件,不回放本地历史,也不会要求你输入迁移名称。它是生产环境的唯一标准方式。
6.3 日志监控与性能优化
上线后,建议配置三层可观测性:
- 应用日志:Vercel的实时日志或云厂商日志服务,关注异常堆栈。
- 数据库慢查询:PostgreSQL的
pg_stat_statements或托管平台的慢SQL记录,索引缺失会在这一层暴露。 - 前端性能:Vercel Analytics或自建Web Vitals采集,观察LCP和INP指标。
T3 Stack作为一个全栈框架,并没有强制绑定日志方案。你可以在Server Router里简单打印请求耗时:
const start = Date.now(); const result = await next(); ctx.logger?.info({ ms: Date.now() - start }, "procedure done");这种轻量日志足够帮助你定位80%的慢接口问题。剩下的属于数据库索引调优和N+1查询优化,建议后续单独做一轮体检。
7. 在项目中推行T3 Stack的个人心得
7.1 什么时候该选T3,什么时候应该绕开
T3 Stack不是万能药。如果你的产品需要开放API给第三方,或者存在复杂的消息队列任务、后台定时任务,tRPC并不是最合适的技术。它的场景聚焦在"同一个团队维护同一个全栈项目",后端逻辑服务于同一套UI。如果你的后端会被多个独立客户端(iOS、小程序、管理后台、开放API)使用,那RESTful或GraphQL的稳定接口层可能更具性价比。
不过,即使是这些边界场景,T3的一些理念仍然值得借鉴:Schema优先的数据库管理、类型安全的API边界、开发阶段的极速反馈。你可以只引入Prisma,也可以单独引入tRPC,这套工具链每个部件都能独立发光。
7.2 团队协作中的约定与纪律
我在带团队落地T3时,吃过不少"缺乏纪律"的亏。举两个例子:
第一,有人为了图快,在客户端组件里直接import服务端db或Router。这在单机开发时跑得通,一旦部署到Serverless环境就会直接失败,因为它违反了前后端的物理边界。正确的做法是:客户端永远只通过src/trpc/react暴露的类型化Hooks来调用后端。
第二,有人把所有业务逻辑都堆在一个Router里,导致单个文件上千行。在T3项目里,合理的做法是按业务域切割Router,每个Router只包含它自己的Procedure。如果两个Router需要共享逻辑,抽到独立的工具函数或Service层中。这个约定不靠编译器强制,靠Code Review把守。
7.3 后续可以扩展的方向
T3 Stack本身很克制,但它的生态允许你继续扩展:表单场景引入react-hook-form + Zod resolver,状态管理场景引入Zustand,消息推送场景集成Socket.io适配器。这些都是常见扩展,不会破坏类型安全的主心骨。
我个人的建议是,先用最小闭环跑通一条完整链路,再逐步加复杂度。如果你在文章列表页已经体验到了"数据库字段改了前端立刻报错"的快感,就基本掌握了T3的精髓——剩下的都是功夫问题。