
一句话:tRPC 的卖点是「端到端类型安全,且零额外定义」——你只写一次服务端函数(含 zod 输入校验),客户端直接 import 那个类型就有了完整的自动补全与类型检查,既不需要 schema 文件,也不需要 codegen;代价是「只在 TypeScript 全家桶同仓时成立」。
一、它为什么能「零 codegen」
⭐⭐⭐ 核心机制:TypeScript 的「类型是编译期概念」
【GraphQL 的路径】
schema.graphql(SDL)→ codegen → client types + hooks
⚠️ 需要「维护 schema + 跑 codegen」(虽然能自动化,但仍是「两份东西」)
【tRPC 的路径】
服务端:const appRouter = t.router({ order: { list: procedure.input(z.object({...})).query(async () => ...) } })
客户端:import type { AppRouter } from '../server/router' // ⭐ 只 import 类型!
const trpc = createTRPCReact<AppRouter>();
trpc.order.list.useQuery({ first: 20 }) // ⭐ 自动补全 + 类型检查
⭐ 关键:`import type` 在**编译后完全消失**(不会把服务端代码打进客户端 bundle),
但 TypeScript 的「类型推导」照样工作。
⭐ 于是「**服务端函数签名的变化会立刻反映到客户端的编译错误上**」——
这是「真正意义上的端到端类型安全」。
⚠️ 但它有「隐式耦合」的代价:
· 客户端「知道」服务端路由的结构(⭐ 改名会「客户端编译失败」)
· 这对「同一个仓库、同一个团队」是**优点**(重构安全)
· 对「面向外部/多语言客户端」是**缺点**(别人没法用)
⭐⭐⭐ 与 REST / GraphQL 的定位对比:
| 维度 | REST | GraphQL | tRPC |
| --- | --- | --- | --- |
| 契约来源 | OpenAPI(可选) | SDL(强制) | ⭐ **TS 类型(零额外定义)** |
| 类型安全 | ⚠️ 要靠 codegen | ⚠️ 要靠 codegen | ⭐ **编译期直接推导** |
| 客户端语言 | ⭐ 任意 | ⭐ 任意 | ⚠️ **必须 TS** |
| 取数粒度 | 服务端定 | ⭐ 客户端声明字段 | ⚠️ 服务端定(每次调用一个「过程」) |
| 版本演进 | 手写 | ⭐ 加字段不影响老客户端 | ⚠️ **强耦合**(同仓同版本) |
| HTTP 缓存 | ⭐ 原生 | ⚠️ 弱(POST) | ⚠️ 弱(默认 POST/batch) |
| 适合 | ⭐ 公开 API | ⭐ 多端 / 复杂取数 | ⭐ **全栈 TS 同仓的内部应用** |
⭐ 一句话选型:
· 「面向外部开发者 / 多语言客户端」→ **REST(OpenAPI)**
· 「多端(Web/iOS/Android)+ 复杂取数」→ **GraphQL**
· ⭐ 「**前后端同一个仓库、都是 TS**」→ **tRPC**(开发体验最好)
二、服务端:router + procedure
// server/trpc.ts —— 初始化与「可复用的过程」
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson'; // ⭐ 处理 Date/Map/Set(见下文)
import { z } from 'zod';
/** ============ 1. Context:每个请求都可用的东西 ============ */
export interface Context {
/** 从请求头/ cookie 解析出的用户 */
user: { id: string; permissions: string[] } | null;
req: Request;
traceId: string; // ⭐ 用于日志串联
}
/** ============ 2. 初始化(⭐ transformer 是「处理 Date/Map 等类型」的关键) ============ */
const t = initTRPC.context<Context>().create({
// ⭐⭐ 让「服务端返回 Date」「客户端收到 Date」都正常工作
// (否则 JSON 会把 Date 变成字符串,与 3.15 的坑同源)
transformer: superjson,
errorFormatter({ shape, error }) {
// ⭐ 统一错误形态(前端只依赖这一套)
return {
...shape,
data: {
...shape.data,
// ⭐ 把「字段级校验错误」也带上(便于与表单库集成,见 1.13)
fieldErrors:
error.cause instanceof z.ZodError
? error.cause.flatten().fieldErrors
: undefined,
},
};
},
});
/** ============ 3. 中间件:鉴权 + 构造「受保护的过程」 ============ */
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
// ⭐ 抛 TRPCError 会被自动映射成合适的 HTTP 状态码(UNAUTHORIZED → 401)
throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' });
}
// ⭐⭐ 关键:把 ctx 收窄(类型上「user 一定存在」)
return next({ ctx: { ...ctx, user: ctx.user } });
});
/** ⭐ 权限中间件工厂(复用同一套权限点规范,见 6.12) */
const requirePermission = (permission: string) =>
t.middleware(({ ctx, next }) => {
if (!ctx.user?.permissions.includes(permission)) {
// ⭐ 服务端「永远是安全的最终防线」(前端隐藏按钮只是体验)
throw new TRPCError({ code: 'FORBIDDEN', message: `缺少权限:${permission}` });
}
return next();
});
/** ⭐ 日志/耗时中间件(⭐ 与「请求层可观测性」呼应,见 7.13) */
const logging = t.middleware(async ({ path, type, next, ctx }) => {
const start = performance.now();
const result = await next();
const elapsed = performance.now() - start;
if (elapsed > 500 || !result.ok) {
console.log(JSON.stringify({
traceId: ctx.traceId, path, type,
elapsedMs: Math.round(elapsed),
ok: result.ok,
}));
}
return result;
});
/** ⭐⭐ 导出的「过程构造器」(业务代码只用这些) */
export const publicProcedure = t.procedure.use(logging);
export const protectedProcedure = publicProcedure.use(isAuthed);
export { t, requirePermission };
// server/routers/order.ts —— 业务路由(⭐ 只写一次,类型自动流向客户端)
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { t, protectedProcedure, requirePermission } from '../trpc';
import { db } from '../db';
/** ⭐ 输入 schema(运行时会校验 + 编译期推导类型) */
const ListInput = z.object({
// ⭐ 每个字段都「显式约束」(tRPC 没有「超取」的概念,所以要「正好定义需要的」)
first: z.number().int().min(1).max(100).default(20),
cursor: z.string().optional(), // ⭐ cursor 分页
status: z.enum(['pending', 'paid', 'refunded']).optional(),
});
export const orderRouter = t.router({
list: protectedProcedure
.input(ListInput)
.query(async ({ input, ctx }) => {
const rows = await db.order.findMany({
where: { userId: ctx.user.id, status: input.status },
take: input.first + 1, // ⭐ 多取一条判断 hasNextPage
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = rows.length > input.first;
const items = hasNextPage ? rows.slice(0, -1) : rows;
return {
// ⭐ 返回值的**类型会被客户端推导**(含 Date——靠 superjson)
items,
nextCursor: hasNextPage ? items[items.length - 1]?.id ?? null : null,
};
}),
byId: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const order = await db.order.findUnique({ where: { id: input.id } });
// ⭐⭐ 「资源不存在」与「无权限」要分开(见 6.12 的 404/403 之分)
if (!order) throw new TRPCError({ code: 'NOT_FOUND', message: '订单不存在' });
if (order.userId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
return order;
}),
refund: protectedProcedure
.use(requirePermission('order:refund')) // ⭐ 细粒度权限
.input(z.object({
id: z.string().uuid(),
amount: z.number().positive(), // ⭐ 输入校验在服务端「一定生效」
reason: z.string().min(1).max(200),
}))
.mutation(async ({ input, ctx }) => {
const order = await db.order.findUnique({ where: { id: input.id } });
if (!order) throw new TRPCError({ code: 'NOT_FOUND' });
if (input.amount > order.amount) {
// ⭐ 业务错误用「合适的 code」(BAD_REQUEST 会变成 400)
throw new TRPCError({ code: 'BAD_REQUEST', message: '退款金额不能超过订单金额' });
}
const receipt = await db.refund.create({
data: { orderId: order.id, amount: input.amount, reason: input.reason, operatorId: ctx.user.id },
});
return { receiptId: receipt.id, refundedAt: receipt.createdAt }; // ⭐ Date 会被 superjson 保留
}),
});
// server/routers/_app.ts —— 根路由 + 类型导出
import { t } from '../trpc';
import { orderRouter } from './order';
import { userRouter } from './user';
export const appRouter = t.router({
order: orderRouter,
user: userRouter,
});
/** ⭐⭐⭐ 只导出「类型」——这是「客户端能拿到类型」的全部机制 */
export type AppRouter = typeof appRouter;
三、客户端:类型自动流动
// client/trpc.ts —— 客户端初始化(⭐ 注意「只 import type」)
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '../server/routers/_app'; // ⭐⭐ 只有类型!
export const trpc = createTRPCReact<AppRouter>();
export function createTRPCClientOptions() {
return {
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson, // ⭐ 必须与服务端一致(否则 Date 会变形)
// ⭐ 批量:把「同一帧内的多个查询」合成一个 HTTP 请求
maxURLLength: 2048,
headers() {
return { authorization: `Bearer ${getToken() ?? ''}` };
},
}),
],
};
}
// client/App.tsx —— Provider 接线
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc, createTRPCClientOptions } from './trpc';
export function App() {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // ⭐ 30 秒内不重新请求
retry: (failureCount, error) => {
// ⭐⭐ 关键:只重试「可重试」的错误(4xx 不该重试)
const code = (error as { data?: { code?: string } })?.data?.code;
if (code === 'UNAUTHORIZED' || code === 'FORBIDDEN' || code === 'NOT_FOUND' || code === 'BAD_REQUEST') {
return false;
}
return failureCount < 2;
},
},
},
}));
const [trpcClient] = useState(() => trpc.createClient(createTRPCClientOptions()));
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<OrderPage />
</QueryClientProvider>
</trpc.Provider>
);
}
// client/OrderPage.tsx —— 业务组件(⭐ 类型全部自动推导)
import { trpc } from './trpc';
function OrderList() {
// ⭐⭐ 输入有类型、返回值有类型、错误有类型 —— 全部来自服务端
const query = trpc.order.list.useQuery({ first: 20, status: 'paid' });
// ^? 自动补全:first / cursor / status(且 first 有 min(1).max(100) 的「文档」提示)
const items = query.data?.items ?? [];
// ^? 元素类型是 Order(含 Date 字段)
if (query.isPending) return <ListSkeleton />;
if (query.error) {
// ⭐ 错误对象带「code / httpStatus / message / fieldErrors」
if (query.error.data?.code === 'UNAUTHORIZED') return <Navigate to="/login" />;
return <ErrorState message={query.error.message} />;
}
return (
<ul>
{items.map((o) => (
<li key={o.id}>
{o.id} · ¥{o.amount}
{/* ⭐ Date 字段是真正的 Date(不是字符串)——superjson 的功劳 */}
· {o.createdAt.toLocaleDateString('zh-CN')}
<RefundButton order={o} />
</li>
))}
</ul>
);
}
// client/RefundButton.tsx —— 突变 + 乐观更新 + 失效(⭐ 与 React Query 完全一致的心智)
function RefundButton({ order }: { order: { id: string; amount: number; status: string } }) {
const utils = trpc.useUtils(); // ⭐ tRPC v11 的 utils(v10 是 useContext)
const refund = trpc.order.refund.useMutation({
// ⭐⭐ 乐观更新(与 7.4 的 React Query 用法一致)
onMutate: async (vars) => {
// ① 取消进行中的查询(避免它「覆盖」我们的乐观更新)
await utils.order.list.cancel();
// ② 快照旧数据(用于失败回滚)
const prev = utils.order.list.getData({ first: 20, status: 'paid' });
// ③ 立刻改本地缓存
utils.order.list.setData({ first: 20, status: 'paid' }, (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((o) =>
o.id === vars.id ? { ...o, status: 'refunding' } : o
),
};
});
return { prev };
},
// ⭐ 失败 → 回滚
onError: (err, _vars, ctx) => {
if (ctx?.prev) utils.order.list.setData({ first: 20, status: 'paid' }, ctx.prev);
// ⭐ 用「服务端给的 message」提示(业务错误信息来自服务端,不用前端猜)
toast.error(err.message);
},
// ⭐ 成功 → 失效相关查询(让服务端数据接管)
onSettled: () => {
void utils.order.list.invalidate();
void utils.order.stats.invalidate(); // ⭐ 失效「统计」(乐观更新覆盖不到的地方)
},
});
return (
<button
disabled={refund.isPending}
onClick={() => refund.mutate({ id: order.id, amount: order.amount, reason: '用户申请' })}
>
{refund.isPending ? '退款中…' : '退款'}
</button>
);
}
⭐⭐⭐ 从上面的代码能看出 tRPC 的四个「体验优势」:
① 【输入是「有约束的类型」而非「任意对象」】
`input(ListInput)` 里 `first` 有 `min(1).max(100)` →
⭐ 客户端补全时**能看到约束**(IDE 会提示),而且「传错值」在**编译期**就报错。
(对比 REST:`POST /orders { first: 999 }` → 运行时 400。)
② 【返回值「带真实类型」】
`items[0].createdAt` 是 **`Date`**(不是字符串)——靠 **superjson transformer**。
⭐ 这与 3.15 讲的「JSON 序列化陷阱」是同一个问题的解法(把类型信息带上)。
③ 【错误是「结构化的」】
`error.data.code` 是**字面量联合类型**(`'UNAUTHORIZED' | 'FORBIDDEN' | ...`)→
⭐ 可以 `switch (error.data.code)` 并让 TS 检查「是否漏了分支」。
(对比 REST:只能靠 HTTP 状态码 + 自己解析 body。)
④ 【与 React Query 完全同构】
`useQuery` / `useMutation` / `useUtils().xxx.invalidate()` / `setData` / `getData` / `cancel`
⭐ 所以「缓存、失效、乐观更新、分页」的知识**完全通用**(见 7.4/7.7)——
tRPC 只是「帮你生成了类型安全的 key 与 hooks」。
四、完整实现:一个端到端类型安全的 CRUD(230 行)
// ============ 服务端:一个完整的「笔记」CRUD ============
// server/routers/note.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { t, protectedProcedure } from '../trpc';
import { db } from '../db';
import type { Prisma } from '@prisma/client';
/** ⭐ 一处定义,两端共用(客户端也能 import 这个 schema 做「前端预校验」) */
export const NoteInput = z.object({
title: z.string().min(1, '标题不能为空').max(120, '标题最多 120 字'),
content: z.string().max(10_000),
tags: z.array(z.string().min(1)).max(10).default([]),
/** ⭐ 乐观锁:客户端带「读到的版本号」,服务端校验(防「覆盖别人的修改」) */
version: z.number().int().nonnegative().optional(),
});
const ListNotesInput = z.object({
q: z.string().default(''),
tag: z.string().optional(),
first: z.number().int().min(1).max(50).default(20),
cursor: z.string().nullable().optional(),
});
export const noteRouter = t.router({
list: protectedProcedure.input(ListNotesInput).query(async ({ input, ctx }) => {
const where: Prisma.NoteWhereInput = {
userId: ctx.user.id,
...(input.q ? { title: { contains: input.q, mode: 'insensitive' } } : {}),
...(input.tag ? { tags: { has: input.tag } } : {}),
};
const rows = await db.note.findMany({
where,
take: input.first + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], // ⭐ 分页要「稳定排序」(加 id 做 tie-breaker)
});
const hasNextPage = rows.length > input.first;
const items = hasNextPage ? rows.slice(0, -1) : rows;
return {
items,
nextCursor: hasNextPage ? (items.at(-1)?.id ?? null) : null,
total: await db.note.count({ where }),
};
}),
create: protectedProcedure.input(NoteInput.omit({ version: true })).mutation(async ({ input, ctx }) => {
const note = await db.note.create({
data: { ...input, userId: ctx.user.id, version: 1 },
});
return note; // ⭐ 返回值类型自动到客户端
}),
update: protectedProcedure
.input(NoteInput.extend({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const { id, version, ...patch } = input;
const existing = await db.note.findUnique({ where: { id } });
if (!existing) throw new TRPCError({ code: 'NOT_FOUND' });
if (existing.userId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
// ⭐⭐ 乐观锁:版本不匹配 → 「别人先改了」
if (typeof version === 'number' && version !== existing.version) {
throw new TRPCError({
code: 'CONFLICT',
message: '这条笔记已被别处修改,请刷新后重试',
});
}
return db.note.update({
where: { id },
data: { ...patch, version: existing.version + 1 },
});
}),
remove: protectedProcedure.input(z.object({ id: z.string().uuid() })).mutation(async ({ input, ctx }) => {
const existing = await db.note.findUnique({ where: { id: input.id } });
if (!existing) return { ok: true as const }; // ⭐ 「删除不存在的」→ 幂等成功(见下文)
if (existing.userId !== ctx.user.id) throw new TRPCError({ code: 'FORBIDDEN' });
await db.note.delete({ where: { id: input.id } });
return { ok: true as const };
}),
});
// ============ 客户端:一个「搜索 + 分页 + 增删改」的完整页面 ============
import { useState } from 'react';
import { trpc } from './trpc';
export function NotesPage() {
const [q, setQ] = useState('');
const [tag, setTag] = useState<string | undefined>();
const utils = trpc.useUtils();
// ⭐⭐ 关键:`useInfiniteQuery` 的「getNextPageParam」从「返回值」里取 cursor
// (⭐ 因为返回类型是推导出来的,所以 `lastPage.nextCursor` 有完整类型)
const list = trpc.note.list.useInfiniteQuery(
{ q, tag, first: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor, // ⭐ 类型安全
// ⚠️ 必须给「初始页参数」(否则类型上是 undefined)
initialCursor: null,
}
);
const create = trpc.note.create.useMutation({
// ⭐ 乐观更新:立刻把新笔记插到列表前面
onMutate: async (vars) => {
const input = { q, tag, first: 20 };
await utils.note.list.cancel(input);
const prev = utils.note.list.getInfiniteData(input);
utils.note.list.setInfiniteData(input, (data) => {
if (!data) return data;
const optimisticNote = {
// ⚠️ 乐观项的「临时 id」(⭐ 必须与服务端 id 的「形态」不同,便于识别)
id: `temp-${crypto.randomUUID()}`,
title: vars.title,
content: vars.content,
tags: vars.tags,
version: 0,
createdAt: new Date(),
updatedAt: new Date(),
userId: 'me',
// ⭐ 用一个「标记」表示「还在同步中」(UI 据此显示「待同步」)
__optimistic: true,
};
const [firstPage, ...rest] = data.pages;
if (!firstPage) return data;
return {
...data,
pages: [{ ...firstPage, items: [optimisticNote as never, ...firstPage.items] }, ...rest],
};
});
return { prev };
},
onError: (err, _vars, ctx) => {
if (ctx?.prev) utils.note.list.setInfiniteData({ q, tag, first: 20 }, ctx.prev);
toast.error(err.message);
},
onSettled: () => { void utils.note.list.invalidate(); }, // ⭐ 让服务端数据接管(替换掉临时项)
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
const total = list.data?.pages[0]?.total ?? 0;
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索标题" aria-label="搜索标题" />
<form onSubmit={(e) => { e.preventDefault(); create.mutate({ title: '新笔记', content: '', tags: [] }); }}>
<button type="submit" disabled={create.isPending}>新建笔记</button>
</form>
共 {total} 条
<ul>
{items.map((note) => (
<li key={note.id} style={{ opacity: (note as { __optimistic?: boolean }).__optimistic ? 0.6 : 1 }}>
{note.title}
{/* ⭐ 乐观项显示「同步中」 */}
{(note as { __optimistic?: boolean }).__optimistic && <span>(同步中…)</span>}
</li>
))}
</ul>
{list.hasNextPage && (
<button onClick={() => void list.fetchNextPage()} disabled={list.isFetchingNextPage}>
{list.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
)}
</div>
);
}
// ============ 类型安全的「复用」:客户端也能用同一份 schema 做预校验 ============
import { NoteInput } from '../server/routers/note';
function useNoteForm() {
return useForm({
// ⭐ 与服务端**同一份**校验规则(不需要「写两遍」)
resolver: zodResolver(NoteInput.omit({ version: true })),
defaultValues: { title: '', content: '', tags: [] },
});
}
// ⭐ 注意:这里让「客户端 bundle 里包含一份 zod 校验」——
// 代价是「几 KB 的 zod + 规则」(⭐ 通常值得,因为它换来「即时反馈」)
// ⚠️ 但要注意「不要 import 服务端 router 文件本身」(那会把服务端依赖也打进 bundle),
// 只 import「schema 所在的独立文件」——这也是「为什么把 NoteInput 单独导出」的原因。
⭐⭐⭐ 这个实现里的四个「工程要点」:
① 【把 schema 抽成「可被两端 import 的独立文件」】⭐
`NoteInput` 定义在 `server/routers/note.ts` 里并 export →
⚠️ 客户端如果 `import { NoteInput } from '../server/routers/note'`,
可能会**连带把服务端的依赖(db、prisma)也拉进 bundle**
✅ 更好的做法:把 schema 抽到 `shared/schemas/note.ts`(⭐ 与应用代码解耦)
② 【乐观更新里的「临时 id」】
⭐ 必须「与服务端 id 的形态不同」(如 `temp-uuid`),
这样「失效后服务端数据接管」时,「临时项」会被「真实的」替换。
⚠️ 如果临时 id 与真实 id 撞了 → 列表里会出现「两个一样的项」。
③ 【乐观锁(`version`)】
⭐ 这是「多标签页/多设备编辑」的必要机制(呼应 3.14 的冲突解决):
客户端提交「我读到的版本号」→ 服务端校验 → 不匹配就 `CONFLICT` →
✅ 前端提示「已被修改,请刷新」(而不是「静默覆盖别人的修改」)。
④ 【「删除不存在的资源」→ 幂等成功】
`remove` 里「找不到就返回 ok」——
⭐ 这让「重复点击/重试」安全(呼应 10.11 的幂等键思想)。
⚠️ 但要注意「这不等于『忽略权限错误』」(FORBIDDEN 仍要抛)。
五、本篇特有的坑
// ① 客户端 import 了「服务端 router 文件的非类型导出」(把 db 打进 bundle)⭐
import { NoteInput } from '../server/routers/note'; // ⚠️ 可能连带服务端依赖
import type { AppRouter } from '../server/routers/_app'; // ✅ 只有 type 才安全
// ✅ schema 抽到 shared/,客户端只 import 那个文件
// ② transformer 两端不一致(Date 变字符串 / Map 变空对象)
// server: 有 superjson;client: 没配 // ❌
// ✅ 两端都配同一个 transformer
// ③ 把「服务端 router 的类型」用在「客户端 bundle 的运行时逻辑」里
// ⚠️ 类型在编译后消失 → 运行时报「找不到 xxx」
// ✅ 类型只用于「标注」,运行时的逻辑不要依赖它
// ④ 用 tRPC 做「面向外部/多语言客户端」的 API
// ⚠️ 强耦合 TS(没有 schema 文档、没有 codegen 给别的语言)
// ✅ 对外用 REST/OpenAPI 或 GraphQL
// ⑤ 「输入没做校验」(只依赖 TS 类型)
// ⚠️ TS 类型「运行时不存在」→ 恶意请求可以传任意值
// ✅ 每个 input 都用 zod(⭐ 运行时的校验不可省)
// ⑥ 权限只在前端判断(服务端没做)
// ✅ 服务端用中间件(见 requirePermission)—— 前端只是体验(见 6.12)
// ⑦ 错误「只抛字符串」(丢失结构)
throw new Error('用户不存在'); // ⚠️ 客户端只拿到 message
// ✅ throw new TRPCError({ code: 'NOT_FOUND', message: '...' })
// ⑧ 「4xx 也重试」(用户等 3 次才看到「参数错误」)
// ✅ 在 React Query 的 retry 里「按 error.data.code 分流」
// ⑨ 批量请求(batch)在「SSR/边缘环境」出问题
// ⚠️ httpBatchLink 会「把多个查询合成一个请求」,在 SSR 里可能「拿到别的请求的数据」
// ✅ 用 `splitLink` 让「服务端渲染的查询不走 batch」,或每个请求一个 client 实例
// ⑩ 「超大 batch」导致 URL 过长(GET 时)或「一个失败全失败」
// ✅ 配 `maxURLLength`;⭐ 或对「关键查询」用「非 batch 的 link」
// ⑪ 把「React Query 的 queryKey」手写在别处(与 tRPC 生成的 key 不一致)
// ✅ 统一用 `utils.note.list.invalidate()`(它内部用「与 hooks 相同的 key」)
// ⑫ 乐观更新「没取消进行中的查询」(被覆盖)
// ✅ `await utils.xxx.cancel(input)`
// ⑬ 乐观更新「没有临时 id」或「临时 id 与服务端撞了」
// ✅ 用「temp-前缀」
// ⑭ 「并发修改」静默覆盖(没做乐观锁)
// ✅ 用 `version` 字段 + `CONFLICT` 错误(见上文)
// ⑮ 「订阅(subscription)」当成「开箱可用」
// ⚠️ tRPC 的 subscription 需要「支持长连接的 transport」(WebSocket/sse link),
// 且「服务端要维护连接状态」
// ✅ 简单场景用「轮询」;需要实时再上 WS(见 7.14)
// ⑯ 「无限查询」忘了 `initialCursor`
// ⚠️ 类型上报错或「首次不请求」
// ✅ 传 `initialCursor: null`
// ⑰ 「中间件里改了 ctx 但没返回 next({ ctx })」(下游拿不到)
return next(); // ⚠️ 收窄的 ctx 丢了
// ✅ return next({ ctx: { ...ctx, user } })
// ⑱ 「logging 中间件」在「慢请求」时才日志(但错误请求也要)
// ✅ `if (elapsed > 500 || !result.ok)`
// ⑲ 「tRPC 的类型」在「monorepo 里跨包」时很慢(TS 重建整个图)
// ⚠️ 大项目里「AppRouter 类型」可能触发「全量类型检查」→ tsserver 卡
// ✅ 用 project references(见 10.9);⭐ 或把「router 类型」拆成「薄的公共入口」
// ⑳ 「部署形态」的耦合(客户端与服务端必须同时上线)
// ⭐ 这是「强耦合」的真实代价:不能「先发后端再发前端」(旧客户端会「调不存在的路由」)
// ✅ 做法:① 灰度时「保留旧路由一个版本」;⭐ ② 或者「服务端对未知路由返回明确的版本错误」
// (让前端能提示「请刷新页面」)
// ⑨ 的完整处理(SSR 场景下不用 batch)
import { splitLink } from '@trpc/client';
const client = trpc.createClient({
links: [
splitLink({
// ⭐ 服务端渲染时(window 不存在)→ 不走 batch(避免「请求间串数据」)
condition: () => typeof window === 'undefined',
true: httpLink({ url: '/api/trpc', transformer: superjson }),
false: httpBatchLink({ url: '/api/trpc', transformer: superjson, maxURLLength: 2048 }),
}),
],
});
// ⑳ 的完整处理(前后端版本不一致时的友好降级)
// 服务端:对「不存在的路由」返回明确错误(而不是 404 HTML)
// 在 Next.js 的 API route 里:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
export async function handler(req: Request) {
const res = await fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
// ⭐ 让「未找到的路由」返回结构化错误
onError({ error }) {
if (error.code === 'NOT_FOUND') {
console.warn('[trpc] 客户端请求了不存在的路由(可能是「前端已更新、后端未更新」或反之)');
}
},
});
return res;
}
// 客户端:识别「路由不存在」→ 提示用户刷新(而不是「一个奇怪的错误」)
function useTrpcErrorToast() {
const utils = trpc.useUtils();
// ⭐ 全局错误处理(tRPC v11 支持在 link 层挂 onError)
// 或在 React Query 的 QueryCache 上挂 onError
}
六、面试延伸
- 「tRPC 是什么?它凭什么做到『零 codegen』?」
tRPC 是一个「让前后端共享 TypeScript 类型」的 RPC 框架。核心机制是「TypeScript 的类型是编译期概念」:服务端定义const appRouter = t.router({ order: { list: procedure.input(zodSchema).query(fn) } }),然后export type AppRouter = typeof appRouter,客户端import type { AppRouter } from '../server/router'(⭐ 只 import 类型,编译后完全消失,不会把服务端代码打进 bundle),再用createTRPCReact<AppRouter>()—— ⭐ 于是「输入类型(含 zod 约束)、返回值类型(含Date等通过 transformer 保留的类型)、错误结构的字面量联合」全部自动流到客户端,不需要 schema 文件、不需要 codegen。而且「服务端函数签名的变化会立刻变成客户端的编译错误」——这是真正的端到端类型安全。 -
「tRPC、GraphQL、REST 怎么选?」
三个判据:① ⭐ 「客户端是谁」——只有你自己的 TS 前端 → tRPC(开发体验最好);多端(Web/iOS/Android)或其它语言 → GraphQL(schema 是跨语言的契约);面向外部开发者/第三方 → REST + OpenAPI(最通用、缓存最容易)。② ⭐ 「取数灵活性」——需要「客户端声明字段、一次拿多个资源」→ GraphQL;「按功能调用固定接口」→ REST/tRPC 都行。③ ⭐ 「演进与部署」——tRPC 强耦合(同仓同版本,⚠️ 不能「先发后端再发前端」);GraphQL「加字段不影响老客户端」;REST 靠版本路径/字段兼容。一句话:「内部全栈 TS 应用用 tRPC,多端/复杂取数用 GraphQL,对外开放用 REST」。 -
「tRPC 的『类型安全』具体体现在哪些地方?」
四处:① ⭐ 输入——input(z.object({ first: z.number().int().min(1).max(100) }))→ 客户端调用时有补全、有类型检查,且约束会作为 IDE 提示显示(不需要读文档);② ⭐ 返回值——useQuery().data的类型自动推导(含嵌套、数组、Date),不需要手写接口;③ ⭐ 错误——error.data.code是字面量联合类型('UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST' | 'CONFLICT' | ...),所以switch时 TS 能检查「漏了分支」;④ ⭐ 路由结构——trpc.order.refund.useMutation()里「改名/删除路由」会立刻导致客户端编译失败(这是「重构安全」的来源,也是「强耦合」的来源)。 -
「tRPC 的运行时安全靠什么?」
⭐ 靠 zod(服务端的输入校验)——这一点必须强调:TypeScript 的类型在运行时不存在,所以「只依赖 TS 类型」是不安全的(恶意请求可以传任意值,绕过编译期检查)。规矩:每个procedure的input都必须是 zod schema(或等价的运行时校验),并在服务端再次执行业务校验与权限校验。同理:权限必须在服务端做(中间件里throw new TRPCError({ code: 'FORBIDDEN' }))——前端的「隐藏按钮」只是体验优化(见 6.12)。⭐ 一句话:「TS 类型保护的是开发者,zod 与权限保护的是系统」。 -
「
transformer是干什么的?」
它解决「JSON 序列化会丢失类型」的问题(与 3.15 的「JSON 六个陷阱」同源):Date会变成字符串、Map/Set会变成{}、undefined会丢。tRPC 默认应该是 JSON,但可以配superjson(或自定义 transformer)让「服务端返回的Date,客户端收到仍是Date」(⭐ 因为它会把「类型标记」一起序列化,并在客户端还原)。两个要点:① ⭐ 两端必须配同一个 transformer(否则「服务端发 Date、客户端按字符串处理」→ 运行时.getTime()报错,而且类型上看起来是对的,⚠️ 最难查);② ⭐ 大响应体的 transformer 有成本(序列化后有额外开销)→ 如果「返回的都是纯 JSON 基本类型」,可以不配(省一点体积与 CPU)。 -
「tRPC 和 React Query 是什么关系?」
⭐ tRPC 的 React 集成是「构建在 React Query 之上」的(@trpc/react-query)——所以:① 缓存/失效/乐观更新/分页的心智完全一致(useQuery/useMutation/useInfiniteQuery/invalidate/setData/getData/cancel都是 React Query 的,见 7.4/7.7);② ⭐ tRPC 额外给你的是「类型安全的 hooks 与 query key」——你不用手写queryKey: ['orders', { filter }](⭐ 容易写错、容易不一致),而是utils.order.list.invalidate({ first: 20, status: 'paid' })(参数就是「查询输入」,内部生成与 hook 相同的 key);③ 所以「学 tRPC」其实是「学 React Query + 一层类型安全的封装」——⭐ 反过来说,如果团队已经熟练 React Query,上手 tRPC 的成本很低。 -
「tRPC 的『强耦合』会带来什么问题?」
三个:① ⭐ 部署耦合——「前后端必须同时上线」:如果「先发后端再发前端」(或反之),会出现「客户端调用不存在的路由」或「服务端不认识新参数」。缓解:① ⭐ 灰度期间「保留旧路由一个版本」;② 对「未知路由」返回明确的结构化错误(让前端能提示「请刷新页面」);③ 用「版本化 router」(如t.router({ v1: ..., v2: ... }))——⚠️ 但这会「把强耦合变回弱耦合」,成本上升。② ⭐ 不能给外部/多语言客户端用(没有跨语言契约)——✅ 需要「对外开放」时,在 tRPC 之上再包一层 REST/OpenAPI(⭐ 常见做法:内部用 tRPC,网关导出 OpenAPI)。③ ⭐ 类型图可能很大(大项目里AppRouter的类型推导会拖慢 tsserver)——✅ 用 project references(见 10.9),或把「router 类型」拆成「薄的公共入口」。 -
「如果团队用 tRPC,你会特别注意哪些工程问题?」
五条:① ⭐ schema 抽到shared/——如果「客户端 import 服务端 router 文件里的 schema」,可能连带把db/prisma打进前端 bundle(⚠️ 体积暴增甚至报错);② ⭐ 错误分类与重试——在 React Query 的retry里按error.data.code分流(UNAUTHORIZED/FORBIDDEN/NOT_FOUND/BAD_REQUEST不该重试);③ ⭐ SSR/批量请求的坑——httpBatchLink在 SSR 里可能「请求间串数据」,✅ 用splitLink让「服务端渲染的查询不走 batch」;④ ⭐ 乐观锁(version)——多设备/多标签页编辑时「防静默覆盖」(呼应 3.14/3.15);⑤ ⭐ 部署顺序——「保留旧路由一个版本」+「对未知路由返回明确错误」。
一句话速记
tRPC = 「端到端类型安全且零 codegen」——服务端只写一次(
t.router+procedure.input(zodSchema)+export type AppRouter = typeof appRouter),客户端import type那个类型(⭐ 编译后消失,不会把服务端代码打进 bundle)就得到「输入约束、返回值类型、错误字面量联合」的完整补全与检查,且「服务端签名变化 → 客户端编译失败」;⭐ 选型三判据:只有你自己的 TS 前端 → tRPC / 多端或其它语言 → GraphQL / 对外开放 → REST+OpenAPI;⚠️ TS 类型运行时不存在——每个input必须用 zod 做运行时校验,权限必须在服务端中间件做(前端只是体验);⭐transformer(superjson)解决「JSON 丢类型」(Date→真实 Date),⚠️ 两端必须一致(否则「类型看起来对、运行时.getTime()报错」,最难查);⭐ 它与 React Query 同构(tRPC 的 React 集成就建在 React Query 上),额外给你「类型安全的 hooks 与 query key」(utils.order.list.invalidate(input)的参数就是查询输入,不用手写 key);⚠️ 强耦合的代价是「部署必须同时上线」(缓解:保留旧路由一个版本 + 未知路由返回结构化错误 + 版本化 router),且类型图大时要靠 project references;工程要点:schema 抽到shared/(否则 db 可能被打进前端)、按error.data.code决定是否重试、SSR 用splitLink绕开 batch、用version做乐观锁、给乐观项用temp-前缀 id。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!