陌上人如玉
公子世无双

7.11 tRPC

7.11 tRPC

一句话: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
}

六、面试延伸

  1. 「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。而且「服务端函数签名的变化会立刻变成客户端的编译错误」——这是真正的端到端类型安全。

  2. 「tRPC、GraphQL、REST 怎么选?」
    三个判据:① ⭐ 「客户端是谁」——只有你自己的 TS 前端 → tRPC(开发体验最好);多端(Web/iOS/Android)或其它语言 → GraphQL(schema 是跨语言的契约);面向外部开发者/第三方 → REST + OpenAPI(最通用、缓存最容易)。② ⭐ 「取数灵活性」——需要「客户端声明字段、一次拿多个资源」→ GraphQL;「按功能调用固定接口」→ REST/tRPC 都行。③ ⭐ 「演进与部署」——tRPC 强耦合(同仓同版本,⚠️ 不能「先发后端再发前端」);GraphQL「加字段不影响老客户端」;REST 靠版本路径/字段兼容。一句话:「内部全栈 TS 应用用 tRPC,多端/复杂取数用 GraphQL,对外开放用 REST」。

  3. 「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() 里「改名/删除路由」会立刻导致客户端编译失败(这是「重构安全」的来源,也是「强耦合」的来源)。

  4. 「tRPC 的运行时安全靠什么?」
    ⭐ 靠 zod(服务端的输入校验)——这一点必须强调:TypeScript 的类型在运行时不存在,所以「只依赖 TS 类型」是不安全的(恶意请求可以传任意值,绕过编译期检查)。规矩:每个 procedure 的 input 都必须是 zod schema(或等价的运行时校验),并在服务端再次执行业务校验与权限校验。同理:权限必须在服务端做(中间件里 throw new TRPCError({ code: 'FORBIDDEN' }))——前端的「隐藏按钮」只是体验优化(见 6.12)。⭐ 一句话:「TS 类型保护的是开发者,zod 与权限保护的是系统」。

  5. 「transformer 是干什么的?」
    它解决「JSON 序列化会丢失类型」的问题(与 3.15 的「JSON 六个陷阱」同源):Date 会变成字符串、Map/Set 会变成 {}、undefined 会丢。tRPC 默认应该是 JSON,但可以配 superjson(或自定义 transformer)让「服务端返回的 Date,客户端收到仍是 Date」(⭐ 因为它会把「类型标记」一起序列化,并在客户端还原)。两个要点:① ⭐ 两端必须配同一个 transformer(否则「服务端发 Date、客户端按字符串处理」→ 运行时 .getTime() 报错,而且类型上看起来是对的,⚠️ 最难查);② ⭐ 大响应体的 transformer 有成本(序列化后有额外开销)→ 如果「返回的都是纯 JSON 基本类型」,可以不配(省一点体积与 CPU)。

  6. 「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 的成本很低。

  7. 「tRPC 的『强耦合』会带来什么问题?」
    三个:① ⭐ 部署耦合——「前后端必须同时上线」:如果「先发后端再发前端」(或反之),会出现「客户端调用不存在的路由」或「服务端不认识新参数」。缓解:① ⭐ 灰度期间「保留旧路由一个版本」;② 对「未知路由」返回明确的结构化错误(让前端能提示「请刷新页面」);③ 用「版本化 router」(如 t.router({ v1: ..., v2: ... }))——⚠️ 但这会「把强耦合变回弱耦合」,成本上升。② ⭐ 不能给外部/多语言客户端用(没有跨语言契约)——✅ 需要「对外开放」时,在 tRPC 之上再包一层 REST/OpenAPI(⭐ 常见做法:内部用 tRPC,网关导出 OpenAPI)。③ ⭐ 类型图可能很大(大项目里 AppRouter 的类型推导会拖慢 tsserver)——✅ 用 project references(见 10.9),或把「router 类型」拆成「薄的公共入口」。

  8. 「如果团队用 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。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.11 tRPC

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫