我一直觉得,前后端团队之间最消耗精力的不是写代码,而是"对齐"——对齐接口文档、对齐字段命名、对齐类型定义。半年前我发起了一个内部代号叫t3code的项目,初衷特别朴素:把"对齐"这件事从人和文档之间的反复沟通,变成编译器的自动检查。t3code 不是一个商业产品,也不是什么新框架,它是我和团队在 T3 Stack 上沉淀出的一整套 TypeScript 全栈实践,覆盖数据库建模、后端路由、前端组件到部署 CI/CD 的完整链路,核心就一句话:类型安全从数据库一路贯通到浏览器。这篇文章是把这套实践从头到尾复盘一遍。如果你还在纠结全栈项目怎么选型,或者已经被前后端类型不一致的问题折磨过,下面这些内容应该能给你一套可以照着改的答案。
1. 为什么我要专门开一个叫 t3code 的项目
1.1 立项前我们被"双重类型维护"折磨了太久
在 t3code 之前,团队的主力项目是典型的前后端分离架构:前端 React + TypeScript,后端 Node.js + Express,数据库用 MySQL。听起来没什么问题,实际跑起来全是问题。后端每个接口都要在src/types里手工维护 DTO 类型,前端 request 层又有一份几乎相同但经常不同步的 interface。改一个字段,改三处:数据库 migration、后端类型、前端类型。漏改一处,运行时不炸,等到联调才发现undefined、null判断满天飞。
印象最深的一次事故:后端把用户昵称字段从nickname改成了displayName,文档写了,后端改了,但前端类型文件没人动。结果列表页正常,详情页直接白屏。我们花了一个下午排查,最后发现只是字段名不一致。这种问题用静态检查根本拦不住,因为前后端是两个独立的类型系统,编译器不会跨进程做契约校验。
所以我立项 t3code 的时候,第一诉求不是技术新颖,而是:消灭手写契约,让类型系统自己长出完整链路。
1.2 t3code 这个名字代表什么
t3code 字面上是"T3 的代码实践"。T3 Stack 的核心就是 Next.js + TypeScript + tRPC + Prisma,再加上可选的 Tailwind CSS 和 NextAuth.js。我之所以用这个代号,是因为它正好体现了我想要的三层含义:T3 Stack 的完整落地;TypeScript 从 start 到 finish;以及 team 协作层面的"代码即契约"。
在立项说明里我给团队写了一段话:以后接口文档不再是 Markdown,而是.ts类型定义;接口联调不再是人对文档,而是编译器对类型。类型不匹配,构建直接失败,根本走不到联调那一步。这个说法当时被不少人质疑,觉得夸张。半年跑下来,团队再也没有因为字段名不一致开过排查会。
1.3 这个项目适合谁作为参考
如果你属于下面任何一种情况,我建议你把这篇复盘当一份参考清单:
- 中小型全栈项目,想要一套代码同时搞定前后端,不想维护两份类型定义
- 团队刚起步,没有专职后端,前端同学又不想写接口文档
- 已经受够 REST API 文档过期、类型漂移、联调扯皮的团队
- 想做类型安全实践,但不确定 tRPC、Prisma、Next.js 之间的配合边界
当然,t3code 也不是银弹。它的适用边界在文末我会专门说,避免你拿着锤子找钉子。
2. t3code 的技术底座:四件套的分工与协作逻辑
2.1 为什么是 Next.js + TypeScript + tRPC + Prisma
先直接给结论:这四个工具凑在一起,是因为它们在"类型安全"这件事上形成了闭合链路,各管一段,没有真空地带。
- Next.js:负责应用框架,包括路由、渲染模式、打包和服务端/客户端组件调度。它解决的是"应用怎么组织"。
- TypeScript:是整个链路的统一语言。严格模式下,它负责把每一层的数据形状变成可被编译期检查的结构。
- tRPC:负责 API 层的类型安全。它不生成任何独立 schema 文件,而是直接复用服务端函数的入参和出参类型,再通过类型推断同步到前端。
- Prisma:负责数据库访问层的类型安全。你定义的
schema.prisma会生成一套完整的 TypeScript 类型,查出来的每一行数据都有精确类型,连关系查询的嵌套结构都能推断出来。
这三者加在一起的效果是:你在 Prisma 里定义一个字段,类型会一路流经 tRPC 的查询函数,最终出现在前端组件的data变量上。中间任何一层改了这个字段的名字或类型,TypeScript 会在几秒内把所有用到它的地方标红。我在 t3code 项目文档里管这个叫"类型瀑布"——是自动流下来的,不需要人手工搬运。
2.2 为什么我最终没选 GraphQL
立项时团队内部讨论过要不要上 GraphQL。这里说说我的取舍,你选型时可以参考:GraphQL 确实也能做到端到端类型安全(配合 codegen),但代价是引入一套 schema 语言、一套解析层、外加代码生成和缓存规范,心智负担不小。对于内部项目、后台管理系统、中小规模应用,这套成本经常是过度的。
tRPC 的思路完全不同:没有 DSL、没有 schema 文件、没有 codegen 步骤。它就是函数调用,只不过这个函数从浏览器跨到了服务器。类型安全完全建立在 TypeScript 的结构类型系统之上,服务端定义的就是前端拿到的,声明和实现永远在一处。用大白话说:GraphQL 是给 API 单独发明了一门语言再翻译成 TS,tRPC 是直接让 TS 当 API 的语言。对于团队主力语言就是 TS 的情况,后者明显更省事。
2.2.1 结论:代码共享,而不是代码生成
t3code 选型还有一个底层原则:优先选择共享类型,而不是生成类型。共享类型是指一份 TS 定义同时被服务和客户端引用,生成类型是指通过 codegen 从 OpenAPI/GraphQL schema 产出客户端类型。共享类型的优势是改一处即可全局生效,但劣势也明显——服务端和客户端没有任何隔离。所以必须配合下文 4.3 说的上下文和鉴权机制,防止前端直接碰到底层数据库调用。
2.3 我们额外补充的工具链
基础四件套之外,t3code 还配了这些辅助工具,每一样都解决了具体痛点:
- Zod:tRPC 的入参校验。每次查询或变更前,Zod schema 会先验证输入,非法输入直接在 API 边界被拦截,不用进业务代码。
- Tailwind CSS:样式方案。项目是管理后台 + 部分面向用户页面,用 Tailwind 做原型迭代非常快。
- NextAuth.js(Auth.js):登录态与鉴权。t3code 里所有写操作都要求登录,用 NextAuth 的 session 直接注入 tRPC 上下文。
- ESLint + Prettier + TypeScript strict:代码规范基线。ESLint 管逻辑问题,Prettier 管格式,TS strict 管类型严谨度,这三者缺一不可。
- superjson:序列化方案。tRPC 默认传输 JSON,但
Date、Map等类型会丢。superjson 能在序列化时保留这些类型,前后端拿到的还是Date对象,不用手工new Date(...)一遍。
我在很多项目里见过一个误区:以为用了 TypeScript 就安全了,结果开了strict又关掉、或把any用得满天飞。t3code 的约定是:新代码不允许出现显式 any,遇到复杂类型写清楚或交给 Zod 推断。这是底线。
3. 初始化与目录结构:create-t3-app 落地实操
3.1 初始化命令与交互项选择
t3code 是从create-t3-app脚手架开始的,命令只有一行:
npx create-t3-app@latest t3code交互式提问会根据你选的依赖动态变化。以我当时用的版本为例,核心选择项及我的选择如下:
| 选项 | 我的选择 | 说明 |
|---|---|---|
| TypeScript | 是 | 没得商量 |
| Tailwind CSS | 是 | 团队已熟悉 |
| tRPC | 是 | 核心链路 |
| Prisma | 是 | 核心链路 |
| NextAuth.js | 是 | 需要登录鉴权 |
| Docker | 否 | 部署暂用托管服务,不需要镜像 |
| CI | 是 | 生成的 CI 模板能改,省事 |
这里给你一个建议:第一次初始化时,除了完全用不到的项,其他都先选上。脚手架帮你把 tRPC + Prisma + NextAuth 的胶水代码接好,这是它最大的价值。你后面如果想移除某个模块,成本很低;但一开始没选、以后想手动接入,要踩的坑会多不少。
3.2 生成后的目录结构:每一层是干什么的
创建完成后,锁眼目录大概是这样的(基于当前 App Router 版本的 create-t3-app):
t3code/ ├── prisma/ │ └── schema.prisma # 数据模型定义 ├── src/ │ ├── app/ │ │ ├── api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 │ │ ├── _components/ # 前端组件 │ │ └── page.tsx # 首页 │ ├── server/ │ │ ├── api/ │ │ │ ├── root.ts # 汇总所有子路由 │ │ │ └── routers/ # 业务路由器(post、user...) │ │ └── db.ts # Prisma Client 单例 │ ├── trpc/ │ │ ├── server.ts # 服务端 tRPC 实例 │ │ ├── client.ts # 客户端调用封装 │ │ └── react.tsx # React Hooks 绑定 │ └── env.js # 环境变量校验我对新人的讲解方式是:把这套结构想象成三条管道。prisma/schema.prisma是数据源头,src/server/db.ts是源头的水龙头,src/server/api/routers/*是给前端送水的泵站,src/trpc/react.tsx是每户人家装的水表。前端组件拧开水表就能用到数据,而且水里有什么字段,类型上写得明明白白。
3.3 初始化完成后我习惯立刻做的三件事
脚手架跑通只是起点。t3code 初始化后,我固定会做三件事,否则后面会被环境问题反复绊倒:
- 配置
.env环境变量:打开.env.example,把DATABASE_URL、NEXTAUTH_SECRET等真实值填好。别用默认值跑生产,这不是开玩笑的事。 - 执行一次数据库同步:如果还没有 migration,先跑
npx prisma db push把 schema 同步到数据库;如果没有数据库,先建一个再同步。 - 跑一次
npm run dev并打开http://localhost:3000:看到一个正常渲染的页面后,立刻打开浏览器 DevTools 的 Network 面板,找到api/trpc/*的那些请求。确认请求是 200,且返回 JSON 里带result.data,说明 tRPC + Prisma 的链路已经通了。
初始化阶段最容易翻车的是NEXTAUTH_SECRET没设置,导致登录相关功能报错。生成一个的简单办法是终端里执行openssl rand -base64 32,然后把输出粘进去。
4. 端到端类型安全的核心链路:从 server router 到前端组件
4.1 tRPC Router 与 Procedure 是怎么工作的
t3code 后端业务逻辑都写在src/server/api/routers/下。一个典型的 post router 长这样:
// src/server/api/routers/post.ts import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { z } from "zod"; export const postRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(50).default(10), }), ) .query(async ({ ctx, input }) => { const posts = await ctx.db.post.findMany({ skip: (input.page - 1) * input.pageSize, take: input.pageSize, orderBy: { createdAt: "desc" }, include: { author: { select: { name: true } } }, }); return { posts, page: input.page, pageSize: input.pageSize }; }), create: protectedProcedure .input(z.object({ title: z.string().min(1), content: z.string().min(1) })) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id }, }); }), });这里的核心概念就两个:router是路由容器,procedure是一个具体的查询或变更操作。query对应读操作,mutation对应写操作。这个区分不是摆设,前端 React Query 会根据它是 query 还是 mutation 自动选择useQuery还是useMutation,缓存策略也不同。你不需要额外写 controller 层,router 本身就是 controller。
注意第 4 行开始的protectedProcedure,它代表这个操作要求已登录。这个词不是 t3code 自定义的,而是 tRPC 的中间件机制,接下来的 4.3 会展开。
4.2 类型是怎样从后端"流"到前端的
这一节是整个项目最核心的部分,我尽量用大白话讲。
在src/server/api/root.ts里,所有子 router 被汇总成一个AppRouter:
// src/server/api/root.ts import { postRouter } from "~/server/api/routers/post"; import { createTRPCRouter } from "~/server/api/trpc"; export const appRouter = createTRPCRouter({ post: postRouter, // user: userRouter, }); export type AppRouter = typeof appRouter;这行export type AppRouter = typeof appRouter是精髓。它把一个运行时的对象(router 的实际结构)变成了一个编译时的类型。前端代码 import 的不是一个 schema 文件,而是这个类型本身:
// src/trpc/react.tsx(脚手架生成的核心封装) import { createTRPCReact } from "@trpc/react-query"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCReact<AppRouter>();createTRPCReact<AppRouter>()会让前端拿到完整的路由类型信息和入参/出参类型信息。组件里调用时,TypeScript 能自动提示:
const { data } = api.post.list.useQuery({ page: 1, pageSize: 10 }); // data 被自动推断为 { posts: { id: string; title: string; content: string; author: { name: string } }[]; page: number; pageSize: number }如果后端把content字段改成body,前端这个组件会在编译期直接报红,不会等到运行时。这就是我开头说的"让编译器帮我们对齐"。整个过程的原理其实很朴素:TypeScript 的结构类型系统允许一个类型通过typeof被整体捕获并传递,tRPC 只是巧妙地利用了这一点,在服务端和客户端之间建立了一座类型桥梁,而不是自己发明一套新的类型语法。
4.3 上下文、中间件与鉴权注入
类型安全解决了"数据的形状",但没解决"谁能拿数据"。t3code 的鉴权链路是这样设计的:
HTTP 请求进入 Next.js App Router 的src/app/api/trpc/[trpc]/route.ts,被 tRPC 的 HTTP 处理器接收。处理器会调用createContext函数,生成本次请求的上下文对象。脚手架默认的上下文长这样:
// src/server/api/trpc.ts(简化示意) import { getServerAuthSession } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerAuthSession(); return { session, db, headers: opts.headers }; };上下文里有两样东西:数据库实例db和用户会话session。之后在trpc.ts中有一段中间件逻辑:
export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new Error("未登录"); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user } } }); });这段逻辑的意义是:protectedProcedure在执行任何业务代码之前,先检查 session 里有没有用户。没有就直接抛错,有就把session塞回上下文,业务代码里就能通过ctx.session.user.id拿到当前用户。你用普通procedure做公开读操作,用protectedProcedure做需要登录的写操作,权限边界就清晰了。
这里有一个很容易踩的坑:不要把db直接暴露给普通 procedure 的返回值。比如你写了一个公开接口,结果return ctx.db.user.findMany(),等于把全库数据通过 API 扔出去了。正确做法是在 router 里明确select需要的字段,或者用include控制返回形状。类型安全管的是形状对不对,权限管的是能不能拿——两件事必须都做。
5. 数据建模与迁移:Prisma 在 t3code 里的完整实战
5.1 schema 设计与迁移流程
t3code 的数据模型在prisma/schema.prisma里定义。以文章系统为例,最核心的两张表是 User 和 Post,加上一张注释表。定义方式如下:
generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } 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()) } model Comment { id String @id @default(cuid()) text String postId String post Post @relation(fields: [postId], references: [id]) authorId String author User @relation(fields: [authorId], references: [id]) createdAt DateTime @default(now()) }定义完 schema 后,执行迁移:
npx prisma migrate dev --name init这个命令做三件事:生成 SQL 迁移文件、执行迁移、重新生成 Prisma Client。生成的 Client 会连带产生完整的 TypeScript 类型,比如Prisma.PostGetPayload<{ include: { author: true } }>这样的工具类型。你在 router 里能拿到什么数据形状,写的时候编译器全知道。
团队协作时,我建议把prisma/migrations目录完整提交到 Git,每个成员按序跑npx prisma migrate deploy而不是db push。db push适合开发早期快速改模型,migrate deploy适合多人环境同步正式结构,两者混用容易造成迁移历史不一致。
5.2 与 tRPC 路由的联调模式
Prisma 和 tRPC 在 t3code 里的结合非常直接:router 的query内部直接用ctx.db查询,查询结果作为函数的返回对象。比如文章列表接口,我习惯直接写上include和select,需要的字段一次性查出来:
const posts = await ctx.db.post.findMany({ where: { authorId: ctx.session.user.id }, select: { id: true, title: true, createdAt: true, author: { select: { name: true } }, }, });这样前端收到的post.author.name就是字符串,而不会出现整个 author 对象甚至密码字段泄漏的隐患。很多新手会把整个Post模型 return 出去,这在 t3code 里属于需要严格禁止的写法——类型上没错,但数据面太大了,深层的关联数据也会被序列化,接口响应变慢不说,还有过度暴露风险。
5.3 数据查询中的三类典型坑
这半年在 t3code 上碰到过的 Prisma 问题,我归纳成下面三类,每一类都耽误过半天以上:
第一类是N+1 查询。列表接口里循环查询每个 post 的作者,数据库请求数等于文章数加一。正确做法是用include或select一次性把关系数据带出来,让 Prisma 生成 join 查询。你可以在 Prisma 的日志里看到实际生成的 SQL,如果发现有规律性的重复查询,基本就是 N+1。
第二类是Serverless 环境下的连接数浪费。如果把 Prisma Client 直接放在冷启动路径里反复创建,连接池很容易被撑爆。t3code 的做法是在src/server/db.ts里做成全局单例,利用globalThis缓存实例,开发环境经历热重载时也不会重复创建。
第三类是Date 类型的序列化丢失。用 tRPC 默认的 JSON 序列化返回Date字段,前端拿到的是一串字符串,比较大小和格式化都很别扭。装了 superjson(脚手架默认带),再把superjson同时配置到 tRPC 两端,前后端就都能拿到真正的Date对象。这个配置不是可选项,而是必须项,否则"类型安全"再安全,也管不住运行时序列化。
6. 部署与 CI/CD:类型安全的"最后一公里"
6.1 构建阶段的类型检查
t3code 的 CI 流程里,第一步不是跑测试,而是类型检查。用的命令是:
npx tsc --noEmit这个命令会全量检查整个项目的 TypeScript 类型,包括 server 端和 client 端。任何一端类型不匹配,构建直接失败,代码根本走不到部署环节。这一步其实是整个类型安全价值的"兑现时刻"——本地编辑器里的红浪不一定被当回事,但 CI 跑不起来,谁都没法合代码。
我见过的团队有这样的情况:本地npm run dev一切正常,一跑tsc --noEmit报出几十个错误,基本都是历史遗留的any和后端返回值形状差异。解决方案是渐进式收紧:先在tsconfig.json里开strict: true,然后约定新代码零 any,再定期清理存量 any。t3code 从立项第一天就坚持这个约定,所以一直没有沉淀出大笔技术债。
6.2 环境变量管理:最容易翻车的部署细节
部署阶段,环境变量比代码更让人头疼。t3code 涉及的关键环境变量至少有三个:
| 变量 | 用途 | 是否必须 |
|---|---|---|
DATABASE_URL | Prisma 连接数据库 | 必须 |
NEXTAUTH_SECRET | 加密 session | 必须 |
NEXTAUTH_URL | 回调地址,生产环境填正式域名 | 必须 |
这里有个部署时特别容易踩的坑:Prisma 的构建过程需要访问数据库。prisma generate本身不需要数据库连接,但如果你在构建脚本里同时调用了prisma migrate deploy,那构建机器必须有DATABASE_URL。我们在 CI 里通过在环境变量中临时配置DATABASE_URL指向一个允许 CI 访问的数据库地址来解决。
另一个坑是Vercel/Serverless 平台下的连接池。如果直接用原生DATABASE_URL,在冷启动密集的场景,短连接数量可能打满数据库连接上限。建议使用连接池模式的连接字符串(比如通过代理连接的DATABASE_URL),配合 Prisma 的?pgbouncer=true连接参数,把连接池压力释放到数据库之外。这个具体参数在不同数据库服务商略有区别,但思路是通用的。
6.3 数据库部署方案的取舍
t3code 的后端数据库我从头到尾用的都是 PostgreSQL。部署方式试过三种,结论供参考:
- 本地托管型数据库服务(如自建 Postgres):最适合开发环境,免费、可控,但生产环境需要自己操心备份、监控、扩容。
- Serverless 数据库(如 Neon、Supabase 这类 Postgres 兼容服务):按量计费、自动扩缩容,非常适配 Next.js 的按需部署模型。t3code 生产就用的这类。
- 传统云数据库实例:适合数据量已经很大、需要稳定低延迟的团队,但成本偏高、运维工作量偏多。
我个人建议,中小规模项目直接上 Serverless 数据库,先跑起来,等数据量大了再迁到托管实例。Prisma 的迁移文件是数据库无关的 SQL,切换成本不会高到让人放弃。
CI 流水线里还有一个值得加的阶段:迁移预览。在应用迁移到生产数据库前,先在一个临时数据库跑一遍prisma migrate deploy,确认 SQL 无误再执行线上。数据库迁移出问题,轻则表锁死,重则丢数据,这个步骤是防止"最后一公里"翻车的兜底。
7. 调试技巧与性能优化:t3code 跑进生产后的实战心得
7.1 类型不刷新:排查"改了类型但前端还是旧类型"的刺头问题
跑 t3code 一个月后,我们遇到一个典型问题:后端改了一个 router 返回类型,前端编辑器里useQuery的数据类型却还是旧的,不报错也不更新。第一反应是 tRPC 类型没生效,排查了半小时,最后发现是TypeScript 语言服务的增量缓存问题。
解决方案分两步:
- 先确认 dev server 还活着,tRPC 的类型不是物理文件变就能立刻全量刷新的,有时候需要重启
npm run dev。 - 重启之后还不行,关掉 VS Code 再开,或者用
npx tsc --noEmit全量检查一下,看是编辑器缓存还是真实类型错误。
这种问题对埋头写代码的开发者来说干扰很大,所以我在团队里立了个规矩:只要是涉及 router 层类型变更的改动,必须顺手跑一次tsc --noEmit,再提交 PR。这比单纯靠编辑器提示靠谱得多。
7.2 网络请求调试:DevTools 里 tRPC 的请求结构
t3code 做接口联调时,不需要像传统 REST 那样翻接口文档。打开 DevTools 的 Network 面板,找到api/trpc/post.list?input=...这样的请求,能看到清晰的负载结构。tRPC 的 HTTP 请求长这样:
POST /api/trpc/post.list Content-Type: application/json {"json":{"page":1,"pageSize":10}}响应是标准 JSON:{"result":{"data":{...}}}或错误结构{"error":{"message":"..."}}。遇到 400/500 错误,优先看 Network 面板里的响应内容,通常错误信息已经足够定位是 Zod 校验失败还是业务逻辑抛错。如果响应信息不够明确,就在 router 里临时加上console.log,serverless 日志平台能看到打印。
这里有个我常用的调试技巧:在src/trpc/client.ts里加一层轻量日志中间件,把每次请求的路径、耗时打出来。这样在开发环境排错时,一眼就能看出哪个 procedure 慢,哪个请求的 input 传错了。等调试完再移除或按环境变量开关。
7.3 性能与缓存:React Query 在 t3code 里的正确用法
t3code 的类型安全解决了正确性,但性能问题的根源在请求频率。tRPC 默认绑定@tanstack/react-query做数据请求,React Query 提供了一套完整的缓存和状态管理机制,但默认行为仅仅是"缓存 + 重新验证",不是你想象中那样把所有请求都优化掉了。
我总结出三个在 t3code 里最实用的性能手段:
第一个是useSuspenseQuery结合服务端渲染。对首屏的列表数据,用 React Suspense 模式配合 SSR,首屏能从"加载转圈"变成"等待后直接渲染",体验提升非常明显。
const { data } = api.post.list.useSuspenseQuery({ page: 1, pageSize: 10 });第二个是控制staleTime。默认的staleTime是 0,意味着每次进入页面都会重新请求。对不频繁变化的数据(比如用户资料),我习惯把staleTime设成 30 秒到 1 分钟,大幅减少服务器压力。
第三个是避免在组件里直接调用多个顶层useQuery。如果页面某个区域同时需要文章列表和用户信息,React Query 本身会并行发出请求,这没问题,但如果你发现同一个接口在多个页面分别调用,可以考虑把数据提升到父组件的 query 中,通过 props 传递,减少重复请求。
数据量大时,还需要考虑分页和无限滚动。t3code 的列表页用的是 React Query 的useInfiniteQuery模式,tRPC 对应的 API 是useInfiniteQuery。分页参数放 input 里,后端用cursor或skip/take都行,但要注意游标分页在大数据集下性能明显优于skip/take。这是我实际测试过才换过来的,原因是skip/take在深页码时数据库 offset 扫描会越来越慢,而游标分页只需要定位到游标位置继续取,受数据量影响小得多。
8. 最后再聊聊 t3code 的适用边界
很多人问我,t3code 这套东西是不是所有项目都能套?我的答案是:它的收益随项目复杂性增长,但也随项目复杂性递减。如果你的项目是一个简单的静态内容站加一个表单提交,那完全没必要上 tRPC + Prisma,用 Next.js 的 Server Actions 就够。反过来,如果你的 API 需要对外开放给第三方开发者,tRPC 也不是好选择——它更适合"你自己前端调用自己后端"的场景,外部接入方拿不到也看不懂你的类型协议。
据我这半年的实际体验,t3code 最适合的形态是:内部管理系统、数据密集型后台、中后台全栈应用,以及那些前端团队能力强、后端资源紧缺的初创项目。它会最大程度压缩接口文档和维护成本。另一个很现实的好处是新人 onboarding 变快了——新同学只要看一遍类型定义,就知道后端能提供什么数据,而不是费力翻文档或求问老同事。
最后分享两个实用的收尾小技巧。
一个是给 t3code 的 router 命名规范:按业务域拆文件,按资源名拆 router。比如postRouter只管文章,userRouter只管用户,千万别把所有接口堆在一个root.ts里。另一个是在根目录放一份CONTRIBUTING.md,把"新代码禁止 any + 每个 mutation 必须配 Zod input + 涉及类型变更必须跑 tsc"这些规则写清楚。团队成员流动时,这份文件比任何口头沟通都可靠。踩过那么多坑之后你会发现,类型安全的价值不是"省事",而是把团队里最珍贵的沟通成本,省下来去写真正有挑战的代码。