上个月在技术群里看到有人抛出"t3code"这个词,我第一反应是某个大厂又发新框架了。翻了一圈文档才发现,它并不是什么官方项目名称,而是开发者社区对 T3 技术栈的一种习惯性简称——TypeScript、Tailwind CSS、tRPC,如果再算上作为骨架的 Next.js,基本上就是当下个人全栈开发里讨论度最高的一套组合拳。这套组合拳想解决的事情其实很朴素:让你在写全栈应用的时候,类型安全从数据库一路贯穿到浏览器端,编译器替你管住接口契约,不用再靠一份经常过期的接口文档活着。
这篇文章我会用一次完整的项目实践来拆解 t3code。内容既包括选型逻辑、初始化选项,也会深入 tRPC 的端到端类型链路,最后把我在部署环节踩过的三个大坑完整复盘一遍。适合正在调研 T3 Stack 的读者,也适合那些已经跑通过 demo、但总在具体报错里打转的人。
1. t3code 到底是什么:不止是三个字母的拼接
1.1 从称呼说起:它更像一个约定俗成的简称
如果你直接搜 "t3code",确实找不到一个像样的官方网站或 GitHub 组织。这不是你搜索方式的问题,而是这个词本身就不是官方命名。在工程语境里,它大致有两种指向:一是 T3 Stack 的通俗叫法,二是用 create-t3-app 生成出来的那一类项目。T3 是 TypeScript、Tailwind CSS、tRPC 三个单词的首字母组合,后来社区也习惯把 Prisma、NextAuth 算进这套默认套餐里。
需要承认的是,"T3"这个名字在传播过程中发生过漂移。最早 theo 提出 T3 Stack 的时候,重点强调的是 TypeScript 在开发流程里的贯穿作用,但大家叫着叫着,发现 Tailwind 和 tRPC 既好记又顺口,干脆就把整个组合都收编进来了。后面 create-t3-app 的出现更是把这套组合固化成了可交互的脚手架。所以你去看任何一个声称基于 T3 技术的项目,基本跑不掉这几个关键词:Next.js、TypeScript、Tailwind、tRPC,以及大概率会出现的 Prisma 和 NextAuth。
1.2 真正要解决的问题是"全栈疲劳"
为什么 t3code 理念会在全栈开发者里受欢迎?我的理解是它精准踩中了一个普遍的痛点:全栈开发的上下文切换成本。
传统模式下,你要在后端定义接口、写参数校验、设计返回值、维护联调文档;前端再为每个接口手写类型定义、处理 loading 和错误状态。表面上看是"一个人干了两个人的活",实际上是一个人干四个人的活——后端逻辑、接口契约、前端类型、文档维护,每一项都在消耗精力。哪怕你有 Swagger、有 OpenAPI、有 codegen 工具,类型系统的对齐也总是存在缝隙:后端改了个字段名,前端重新生成代码,运气好全绿,运气不好到处都是 undefined is not a function。
t3code 的思路完全不同。它没有引入任何独立的中间协议层,而是让 TypeScript 编译器本身充当协议描述。tRPC 的服务端 procedure 定义了什么输入输出,客户端 import 对应的类型之后,编译器就替你检查参数和返回值。改一个字段、挪一个参数,报错会直接出现在编辑器的红色波浪线里,而不是出现在线上用户的反馈里。加上 Prisma 从数据库表结构反推全链路类型,zod 用同一套 schema 同时做运行时校验和编译期推导,这套体系在"类型一致性"上的完成度,确实是我用过的组合里最高的。
1.3 别把它理解成某种银弹
不过话说回来,t3code 值得关注,不等于它适合所有项目。后面我会展开讲适用的边界,但先在概念上厘清一点:它不是一套编程范式,也没有发明新语言,它只是在 TypeScript 生态里把若干成熟工具按特定方式组合起来。理解这一点很重要,因为组合出来的系统,坑也往往是"组合层面"的坑,而不是单个工具本身的问题。这也是我这篇文章坚持写实际项目和真实报错的原因——光看三件套的文档说明,远不足以理解它到底解决和制造了哪些问题。
2. 选型逻辑:为什么这套组合能减少"全栈疲劳"
2.1 对比传统方案,差异到底在哪里
很多人在选型时纠结的问题是:既然要全栈 TypeScript,为什么不用更传统的 REST API,或者干脆上 GraphQL?我把这三种方案的横向对比整理成了下面这张表,方便直观理解差异:
| 方案 | 类型安全程度 | 学习曲线 | 中间层复杂度 | 适合规模 |
|---|---|---|---|---|
| REST + OpenAPI | 低,依赖 codegen 和纪律 | 较低 | 中,需要维护文档、请求定义、响应定义 | 中小型到大型都行,但要靠流程约束 |
| GraphQL + codegen | 高,schema 两端维护 | 高 | 高,需要维护 schema、resolver、类型生成链条 | 大中型团队,跨端场景多 |
| tRPC + zod | 高,编译器直接推导 | 中低 | 低,没有独立协议层,类型即契约 | 中小型、个人项目,全队都是 TS |
重点说下 GraphQL 的对比,因为很多人会觉得 GraphQL 才是"类型安全的全栈方案"。GraphQL 确实做到了 schema 驱动,但它的代价是引入了额外的一层抽象:你要写 GraphQL Schema 定义,要写 resolver,要维护 codegen 的生成流程,前端的 type imports 来自生成文件而不是共享代码。一旦团队规模不大,这套机制的维护成本很快会超过收益。tRPC 的做法更"省事":不需要 schema,不需要 codegen,直接在代码层面让类型流动起来。它的约束前提只有一个——前端和后端都必须是 TypeScript,并且共享同一个类型宇宙。
2.2 为什么偏偏是 Tailwind 和 Prisma
样式方案看起来相对独立,但 Tailwind 在这个组合里不是随便凑数的。个人项目最怕什么?设计系统是空的、组件库没搭、写 CSS 的时间比写逻辑还多。Tailwind 的原子类思路可以让你快速产出风格一致的页面,配合 Headless UI 或 shadcn/ui 这类无样式组件库,三下两下就能拼出一个体面的界面。而且它和 React 生态的配合很顺,不会像传统 CSS Modules 那样因为命名规范产生额外的心智负担。
Prisma 的地位则更加核心。t3code 这套组合强调类型贯穿,那"数据模型"就必然需要一个能被类型系统认可的单一事实来源。Prisma 的 schema 文件恰好承担了这个角色:它不仅定义数据库表结构,还会生成完整的 TypeScript 类型,query 的返回值、关联查询的联表结果,全部带类型。配合迁移工具,从改 schema 到跑 migration,链路是完整且自动化的。这一点经常被低估——很多人只在 CRUD 层面感受到 Prisma 的便利,忽略了它在类型生态里承担"源头供应商"的责任。
2.3 什么时候别硬套 t3code
选型最忌讳"看别人都用我也用"。按我的实测经验,下面这些场景请慎重:
- 你的主要接口必须开放给第三方非 TS 客户端使用。tRPC 不是标准协议,外部接入需要自己写 HTTP 调用逻辑,体验远不如 REST 直观。
- 团队 TypeScript 基础薄。这套组合的舒适感建立在类型系统的强约束上,如果队友对泛型和类型推导不熟,报错信息反而会成为劝退理由。
- 需要做复杂的跨服务异步任务编排。tRPC 的业务定位是同步请求-响应,虽然可以扩展起步,但它不是消息队列,硬塞会很难受。
- 纯前端静态站点或纯后端服务。两者都不需要 T3 的"全栈粘合"能力,引入反而增加复杂度。
3. 初始化与目录结构:create-t3-app 的每个选项得想清楚
3.1 初始化命令和交互选项
官方脚手架的使用方式很简单:
npx create-t3-app@latest t3code-demo启动之后会出现一个交互式选项列表,这时千万不要无脑一路回车,每一项都值得想清楚:
| 选项 | 是否推荐默认启用 | 我的建议 |
|---|---|---|
| TypeScript | 必选 | 这是整套体系的基础,没有讨论空间 |
| Tailwind CSS | 推荐 | 如果完全不接受原子类,可以关,但不建议绕过 |
| tRPC | 推荐 | 这是 t3code 的灵魂,建议先保留并上手体验 |
| Prisma | 推荐 | 数据库访问层的类型支撑,几乎必需 |
| NextAuth | 按需 | 当前项目不需要登录就直接关,省得配置复杂度 |
| Drizzle | 按需 | 新版脚手架会在 Prisma 和 Drizzle 之间切换,如果偏好 SQL 语法原生可考虑 |
| ESLint / Prettier | 一直开着 | 别问,开就完了 |
| 包管理器 | 团队统一 | npm、pnpm、bun 都可以,但建议选 pnpm,依赖安装速度在本地差异明显 |
选择完成后,脚手架会直接生成一个可运行的基础项目。这里有一个很容易被忽略的点:如果你真心想学 t3code,第一件事不是急着删 demo 代码,而是应该先把生成的目录结构和每个文件的职责读一遍。
3.2 目录结构逐层拆解
一个典型的 create-t3-app 项目(Pages Router 版本)结构大致如下:
src/ pages/ # 路由页面,Next.js 文件夹即路由 api/ trpc/ [trpc].ts # tRPC 的 HTTP 入口 _app.tsx # 全局 Provider 挂载处 server/ api/ routers/ post.ts # 业务模块 router,核心写业务的地方 root.ts # 所有子 router 的汇总入口 trpc.ts # tRPC 初始化、procedure 工厂、context 类型 db.ts # PrismaClient 单例 auth.ts # NextAuth 配置 utils/ api.ts # tRPC 客户端创建,包含泛型类型定义 styles/ # 全局样式我个人的理解方式是给这套结构贴上三个角色标签:
- server目录是"服务端专属世界",里面所有代码只会在 Node.js 环境运行,可以安全操作数据库和 session;
- utils/api.ts是"类型接口站",它把服务端 AppRouter 类型引到前端,之后所有 hooks 的入参和返回值就都能推导了;
- pages/api/trpc/[trpc].ts是"运行时桥",HTTP 请求进来之后交给 tRPC 处理,完成请求分发和响应序列化。
明白这三个标签之后,你再看到诸如"为什么客户端不能直接 import server/db"这类问题,自己就能给出答案——从安全角度,把数据库访问能力暴露给浏览器端是灾难性的;从架构角度,目录边界就是运行时边界。
3.3 初学者最容易犯的两个结构错误
我见过不少项目把 create-t3-app 跑起来后就严重偏离合理结构,最常见的两个问题是:
第一,"一个 router 干到底"。默认生成的 root.ts 只是一个汇总入口,结果有人把所有业务全部塞进 root.ts 或者说直接改默认的 exampleRouter。表面上功能一样,但项目一膨胀,router 文件变成几百行的巨型函数,类型检查速度下降是小事,维护起来才真的头大。正确做法是每个业务域建一个 router 文件,比如 post.ts、user.ts、comment.ts,在 root.ts 里用createCallerFactory或mergeRouters组合起来。
第二,"把业务逻辑写在页面组件里"。有人会在页面里直接调用 Prisma client 查数据,这在 Pages Router 的 getServerSideProps 里勉强可行,但一旦逻辑复杂,测试和复用都会变得很别扭。t3code 的范式是:数据读写一律进 router 的 procedure,页面只管调用。这不只是为了类型安全,更是为了让所有业务入口收敛到可被客户端调用的统一边界上。习惯这个思维方式之后,加功能的速度会明显变快。
4. tRPC 的端到端类型安全:从原理到代码链路
4.1 原理:编译器即协议
很多人第一次接触 tRPC 的观感是"有点魔法",因为客户端没有定义任何接口描述,居然能推导出服务端的函数签名。其实拆开来看,原理并不玄学。
tRPC 没有发明运行时协议,它不是像 REST 那样约定/posts/:id返回某个 JSON、也不是像 GraphQL 那样用 schema 语言描述类型。它只是让 TypeScript 编译器担任了"协议描述符"的角色:服务端定义一个 procedure,输入用何种 zod schema 校验、输出会从 resolver 的返回值推导出什么类型,这些信息全部编码在 TypeScript 的类型空间里。客户端通过 import 服务端的AppRouter作为泛型参数,编译器就能递归地把所有 procedure 解析出来,形成对应的调用方法签名。
打个比方,这就像你去餐厅吃饭不需要看纸质菜单,因为厨师直接坐你旁边,你问"今天有什么菜",他口头报一遍,服务员又同样用这套推荐词给你上菜。中间省掉了"把菜单翻译成正式文件再翻译回来"的过程。tRPC 的巧妙之处在于把这个"口头传导"变成了编译期约束,一旦厨师改了菜名,你这边不更新点菜方式,编译器立刻会叫停。
4.2 zod 的双重身份:运行时校验和类型推导
tRPC 的输入校验默认走 zod,对很多人来说这是最容易被低估的部分。先看一段最常见的服务端代码:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; export const postRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ title: z.string().min(1).max(80), content: z.string().min(1).max(5000), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });这段代码里 zod schema 承担了两件事:
- 运行时:请求到达服务端时,input 会被 zod 执行校验,长度、类型不合法就直接抛错,返回给客户端一个结构化的错误信息;
- 编译期:
z.infer<typeof inputSchema>对应的类型会被自动绑定到input参数上,你在 mutation 函数体里敲input.,编辑器立刻补全出title和content,类型完全正确。
更厉害的是,这段类型信息会一直流动到前端。在客户端组件里,你调用api.post.create.useMutation()之后,拿到的变量、传入的参数、返回值,都会被编译器推导出来。你不用担心"服务端改了字段长度,前端表单还是旧的",因为类型链会强制你同步更新。
客户端调用侧对应的代码长这样:
const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => { utils.post.getRecent.invalidate(); }, });可以看到,前端没有手写任何 URL 字符串,没有定义请求体类型,也没有创建新的类型文件。所有的"接口定义"都来自服务端代码本身的类型形状。这就是 t3code 最具说服力的体验——协议层的维护成本几乎消失,你只需要关心业务本身。
4.3 类型链路里最容易触发报错的地方
类型链路的强大建立在严格的编译期检查上,所以它也会在你犯错时毫不留情地报错。下面这几类错误我在实际项目里见过太多次:
第一类,"修改了 input 结构但忘了改使用方"。这是好事,报错是故意提醒你。比如把content改成body,所有引用input.content的地方都会被标红,你能在提交前扫清所有残留。
第二类,"异步缓存失效没有处理到位"。mutation 成功后如果不清缓存,列表页会一直呈现旧数据。常见的处理是用api.useUtils()拿到工具对象,然后调用invalidate让相关查询失效。很多人栽跟头的原因是只 invalidate 了当前页面的 query 名称,忘了列表和详情是不同 procedure,需要根据业务关系分别失效。
第三类,"把 protectedProcedure 和 publicProcedure 搞混"。protectedProcedure 会强制要求 session 存在,匿名用户调用时直接抛 UNAUTHORIZED 错误。前端如果没有统一处理错误状态,会表现为"点了按钮没反应",这类问题排查起来比较隐蔽。成熟的写法是定义一个统一错误处理组件,全局捕获 tRPC 抛出的TRPCClientError,再根据 error.data.code 给出界面提示。
5. 实战拆解:一个评论功能模块的落地方案
5.1 数据模型设计
这一节我用一个真实的笔记分享站案例来串起整个开发链路,重点演示评论模块。先设计数据模型,这是整个链路的源头:
model User { id String @id @default(cuid()) name String? email String? @unique posts Post[] comments Comment[] } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id]) comments Comment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Comment { id String @id @default(cuid()) content String postId String post Post @relation(fields: [postId], references: [id]) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }这里有一个实用设计细节:评论列表通常需要显示作者名、作者头像等关联信息,所以在 Prisma 里把author关系建模好,查询时可以连表取回,避免后端手写多套 mapper。数据模型确定后,跑一次:
npx prisma migrate dev --name init迁移会自动生成 TypeScript 类型,你会发现接下来的编码体验立刻提升一个层级——因为关联查询的返回类型也被精确推导了。
5.2 服务端 Router 编写
评论模块的 router 我拆成三块能力:创建评论、查询列表、删除自己的评论。代码结构如下:
import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "@/server/api/trpc"; export const commentRouter = createTRPCRouter({ // 创建评论:登录用户才能评论 create: protectedProcedure .input( z.object({ postId: z.string().min(1), content: z.string().min(1).max(500), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.comment.create({ data: { content: input.content, postId: input.postId, authorId: ctx.session.user.id, }, }); }), // 按帖子查评论列表,带作者信息 listByPost: publicProcedure .input( z.object({ postId: z.string().min(1), }) ) .query(async ({ ctx, input }) => { return ctx.db.comment.findMany({ where: { postId: input.postId }, include: { author: { select: { id: true, name: true }, }, }, orderBy: { createdAt: "desc" }, }); }), // 删除评论:只能删除自己的,写进 procedure 而不是页面判断 delete: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const comment = await ctx.db.comment.findUnique({ where: { id: input.id }, }); if (!comment || comment.authorId !== ctx.session.user.id) { throw new Error("无权删除该评论"); } return ctx.db.comment.delete({ where: { id: input.id } }); }), });注意我在删除逻辑里专门做了权限校验。可能有人觉得多此一举,页面端判断一下"当前用户是不是评论作者"就够了。但从安全和架构上讲,业务规则必须落在服务端 procedure 里,因为 tRPC 的接口是可以被外部直接调用的,任何只靠前端隐藏按钮的尝试都不构成真正的权限控制。这是我在 t3code 项目里学到的很重要的工程习惯:页面控制交互可见性,服务端控制数据安全性。
5.3 页面接入与缓存失效策略
客户端我在_app.tsx挂好 tRPC Provider 后,评论区组件直接使用 hooks。列表查询用useQuery,创建和删除用useMutation。这里最关键的是缓存失效策略:
const utils = api.useUtils(); const createMutation = api.comment.create.useMutation({ onSuccess: async () => { // 评论列表刷新 await utils.comment.listByPost.invalidate({ postId }); }, }); const deleteMutation = api.comment.delete.useMutation({ onSuccess: async () => { await utils.comment.listByPost.invalidate({ postId }); }, });注意invalidate是可以传过滤条件的,表示"让符合条件的查询缓存全部失效"。一开始我图省事,直接在onSuccess里调用router.refresh()或location.reload(),结果页面体验生硬,刷新时机还容易出问题。用invalidate配合 tRPC 内置的 react-query 缓存,页面能在后台自动更新数据,用户无感,体验顺滑很多。
还有一种常见的边界问题:如果评论详情和评论列表是两个不同的 procedure,删除评论后详情里的总数也需要同步减少。这种情况下要么把计数算进列表返回结果里,要么在onSuccess里同时 invalidate 详情查询。按我的经验,最好让评论列表 procedure 直接返回一个commentCount字段,这样页面只需要订阅一个查询源,数据一致性更容易保证。
5.4 错误处理:不要让报错变成白屏
评论提交最需要容错。表单输入为空、字数超限、未登录,三种情况下必须给出友好的提示而不是把错误丢给控制台。配合 zod 很好办,因为 zod 错误里带有fieldErrors,你可以在前端拿到并直接展示:
const errorMessage = error?.data?.zodError?.fieldErrors; if (errorMessage?.content) { setContentError(errorMessage.content[0]); }tRPC 的错误对象结构里,data.zodError正是服务端 zod 校验失败后透传的字段级错误信息。这又是一个类型安全带来的红利:前端不需要猜接口会返回什么错误格式,编译器已经把错误类型推出来了。
6. 部署上线:最容易翻车的三个地方和应对
6.1 Prisma 连接池:serverless 环境的头号杀手
在本地开发时,一切风平浪静,但一部署到 Vercel 这种 serverless 平台,第一个炸的通常就是数据库连接。原因在于 serverless 函数会按请求维度或实例维度频繁创建和销毁,而每个函数实例里如果都 new 一个 PrismaClient,连接数会快速堆积,最终把数据库连接池打满。
create-t3-app 默认的server/db.ts其实已经做了最重要的优化——用 globalThis 缓存 PrismaClient 实例:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;这段代码的道理是:全局变量也存在整个 serverless 实例的生命周期里,多次热重载不会反复创建实例;在函数复用的情况下,连接也能被复用。但在生产环境仍然可能出现连接数过高的问题,我的应对建议是:
- 数据库连接字符串上追加连接池参数(如 PostgreSQL 的
?pgbouncer=true或sslmode=require&connect_timeout=10),减少单命令连接开销; - 如果连接数依然紧张,考虑用 PgBouncer 或 Prisma Accelerate 这类中间层管理连接。
- 千万别把
connection_limit调成无脑大数,连接池的黄金法则是"够用即合理",过多反而增加数据库侧调度压力。
6.2 NextAuth 在 serverless 下的 session 策略
T3 模板里集成 NextAuth 很顺手,但默认配置在 serverless 环境容易踩坑。最简单的一个:用户刚登录成功,刷新页面却变成未登录状态。原因多半是 session 策略选了 database,而每次请求都要查 session 表,在 serverless 冷启动或连接池压力大的情况下,频繁访问数据库很容易失败或超时。
我的建议是:大部分个人或中小型项目,直接把 session 策略切成 JWT:
session: { strategy: "jwt", },JWT 方案把会话信息加密放到 cookie 里,服务端不需要每次查数据库,冷启动的额外开销显著降低。代价是注销和封禁用户会有延迟,因为令牌有一段时间内有效,但对于 t3code 覆盖的大多数应用场景,这点副作用完全可以接受。
另外还有一个常见问题:在 tRPC 的 context 里用getServerSession时,有人发现 session 总是 null。排查思路通常围绕三处:是否在_app.tsx正确包裹了 SessionProvider、API Route 的入口是否在pages/api/auth/[...nextauth].ts、以及 NextAuth 的 secret 是否稳定配置。很多部署环境不提供固定的 crypto 密钥,如果 secret 每次构建都变化,session 自然会对不上。解决方法是把NEXTAUTH_SECRET显式写死在环境变量里。
我在 tRPC context 里通常会这样组织 session 数据:
export const createTRPCContext = ({ session }: { session: Session | null }) => ({ db, session, });之后所有 procedure 都能从ctx.session拿到当前登录用户,protectedProcedure则会在进入 resolver 前验证 session 存在,不存在直接抛错:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });6.3 Next.js 缓存策略:数据看起来"没更新"的真相
这是我线上最容易迷糊的地方,直到把三个 API 的语义彻底理清:
router.refresh():刷新当前路由的 RSC 数据,不触发服务端重新执行逻辑,只更新已渲染部分;revalidatePath/revalidateTag:在服务端动作里主动让某个路径或某个 tag 的缓存失效,常用于 ISR 页面;invalidate:tRPC 客户端层的查询缓存失效,让 react-query 重新请求数据。
很多人在评论区组件里改了数据,却用router.refresh()期望整个页面数据都更新,结果发现某些通过静态渲染生成的列表纹丝不动。正确逻辑是:客户端数据变动优先用 invalidate,服务端缓存策略在需要时配合 revalidatePath。如果你用了 App Router,并且在布局里设置了fetchCache之类的缓存选项,还要注意 tRPC 的 HTTP 请求是否能穿透缓存。我的一次线上事故就是 ISR 页面重验证周期没有设短,用户发布了一条笔记,主页却 60 秒内都看不到更新。后来我在发布动作里主动调用revalidatePath("/"),问题立刻消失。
还有一个很隐蔽的下载坑:在开发环境切客户端组件边界时,报"Server Component cannot use useQuery"之类的错。那是你在 RSC 里直接调用了 tRPC 的 hook,tRPC hooks 是绑定 React Query 的,只能出现在客户端组件。正确做法是页面组件保持服务端组件,只把需要交互的区块单独抽成带"use client"的子组件,再在子组件里调用 hooks。这个边界习惯越早养成,后面踩坑越少。
7. 最后:几个小技巧和个人体会
坦白说,t3code 不是银弹,它没有发明新语言,也没有解决所有工程问题。但对我这种"一个人想干全栈的活、又不想让接口文档拖后腿"的开发者来说,它确实是目前体验最好的开启方式。代码写完直接有类型保护,前端调用后端像调用本地函数,这种开发感受一旦适应,再回去写手搓 REST + 手写类型,会觉得相当煎熬。
几个从实际项目里总结的小技巧,送给准备上手或正在用这套组合的读者:
第一,新开项目直接用 create-t3-app,别从零手搓。不是说你不会配 Webpack 或 PostCSS,而是脚手架默认的坑已经踩平了,省下来的时间应该花在业务上。
第二,保持"一个业务域一个 router"的拆分习惯。我见过太多项目把所有接口堆在一个文件里,刚开始很爽,后边每加一个功能都要看几百行代码才敢动手。按 module 拆分,配合 zod schema 的集中管理,整个项目的可维护性完全不是一个级别。
第三,遇到改了 router 但客户端类型没更新的情况,第一反应是重启 dev server,而不是怀疑 TypeScript 配置。tRPC 的类型推导基于服务端代码的最新状态,偶尔热更新不及时,重启一下就好了。
第四,VSCode 强烈建议装 Tailwind CSS IntelliSense 和 Prisma 官方插件,前者让你写原子类不靠猜,后者让 schema 文件带语法高亮和自动补全。这些看起来很小的插件,实际使用体验提升非常大。
第五,数据库 schema 设计阶段多投入点时间。t3code 下 Prisma schema 是类型源头,改一个字段名会波及整个链路,迁移发布前先想清楚,比上线后反复打补丁节省太多成本。
如果你已经跑通了这篇文章里的流程,下一步可以试着往项目里加实时更新能力,或者把 tRPC 的中间件机制用起来做更精细的权限控制。这套组合的扩展面很广,越深入用,越能体会到"类型系统贯穿始终"的爽感。