1. 为什么是 T3,为什么叫 t3code
最早冒出这个念头,是在一次前后端联调被类型定义逼疯的晚上。前端维护一套UserDTO,后端维护一套UserVO,中间再夹一层接口文档手工同步,某天后端把字段从userName改成nickname,前端编译不报错,直到线上页面显示空白才发现问题。这种割裂感在团队项目里几乎是每天的日常,我当时就想:有没有可能整个链路从数据库到最终渲染,类型是天然同源的?答案就是 T3 Stack。
t3code 就是用 T3 Stack 做的一个全栈实战项目,选型是 Next.js App Router + TypeScript + tRPC + Tailwind CSS + Prisma + NextAuth。这个组合最核心的价值一句话就能说明:从数据库 Schema 到前端组件 Props,整条数据链路全程类型安全,中间不需要手工维护任何一份重复的接口类型定义。
T3 不是某个框架,而是一套工程实践的选型标准。它由 Theo 提出,核心思想是“尽量少用魔法,尽量多依赖 TypeScript 的类型推导”。你写一个数据库模型,Prisma 帮你生成类型;你写一个后端查询逻辑,tRPC 帮你把输入输出类型反射到前端;你在组件里调用useQuery,返回的 data 类型自动完整,根本不需要像传统 REST 那样先打开接口文档确认字段。这就像给整个应用从地基到天花板拉了同一根承重柱,所有楼层自动对齐。
这个项目对谁最有参考价值?我认为是两类人。一类是想尝试 T3 生态但不知道从何下手的前端开发者,t3code 提供了一个完整的、可落地的范例;另一类是正在做全栈项目,被前后端类型割裂和联调成本困扰的团队,哪怕不是用 T3,这篇文章里关于类型安全的组织和思路也值得借鉴。下面我按从零到上线的顺序,把每个关键决策和踩坑过程都写清楚。
2. 项目设计与数据模型
2.1 核心需求与方案选型
t3code 的定位是一个代码片段管理工具:用户注册登录后,可以创建、编辑、删除自己的代码片段,每个片段支持标签、语言分类、描述说明,还能根据语言标签快速过滤。功能看起来简单,但技术链路几乎覆盖了 T3 生态的所有核心环节:认证、数据库、服务端逻辑、客户端状态管理、样式系统、部署优化,非常适合作为 T3 栈的完整演练场。
选型时我反复纠结过两个点。第一个是服务端逻辑放在哪里。传统 Next.js 项目通常靠 API Routes 处理 HTTP 请求,但 tRPC 的模型更彻底:它不关心 HTTP 动词和 URL 设计,只关心“客户端调用了一个带类型的过程”。这带来一个直接好处——前端调用后端的错误提示和代码补全,和调用本地函数没有差别。第二个是数据库层。我选了 Prisma + SQLite 起步,因为本地开发不需要单独安装数据库服务,改动 Schema 后一句prisma migrate dev就能同步,后续需要上线时换成 PostgreSQL 只需要改 datasource 配置。
目录结构沿用了 T3 标准的组织方式:
t3code/ ├── src/ │ ├── pages/ # 页面路由(Next.js Pages Router 也可兼容) │ ├── app/ # App Router 的入口与布局 │ ├── server/ │ │ ├── api/ # tRPC 根路由与子路由 │ │ ├── db/ # Prisma client 单例 │ │ ├── auth/ # NextAuth 配置 │ │ └── trpc.ts # tRPC context 与中间件 │ ├── trpc/ │ │ └── client.ts # 前端 tRPC hooks 封装 │ ├── styles/ # Tailwind 全局样式 │ └── utils/ # 工具函数 └── prisma/ └── schema.prisma之所以分成server和trpc两个目录,是为了把“服务端可执行的逻辑”和“客户端可调用的hooks”物理隔离开。新手最容易犯的错是直接在组件里写 Prisma 查询,这在 Next.js 的 App Router 里还能跑,但容易把服务端隐私逻辑泄露到客户端 bundle 中;用 tRPC 强制走一层 router,等于给所有服务端操作盖了一层保险。
2.2 数据模型设计
代码片段的核心实体是Snippet和Tag,关系是多对多:一个片段可以有多个标签,一个标签可以挂在多个片段上。
// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? snippets Snippet[] accounts Account[] sessions Session[] } model Snippet { id String @id @default(cuid()) title String description String? code String language String @default("text") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id]) authorId String tags Tag[] } model Tag { id String @id @default(cuid()) name String @unique snippets Snippet[] }注意这里language字段我特意设置了默认值"text",因为用户在创建片段的第一步可能还没想好语言分类,先用纯文本占位,后续再编辑。这个设计看起来不起眼,但后端 Zod 校验也要对应做兼容,否则用户提交空语言类型时会直接被拒。
2.3 为什么把类型安全放在第一位
传统开发的类型断裂发生在三个位置:数据库层、API 层、前端状态层。Prisma 解决了数据库到 ORM 的类型,OpenAPI 或手工类型定义解决了一部分 API 层,前端再用 Zod 或手工 interface 兜底。但这三层之间缺乏自动映射,一旦任意一层改了字段,其他层只能靠人肉搜索。T3 栈的解题思路是把“API 层”直接替换为 tRPC 的过程调用,过程输入输出用 Zod 即席定义,Prisma 生成的类型又自动流入 Zod,最后 tRPC 的类型反射把完整链路注入前端 hooks。这等于由编译器强制保证整个链路的类型一致,而不是靠约定。
3. 环境搭建与项目初始化
3.1 初始化 T3 项目
我直接用create-t3-app起步,这比手动一个个配置 Next.js、Tailwind、tRPC、Prisma 要省事很多。
pnpm create t3-app@latest t3code交互式选择时,我勾选了 Next.js、Tailwind CSS、tRPC、Prisma、NextAuth。TypeScript 和 ESLint 是默认持有的,不需要额外操作。用 pnpm 而非 npm 是因为 pnpm 对 monorepo 和依赖提升控制得更好,安装速度也更快,实测在 t3code 这种中型项目上pnpm install比 npm 快接近一倍。
初始化完成后,第一件事是检查.env文件里的DATABASE_URL。开发环境 SQLite 的写法是:
DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="一个足够长的随机字符串" NEXTAUTH_URL="http://localhost:3000"NEXTAUTH_SECRET可以用openssl rand -base64 32生成。如果少了这个变量,NextAuth 在生产环境会直接启动失败;开发环境可能不报错,但登录后 session 无法解密,表现就是“登录成功后立刻又变回未登录”,这个坑我在早期遇到过。
3.2 配置目录别名与 ESLint
create-t3-app默认在tsconfig.json里配置了@/别名指向src/:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }这个小配置值得养成习惯。T3 项目里 import 路径会大量出现@/server/api/routers/snippet,如果不用别名,相对路径会变成../../../../server/api/...,一旦目录调整就是一场灾难。你后续加新模块时,只要放在src下,import 路径永远从@/开始。
ESLint 方面 T3 栈默认用了eslint-config-next/core-web-vitals,不过我更建议加一条@typescript-eslint/no-floating-promises,它能把“调了 await 却忘了 catch”的风险在 lint 阶段暴露出来。tRPC 的调用返回 Promise,组件里异步操作一旦被 try/catch 包裹漏掉,错误就会被 React 上层吞掉,表现为页面卡死或静默不更新,排查起来非常痛苦。加了这个规则之后,未处理的 Promise 直接编译失败,能挡住一批运行时怪病。
3.3 数据库初始化和客户端单例
npx prisma migrate dev会在开发数据库上执行迁移,同时生成新的 Prisma Client。注意不要用prisma db push代替,因为它虽然能快速同步 Schema,但不生成迁移文件,团队协作时会丢失历史结构变更的轨迹。
Prisma Client 的创建要使用全局单例,避免每次引用都新建连接导致连接耗尽。create-t3-app已经帮你写好了这个模式:
import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"], }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;这里把globalThis作为缓存容器的思路值得细品:开发环境下 Next.js 的 hot reload 会反复重新加载模块,如果每次加载都new PrismaClient(),很快就会出现几十个连接堆积在数据库上;挂到全局对象后,只要进程还活着就复用同一个实例。
4. 核心功能实现:端到端类型安全的完整链路
4.1 tRPC 实例与 Context 设计
tRPC 的入口是创建一个 tRPC 实例,后续所有 router 都基于它扩展。t3code 的src/server/trpc.ts核心代码如下:
import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; import { ZodError } from "zod"; import { prisma } from "@/server/db"; import { getServerSession } from "next-auth"; import { authOptions } from "@/server/auth"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { prisma, session, ...opts, }; }; const t = initTRPC.context<typeof createTRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; 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 }, }, }); } );这三段代码解决的关键问题分别是:
createTRPCContext——每个请求进来时先执行,把 Prisma 实例和 NextAuth 的 session 注入到后续任意 procedure 的 context 中。后续写业务逻辑时不需要再各自调用getServerSession,省掉大量重复代码。superjson——一个专门解决 Date、Map、Set 等复杂类型序列化的库。createdAt是 Date 类型,如果没有 superjson,前端拿到的会是字符串,排序、比较时极易出错。加入这个 transformer 后,Date 在传输过程中自动保留类型。protectedProcedure——一个自定义中间件,检查 session。所有需要登录的接口都统一走它,未登录时抛UNAUTHORIZED,前端会自动收到对应的错误码。注意我没有简单地把错误格式化统一,而是把 ZodError 的扁平化错误透传给前端,这样表单字段级别的校验错误也能直接映射到对应输入框上。
4.2 编写 Snippet Router
Router 是 tRPC 的聚合层,一个业务模块一个文件。snippet 模块的增删改查代码如下:
import { z } from "zod"; import { router, protectedProcedure, publicProcedure } from "@/server/trpc"; export const snippetRouter = router({ list: publicProcedure .input( z.object({ language: z.string().optional(), tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.prisma.snippet.findMany({ where: { ...(input.language ? { language: input.language } : {}), ...(input.tag ? { tags: { some: { name: input.tag } } } : {}), ...(input.search ? { title: { contains: input.search } } : {}), }, include: { tags: true, author: { select: { name: true } } }, orderBy: { createdAt: "desc" }, }); }), detail: publicProcedure .input(z.object({ id: z.string().cuid() })) .query(async ({ ctx, input }) => { return ctx.prisma.snippet.findUnique({ where: { id: input.id }, include: { tags: true, author: { select: { name: true, image: true } } }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), code: z.string().min(1), language: z.string().min(1).default("text"), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const authorId = ctx.session.user.id; const tagConnect = input.tags.map((name) => ({ where: { name }, create: { name }, })); return ctx.prisma.snippet.create({ data: { title: input.title, description: input.description, code: input.code, language: input.language, authorId, tags: { connectOrCreate: tagConnect }, }, }); }), update: protectedProcedure .input( z.object({ id: z.string().cuid(), title: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(), code: z.string().min(1).optional(), language: z.string().min(1).optional(), tags: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { const snippet = await ctx.prisma.snippet.findUnique({ where: { id: input.id }, }); if (!snippet || snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } const data: Record<string, unknown> = { ...input }; delete data.id; if (input.tags !== undefined) { data.tags = { set: [], connectOrCreate: input.tags.map((name) => ({ where: { name }, create: { name }, })), }; } return ctx.prisma.snippet.update({ where: { id: input.id }, data, }); }), delete: protectedProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { const snippet = await ctx.prisma.snippet.findUnique({ where: { id: input.id }, }); if (!snippet || snippet.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } await ctx.prisma.snippet.delete({ where: { id: input.id } }); return { success: true }; }), });几个埋点值得展开说。
list接口是公开的,不做登录限制。这样首页可以直接展示最新的代码片段,游客也能浏览,引导注册之后才有修改权限。查询条件里language、tag、search都是可选参数,用展开运算符动态拼where,避免为每种组合单独写分支。
create接口里标签处理用了connectOrCreate:如果标签名已经存在就关联到已有记录,不存在就先创建再关联。这一步避免了两轮查询,是 Prisma 多对多写入的常规优化手段。虽然代码量看着多,但对比“先 findUnique 再 connect”的方式,数据库请求数从 N+1 降到 1。
update和delete都做了归属检查,防止用户 A 修改用户 B 的片段。注意这里是用FORBIDDEN而不是UNAUTHORIZED,这两个错误码含义不同:前者代表已认证但无权限,后者代表未认证。前端可以据此给出不同交互。
4.3 前端调用与类型推导
服务端的 router 写好只是第一步,真正让 tRPC 发挥威力的是前端调用。T3 栈里,前端 Router 类型通过import type导入,实现零成本类型反射:
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import type { AppRouter } from "@/server/api/root"; export const trpc = createTRPCReact<AppRouter>();这一步把后端 router 的类型空间完整暴露给客户端。接下来在组件里直接写业务逻辑:
function SnippetList({ language }: { language?: string }) { const { data, isLoading, error } = trpc.snippet.list.useQuery({ language, }); if (isLoading) return <div role="status">加载中...</div>; if (error) return <div>请求失败:{error.message}</div>; return ( <div className="grid gap-4"> {data?.map((snippet) => ( <div key={snippet.id} className="rounded-lg border p-4"> <h3 className="text-lg font-semibold">{snippet.title}</h3> <code className="block text-sm text-gray-600"> {snippet.language} </code> </div> ))} </div> ); }data的类型直接就是 Prisma 查询返回的完整类型,包含tags、author这些关联字段,snippet.createdAt也是 Date 类型。如果后端把字段改名,前端这里编译立刻报错。这不是运行时的弱校验,而是编译期的强约束,是我认为 T3 栈最值得称道的地方。
useMutation 的封装也很有用,但因为牵扯到前端状态更新,比 useQuery 复杂一档,我在第 6 节专门展开讲。
4.4 Next.js Server Component 与 tRPC Server Caller
App Router 服务端组件和 tRPC 的整合有一个比较巧的地方。你可以在服务端组件里直接调用createCaller创建一次请求上下文,同步执行 tRPC query,把结果作为初始状态传给页面。这样的好处是首屏渲染直接带数据,不需要客户端先 loading 再请求:
import { createTRPCContext } from "@/server/trpc"; import { appRouter } from "@/server/api/root"; import { SnippetList } from "@/components/SnippetList"; export default async function HomePage() { const ctx = await createTRPCContext({ headers: new Headers() }); const caller = appRouter.createCaller(ctx); const snippets = await caller.snippet.list({}); return <SnippetList initialData={snippets} />; }在客户端组件的useQuery中传入initialData:
const { data } = trpc.snippet.list.useQuery({}, { initialData: snippets });这里有两个易错的细节。第一,服务端组件里不能直接用useQuery,必须走caller。第二,initialData的类型必须严格匹配 query 的输出类型,如果服务端拿到的数据类型和 query 输出类型有偏差,前端会以 query 声明为准,运行时仍可能出现字段缺失。所以我在服务端调用时尽量直接复用同一个 input schema,保证输入输出都在 tRPC 类型约束下统一。
5. 认证、权限与鉴权边界
5.1 NextAuth 配置
T3 栈默认的认证方案是 NextAuth,支持 GitHub、Google 等多个 Provider。t3code 里配置了 GitHub 登录,目的是让更新代码片段时能同时绑定作者身份,避免匿名篡改数据。.env中需要填入 GitHub OAuth App 的 ID 和 Secret:
GITHUB_CLIENT_ID="xxx" GITHUB_CLIENT_SECRET="xxx"src/server/auth.ts的核心配置:
import { NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { prisma } from "@/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; } return session; }, }, };session 回调里把 user.id 注入 session,这样 tRPC 中间件里才能拿到ctx.session.user.id做归属判断。如果漏了这个 id 字段,前端任何“根据用户过滤”的查询都拿不到当前用户 ID,只能靠 email 匹配,非常别扭。
5.2 受保护路由与中间件
tRPC 的 protectedProcedure 已经处理了 API 层的权限。页面层的路由守卫则可以在 layout 里判断 session 是否存在,不存在就重定向到登录页:
import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "@/server/auth"; export default async function ProtectedLayout({ children, }: { children: React.ReactNode; }) { const session = await getServerSession(authOptions); if (!session) { redirect("/auth/signin"); } return <> {children} </>; }注意这里服务端的 session 判断和 tRPC 中间的鉴权是两个不同层级的安全网:页面层控制“能不能进这个页面”,API 层控制“能不能执行这个操作”。只拦前端页面不拦 API 是典型的安全漏洞,因为任何人都可以直接构造请求调用 API。
6. 样式系统与前端交互细节
6.1 Tailwind 配置与主题定制
Tailwind 在 T3 栈里几乎是默认配置,t3code 里的风格走的是简洁的代码编辑器路线。tailwind.config.ts里可以定制补充颜色和字体:
import type { Config } from "tailwindcss"; export default { darkMode: "class", theme: { extend: { colors: { "code-dark": "#0d1117", "code-accent": "#58a6ff", }, fontFamily: { mono: ["JetBrains Mono", "Fira Code", "ui-monospace", "monospace"], }, }, }, } satisfies Config;代码块本身我用了react-syntax-highlighter组件。这个库支持的语言种类很多,但打包体积不小,T3 栈推荐的做法是用<PrismLight>配合registerLanguage按需注册语言,而不是全量Prism。全量打包会多出几 MB 的 JS,对首屏性能影响明显。
6.2 深色模式与 Class 策略
Tailwind 的深色模式默认跟随系统,但在代码工具场景里,很多用户希望手动切换明暗主题并记住偏好。所以我改用darkMode: "class",配合一个简单的 hook:
function useTheme() { const [theme, setTheme] = useState<"light" | "dark">( () => (localStorage.getItem("theme") as "light" | "dark") ?? "light" ); useEffect(() => { document.documentElement.classList.toggle("dark", theme === "dark"); localStorage.setItem("theme", theme); }, [theme]); return { theme, toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")) }; }注意localStorage只能在客户端访问,所以初始化时用了 lazy initializer 并挂在useEffect里,避免 SSR 阶段访问浏览器 API 导致 hydration 报错。
6.3 表单交互与 Zod 错误映射
创建片段的表单是一个核心交互。tRPC 返回的 ZodError 经过 errorFormatter 处理后有zodError字段,字段级错误可以被直接映射到对应输入项。实现上是 submit 时调用 mutation,拿到错误后拆分展示:
const utils = trpc.useUtils(); const createMutation = trpc.snippet.create.useMutation({ onSuccess: async () => { await utils.snippet.list.invalidate(); resetForm(); }, onError: (error) => { const zodError = error.data?.zodError; if (zodError) { setFieldErrors(zodError.fieldErrors); } }, });invalidate()是 tRPC 的缓存失效方法,调用后所有snippet.list相关 query 会自动重新请求,页面列表实时更新。这里不需要手动setState修改列表,tRPC 的 query 缓存帮我们省了一堆样板代码。
7. 部署、性能优化与踩坑实录
7.1 生产环境构建
pnpm run build时 T3 项目最容易遇到的一个坑是:Next.js 在构建阶段会执行 App Router 里所有服务端组件的代码。如果某个组件调用了getServerSession(),而构建机器上没有配置 OAuth 环境变量,构建会因缺少 provider 直接失败。解决办法是构建前确认.env.production文件完整,或者在代码里对 session 查询做空值兜底。
构建成功后,t3code 采用 Node 服务方式部署而不是纯静态导出,原因是 tRPC 和 NextAuth 都需要服务端运行时能力。output: "standalone"可以减小部署体积,把依赖打到一个standalone文件夹,服务器上只需要 node 环境:
// next.config.mjs const nextConfig = { output: "standalone", };这个配置会把 server 代码和必要依赖打包到一个独立目录,配合 PM2 启动,部署起来非常干净。
7.2 常见错误速查表
这段时间从开发到上线,我整理了高频问题的排查路径:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 登录后 session 立刻失效 | NEXTAUTH_SECRET缺失或在部署环境变化 | 固定一个随机密钥,且所有机器保持一致 |
| useQuery 返回 undefined 但无报错 | Query key 冲突或首帧 cached 为空 | 检查 query input 是否稳定,避免在 render 中创建新对象 |
| tRPC 调用报 UNAUTHORIZED | 中间件没有正确读取 session | 确认authOptions是否配了 PrismaAdapter,检查 session callback 是否返回 user.id |
| 构建时 Prisma 查询失败 | 构建环境没有跑prisma generate | 在构建脚本中加prisma generate,确保客户端可用 |
| 开发环境数据库连接过多 | Prisma client 未做全局单例 | 用 globalThis 缓存 client 实例 |
| 代码高亮组件体积过大 | 全量引入了 Prism 语言包 | 改用PrismLight按需注册语言 |
| 服务器时区导致时间显示偏移 | Date 在传输中被当作字符串处理 | 确认 tRPC 配置了 superjson transformer |
7.3 性能优化与启动加速
tRPC 默认的 link 是httpBatchLink,它会把同一批次的多个请求自动合并成一个 HTTP 请求。比如列表页同时调用了snippet.list和tag.getAll,第一次加载时实际只发一次请求,显著减少网络往返。需要留意的是,batch 功能只在 HTTP 层生效,如果某个 query 对实时性要求极高,可以单独用httpLink覆盖。
服务端数据缓存方面,我用了 Next.js 的fetch缓存策略配合revalidate实现首页 snippet 列表的增量更新。App Router 的fetch默认不缓存,可以在服务端 caller 调用时设置:
await caller.snippet.list({}, { cache: "no-store" });这里的{}是给 tRPC 内部二次设置用的参数。不过实际开发中,t3code 的列表数据多数来自直接查询,我更倾向于用 tRPC 的 query 缓存 + invalidate 机制管理更新,而不是过度依赖 Next.js 的 fetch 缓存,两者叠加反而容易造成数据层混乱。
8. 实战之外:从 t3code 中沉淀的工程经验
t3code 这个项目本身不大,但它背后反映的选型思路可以迁移到很多中型全栈项目里。几点亲测体会供参考:
第一,类型安全的收益是渐进的,前期会感觉“多写了很多样板代码”,但一旦业务迭代到十几个 router、上百个字段时,前后端类型自动同步带来的节省非常可观。我在第六周接手一个新页面时,几乎没打开过接口文档,因为编辑器里的类型提示已经告诉我全部可用字段。
第二,tRPC 的 learning curve 其实比预期的要缓,只要理解“query 用于读,mutation 用于写,中间件处理横切逻辑”三个概念,后面写起来很像在写本地函数。
第三,不要盲目照搬任何一套架构。如果你的团队后端已经在维护一套成熟的 REST API,跨语言的调用方比较多,tRPC 的受益面窄于 OpenAPI 方案。T3 栈最适合的场景是前后端都由你掌控,或者未来明确不会对第三方开放 API 的团队内部项目。
最后再分享一个提升开发体验的小技巧:在开发环境给 tRPC 加上日志中间件,记录每个请求的耗时和返回体积,这样能直观看到哪个 query 拖慢了页面。排查 N+1 查询时尤其好用:
export const loggerMiddleware = t.middleware(async ({ path, type, next }) => { const start = Date.now(); const result = await next(); const duration = Date.now() - start; console.log(`[tRPC] ${type} ${path} took ${duration}ms`); return result; });把这个中间件挂到 router 外层:
export const appRouter = router({ snippet: snippetRouter, tag: tagRouter, }) .middleware(loggerMiddleware);这种“中间件 + 日志”模式不仅适用于 tRPC,任何具备 middleware 能力的框架都值得做一套,能帮你快速定位慢查询和异常路由。t3code 上线后,靠这个中间件我第一周就揪出了两个隐藏的 N+1 查询。