tRPC 实战:端到端类型安全、Zod 校验与 React Query 集成
tRPC 为什么值得用
传统前后端协作靠「手写 API 文档 + 各自维护类型」,类型一改就容易出现前后端不一致。tRPC 的核心卖点是:你的后端过程(procedure)就是类型来源,前端调用时自动获得完整类型推导,无需 codegen、无需 schema 文件。
| 方案 | 类型来源 | 是否需要 codegen | 跨语言 |
|---|---|---|---|
| REST + 手写类型 | 人工维护 | 否 | 是 |
| GraphQL + codegen | schema 文件 | 是 | 是 |
| tRPC | 后端过程直接推导 | 否 | 否(限定 TS/TS) |
定义 Router 与 Procedure
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
export const userRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
create: publicProcedure
.input(z.object({ name: z.string().min(1), email: z.string().email() }))
.mutation(({ input }) => {
return db.user.create({ data: input });
}),
});
query 对应读、mutation 对应写。输入用 Zod 声明,tRPC 会在运行时自动校验。
复杂入参结构(嵌套对象)可先用 JSON 格式化 工具梳理,再转成 Zod schema,避免字段遗漏。
与 React Query 集成
这是最经典的组合:tRPC 负责类型,React Query 负责缓存/重试/失效。
// client/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/routers";
export const trpc = createTRPCReact<AppRouter>();
function UserList() {
const users = trpc.user.list.useQuery();
const utils = trpc.useUtils();
const create = trpc.user.create.useMutation({
onSuccess: () => utils.user.list.invalidate(),
});
if (users.isLoading) return <p>加载中…</p>;
return (
<ul>
{users.data?.map((u) => (
<li key={u.id}>{u.name}</li>
))}
<button onClick={() => create.mutate({ name: "Ada", email: "a@x.com" })}>
新增
</button>
</ul>
);
}
注意 users.data 的类型、以及 create.mutate 的参数类型,全部由后端过程推导,改了后端入参前端立刻报错。
中间件与鉴权
import { initTRPC, TRPCError } from "@trpc/server";
const t = initTRPC.context<{ jwt?: string }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.jwt) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, userId: verify(ctx.jwt) } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
中间件可以改写 ctx,下游 procedure 就能拿到 userId。
校验 JWT 时可用 JWT 解码 工具查看 payload 中的
sub与过期时间,快速核对鉴权链路。
错误处理
tRPC 用 TRPCError 统一错误,前端通过 error.data.code 区分:
protectedProcedure.query(({ ctx }) => {
const u = db.user.findUnique({ where: { id: ctx.userId } });
if (!u) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
return u;
});
前端:
const { error } = trpc.user.me.useQuery();
if (error?.data?.code === "NOT_FOUND") return <p>未找到</p>;
错误码与 HTTP 状态码一一对应,排查时可用 HTTP 状态码 工具对照 UNAUTHORIZED=401、NOT_FOUND=404 等。
Subscriptions(实时)
tRPC 支持基于 WebSocket 的订阅:
export const appRouter = router({
onMessage: publicProcedure.subscription(({ signal }) => {
return observable<Message>((emit) => {
const timer = setInterval(() => emit.next({ text: "ping" }), 1000);
return () => clearInterval(timer);
});
}),
});
从 REST 平滑迁移
- 梳理边界:把「读」映射为
query、「写/副作用」映射为mutation。 - 补 Zod:给每个接口加输入 schema,等于顺手补了运行时校验。
- 替换客户端:用
trpc.xxx.useQuery/useMutation替代fetch+ 手写类型。 - 鉴权下沉:把分散的 JWT 检查收敛到中间件
protectedProcedure。 - 保留 REST:tRPC 也可通过
fetch adapter暴露 HTTP 端点,渐进迁移。
常见问题 FAQ
Q1:tRPC 能跨语言吗?
不能。它依赖 TypeScript 类型推导,前后端都必须是 TS。若需多语言,应回到 REST/GraphQL。
Q2:和 GraphQL 比有什么优势?
免去 schema 文件与 codegen,类型即源码。代价是失去跨语言与公共 API 能力。
Q3:Zod 校验失败返回什么?
自动转为 BAD_REQUEST 错误,前端 error.data.code === "BAD_REQUEST",并携带 Zod 问题列表。
Q4:能不用 React Query 吗?
可以,tRPC 核心与客户端无关,也有原生 client 调用方式,但 React Query 集成体验最佳。
Q5:大列表分页怎么写?
在 input 里加 cursor/limit,用 query 返回 { items, nextCursor },前端配合 useInfiniteQuery。
工具推荐
在 tRPC 全栈开发中,以下 工具库 能帮上忙:
- JSON 格式化 — 梳理入参/出参结构再写 Zod
- JWT 解码 — 核对鉴权 Token 的 sub 与过期时间
- HTTP 状态码 — 对照 tRPC 错误码与 HTTP 语义
- Base64 编码 — 处理凭据与二进制载荷
tRPC 不是「又一个 RPC 框架」,而是一套「类型即契约」的开发范式:后端过程直接成为前端的类型来源,省掉文档、codegen 与两边对字段的心智负担。当你改了后端入参,编辑器立刻在前端点红——这种端到端的类型安全,正是现代全栈 TS 团队的甜蜜点。
本站提供浏览器本地工具,免注册即可试用 →