tRPC 与 React Query 的深度融合:实现无需 API 手册的自描述型全栈类型安全架构

各位好!我是你们的老朋友,今天咱们不聊虚的,咱们来聊聊全栈开发界的一对“绝代双骄”——tRPCReact Query

我知道你们很多人听到“全栈类型安全”这五个字的时候,第一反应是:“别闹了,谁还维护两个文档?谁还写两个接口定义?”

没错,这正是今天讲座的主题。我们要消灭那种“前端写 TypeScript,后端写 any”的乱世。我们要构建一种架构,让后端的函数定义直接变成前端的组件,让 API 手册变成多余的废纸,甚至让“自描述”这个词,真正意义上变成代码的一部分。

准备好了吗?咱们开始。

第一章:如果你讨厌 API 手册,那你一定要听这个

先想象一下这样一个场景:

你的前端团队很棒,他们严格遵循 TypeScript 规范,定义了一个 User 接口:

// frontend/src/types/user.ts
interface User {
  id: number;
  username: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

然后你的后端团队也很棒,但后端在用 Java 或者 Python,或者仅仅是懒得同步类型。于是他们生成了一个 JSON Schema,或者随手写了一个 user.py 文件,里面写着:

# backend/models.py
class User:
    id: str  # 哦,是字符串,前端是 number
    name: str
    email: str

当你把这两个东西对接的时候,发生了什么?
前端开发者看着 id: number 的定义,兴高采烈地调用了接口。结果呢?运行时报错:Cannot read property 'toUpperCase' of undefined,或者更糟糕,TypeError: Cannot convert value of type 'string' to type 'number'

这时候,你想起了那个已经被遗忘在服务器角落、落满灰尘的《API 接口文档 V1.0》。
“文档上说 id 是字符串!”前端吼道。
“那是旧版本!现在后台已经改成数字了!”后端吼道。
“你们这群没文化的!为什么不改回字符串?”

大家都很累,谁也没想加班,但代码挂了。

这就是没有类型安全的代价。我们每天写代码,90% 的时间都在处理这种“定义不一致”的垃圾时间。我们像是在玩大家来找茬,只不过找茬的对象是我们自己定义的接口。

tRPC 的出现,就是为了终结这种闹剧。

第二章:tRPC —— “不需要翻译的 RPC”

首先,纠正一个误区。tRPC 不是一种新的 RPC 协议(比如 gRPC 或 Thrift)。它不是让你去配置一堆 XML 文件或者 Protobuf 定义文件的。

tRPC 的核心哲学非常简单,甚至有点“霸道”:

如果你在服务器上定义了一个函数,那么在客户端,你应该能像调用本地函数一样调用它,并且拥有完全相同的类型定义。

它怎么做到的?这其实利用了 TypeScript 编译器的魔力。

当你定义了一个 Procedure(过程)时,tRPC 会捕获输入参数的类型和返回值的类型。然后,它会在运行时,通过 Proxies(代理)把这些类型“传送”给客户端。

简单来说,tRPC 的工作流程是这样的:

  1. 服务器端:你定义了 appRouter.procedure.input(z.object({ name: z.string() })).query(() => "Hello")
  2. 编译时:TypeScript 编译器看到这个函数,确认了输入是 z.string(),输出是 string
  3. 客户端生成:tRPC 的 CLI 工具会读取服务器代码,自动生成客户端的代码。
  4. 客户端调用:你写 trpc.hello.query({ name: "Alice" })。这时候,TypeScript 就会介入,它发现你传了一个对象,并检查这个对象的属性 name 是否是 string。如果传了 age,编译器直接报错!这就是编译时错误

看,这就是我们想要的。错误在代码写出来的那一刻就暴露了,而不是在用户点击“保存”按钮之后。

第三章:React Query —— tRPC 的最佳拍档

tRPC 解决了“类型”的问题,那“数据获取”呢?如果只是解决了类型,我们还得自己写 fetch 逻辑,自己处理 loading、error、retry,自己写缓存逻辑。那也太累了。

这时候,React Query(也就是大家熟悉的 TanStack Query)闪亮登场了。

React Query 的工作是管理服务端状态。它有一个核心理念:“缓存优先”。如果你之前请求过这个数据,它就不会再发起新的请求,除非你告诉它要更新。

tRPC 和 React Query 的结合,就像是给这辆车装上了涡轮增压。

tRPC 给你一个包含 data, isLoading, error 的对象(这很 React Query),而 React Query 负责在后台处理网络请求。

第四章:实战演练 —— 构建一个“全自动”的 TODO 应用

为了演示这个“无需 API 手册”的架构,我们来做一个极简的 TODO 应用。不需要数据库,我们就用内存模拟。

第一步:服务器端的“上帝视角”

在服务器端,你不需要写 JSON Schema,不需要写 Swagger 文档。你只需要写正常的 TypeScript 函数。

// server/src/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

// 初始化 tRPC
const t = initTRPC.create();

// 定义公共中间件:处理错误
const middleware = t.middleware(({ next }) => {
  return next();
});

// 核心路由
export const appRouter = t.router({
  // 这是一个查询 Procedure
  // 我们定义了输入验证规则
  getTodos: t.procedure
    .input(
      z.object({
        limit: z.number().min(1).max(100).default(10), // 限制返回数量
        status: z.enum(['pending', 'completed']).optional(), // 可选的枚举筛选
      })
    )
    .query(async ({ input }) => {
      // 模拟数据库查询
      // 注意:这里不需要写文档说"limit 必须是 1-100"
      return [
        { id: 1, title: '买咖啡', completed: false },
        { id: 2, title: '写代码', completed: true },
        // 根据输入筛选...
      ];
    }),

  // 这是一个变更 Procedure
  addTodo: t.procedure
    .input(
      z.object({
        title: z.string().min(1).max(50), // 必须有标题,且长度 1-50
      })
    )
    .mutation(async ({ input }) => {
      // 模拟数据库插入
      return { id: 3, title: input.title, completed: false };
    }),
});

export type AppRouter = typeof appRouter;

看!这里定义了 limit 限制是 1-100,定义了 status 只能是 pendingcompleted。这些验证规则是硬编码在代码里的。

第二步:客户端的“零配置”接入

现在,神奇的事情发生了。在你的前端项目中,安装 @trpc/client@trpc/react-query

你不需要手动去写接口定义文件。你只需要创建一个 client 实例,然后从服务器导入你的 AppRouter 类型。

// client/src/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/src/router';

// 这个 hook 就是你未来的核心武器
export const trpc = createTRPCReact<AppRouter>();

第三步:在 React 组件中使用

现在,让我们来看看组件代码。没有任何 fetch 调用,没有任何 axios.get,甚至没有 URL。

// client/src/components/TodoList.tsx
import { trpc } from '../trpc';
import { useState } from 'react';

export const TodoList = () => {
  const [input, setInput] = useState('');

  // 1. 获取数据
  // 你看这个 query 的参数,是不是看起来像调用本地函数?
  // 而且,TypeScript 会自动推断出这里的 input.type 是什么。
  const { data, isLoading, error } = trpc.getTodos.useQuery({
    limit: 20,
    status: 'pending', // 这里如果改成 'invalid_status',TypeScript 会直接报错!
  });

  // 2. 提交数据
  const addTodoMutation = trpc.addTodo.useMutation();

  if (isLoading) return <div>Loading... (Wait, why is it loading if it's local?)</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <ul>
        {data?.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          // 直接调用 mutation,不需要 axios.post
          addTodoMutation.mutate({ title: input });
          setInput('');
        }}
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          // 如果没输入,输入框就变灰,TypeScript 帮你做的
        />
        <button disabled={!input || addTodoMutation.isLoading}>
          {addTodoMutation.isLoading ? 'Adding...' : 'Add'}
        </button>
      </form>
    </div>
  );
};

这段代码的美妙之处在于:

  1. 类型推断trpc.getTodos.useQuery 的返回值 data,在 TypeScript 中直接就是 Todo[] 的类型。你不需要去写类型声明文件。
  2. 输入验证:我在代码里写了 limit: 20。如果我在组件里写了 limit: 1000TypeScript 编译器会直接报红,告诉你 1000 超过了 max(100) 的限制。这比任何运行时检查都更早地抓住了 bug。
  3. 错误处理error 对象是强类型的。你不用去猜错误是 404 还是 500,TypeScript 会根据服务器返回的错误信息生成精确的错误类型。

这就是“自描述”。代码本身描述了它能做什么,输入什么,输出什么。代码即文档

第五章:深入细节 —— Mutations、Infinite Queries 和 Server Components

光有一个简单的列表还不够。让我们再进阶一点,聊聊 Mutations 和 Infinite Queries。

1. Mutations(变更)的乐观 UI

当你要修改数据(比如更新头像、提交表单)时,React Query 的 useMutation 钩子非常强大。

假设服务器端有个更新用户的 Procedure:

// server
updateUser: t.procedure
  .input(z.object({ id: z.number(), name: z.string() }))
  .mutation(({ input }) => { /* ... */ })

客户端调用:

const updateMutation = trpc.updateUser.useMutation({
  onSuccess: (data) => {
    // 成功回调,TypeScript 保证 data 是 User 类型
    console.log('Updated user:', data);
  },
  onError: (err) => {
    // 错误回调,TypeScript 保证 err 是 TRPCError 类型
    console.error('Update failed:', err);
  }
});

最棒的是什么?是乐观更新。React Query 允许你在 mutation 开始的时候,立刻更新 UI,假设请求一定会成功。如果请求失败,它再自动回滚。这在交互体验上简直是降维打击。

2. 无限滚动

如果你要做一个无限滚动的 Feed 流(比如 Twitter 或 Hacker News),React Query 的 useInfiniteQuery 是标配。配合 tRPC,你可以轻松实现:

const { fetchNextPage, hasNextPage } = trpc.infiniteFeed.useInfiniteQuery(
  {},
  {
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    // 这里你可以调用 tRPC procedure,传递分页参数
  }
);

这里不再需要你去管 window.onscroll,也不需要自己去维护 page 状态。tRPC 和 React Query 自动帮你把参数映射好,自动帮你管理 nextCursor

3. Next.js App Router 的完美结合

如果你使用 Next.js 13+ 的 App Router,tRPC 可以直接在 Server Components 中运行!

这意味着什么?意味着你可以直接在服务器端调用 tRPC Procedure!

// app/page.tsx (Server Component)
import { trpc } from '@/app/trpc'; // 客户端 client,但在服务器也能用

export default async function HomePage() {
  // 在服务器端直接获取数据!
  // 零客户端 JavaScript,极速首屏加载!
  const todos = await trpc.getTodos.query({ limit: 10 });

  return (
    <div>
      <h1>Server Side Rendered Data</h1>
      <ul>
        {todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
      </ul>
    </div>
  );
}

然后在客户端组件中:

// components/TodoInput.tsx (Client Component)
import { trpc } from '@/app/trpc';

export const TodoInput = () => {
  // 在这里我们使用 React Query 的 useMutation
  // React Query 会自动处理从 Server Component 获取的数据到 Client Component 的传递
  const addTodo = trpc.addTodo.useMutation();

  // ...
}

这种架构让 React Query 既能做 Server Components 的数据获取(通过 Server Actions 或者直接调用),又能做 Client Components 的交互(通过 useMutation)。

第六章:为什么说这是“无需 API 手册”?

现在,让我们来总结一下为什么这种架构能消灭 API 手册。

1. 类型即契约
传统的 API 手册(比如 Swagger UI)是一份静态的文档。开发人员可能看,也可能不看。文档更新了,但开发人员忘记同步更新代码了,这就是事故的源头。
在 tRPC 架构下,契约代码(Schema)是直接嵌入在项目源码里的。只要 Schema 不变,类型就不会变。你不需要找时间读文档,你只需要看类型定义。

2. 实时反馈
假设后端同事突然改了接口:status 字段从字符串改成了布尔值。
前端同事写代码时,IDE 会直接标红。如果他在调用时还传了字符串,浏览器会报错。
这种反馈是实时的,是零延迟的。这就是所谓的“上帝视角”。

3. 自动补全
当你调用 trpc.something.query() 时,你的 IDE 会自动列出所有可用的 Procedures。输入 query( 后,它会自动列出这个 Procedure 需要什么参数,参数有什么约束。这比任何文档都好用。

4. 验证即文档
在 tRPC 中,zod schema 不只是为了验证数据,它本身就是在告诉调用者:“嘿,这个参数是个字符串,而且必须大于 0”。这就是最好的文档。

第七章:幽默的“坑”与“药方”

当然,这门技术也不是完美的,虽然它很棒,但我们要诚实。

坑一:循环依赖
如果你在定义路由的时候,引入了另一个定义了路由的文件,或者引入了数据库模型,这可能会导致循环引用错误。

  • 药方:不要在路由定义里引入业务逻辑代码。将业务逻辑抽离成纯函数,只保留类型定义在路由层。或者使用 defer

坑二:复杂输入类型
如果你传的参数非常复杂,嵌套很深,客户端生成类型可能看着有点头疼。

  • 药方:把复杂的输入拆分成多个小的 Procedure。比如不要写一个 createUser({ address: { city: ..., zip: ... }, profile: { ... } }),而是拆成 createUserupdateProfile

坑三:开发环境与生产环境的差异
tRPC 在开发环境会直接把类型传给前端。但在生产环境,它需要编译。如果你在 Server Component 里写了 await trpc.query(...),别忘了在构建命令里加上 tRPC 的生成脚本。

  • 药方:配置好 tsc-alias@trpc/server 的编译配置。不要相信“魔法”永远生效,你要知道魔法背后的原理。

第八章:最后的话 —— 开发者的自由

各位,写代码的最高境界是什么?是消除不确定性

以前,你调用一个接口,你不知道它会不会挂,不知道参数对不对,不知道返回值是什么。
现在,有了 tRPC 和 React Query 的深度融合,你的代码里充满了确定性。每一行代码,每一个变量,都受 Type Safety 的保护。

React Query 解决了“状态管理”的痛苦,tRPC 解决了“类型同步”的痛苦。两者的结合,就像是给你的全栈应用装上了一副防弹盔甲和一副导航眼镜。

在这个架构下,前端开发者不再需要跟后端吵吵嚷嚷地确认参数格式,不再需要维护两套代码。后端开发者也不需要费劲写 Swagger 文档。

代码即文档,编译即测试。

这不仅仅是一个技术栈的选择,更是一种工作流的革命。它让我们从繁琐的样板代码中解放出来,去思考更有趣的业务逻辑,去构建更流畅的用户体验。

所以,别再纠结你的 API 手册有没有更新了,把你的时间花在写代码上吧。毕竟,写代码比维护文档有趣多了,不是吗?

好了,今天的讲座就到这里。希望大家回去以后,能把那些该死的 any 全部干掉,拥抱类型,拥抱 tRPC,拥抱 React Query!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注