React 服务器组件与 tRPC 的物理集成:在 RSC 内部直接调用类型安全的 Server-side 过程

大家好,欢迎来到今天的讲座。

如果前端开发是一场浪漫的邂逅,那么 React Server Components (RSC) 就是那位高冷、深藏不露、只在后台默默奉献的“服务器端男友”,而 tRPC 则是那位精通代码、追求极致类型安全、让你即使闭上眼睛也能写对接口的“类型女神”。

我们要讨论的主题是:如何让这两位跨越物理隔离的鸿沟,在服务器的内存空间里直接“私奔”。没错,我们要在 RSC 内部直接调用 tRPC,不通过 HTTP 协议,不通过序列化/反序列化的繁琐仪式,而是实现一种“物理”上的代码集成。

准备好了吗?让我们开始这场关于架构、类型体操和前端架构进阶的探险。

第一章:当绅士遇上忍者——RSC 与 tRPC 的文化冲突

首先,我们需要承认一个尴尬的现实。

传统的 tRPC 是基于 HTTP 的。它喜欢打电话,喜欢发包裹。你写一个 hello 过程,它就生成一个 HTTP 端点。你的浏览器端组件 fetch('/api/trpc/hello'),数据就传过来了。这很棒,很标准。

但是,RSC(React Server Components)出生在服务器上。它没有浏览器,没有 window,没有 navigator。RSC 的性格是内敛的,它通常只需要从数据库拿点数据,渲染一下,然后发个 HTML 就完事了。

传统的 tRPC 喜欢通过 HTTP 与世界交流,而 RSC 喜欢在服务器内存里直接算数。这两者如果强行用 HTTP 桥接,就会产生一种“远程调用”的奇怪感。

试想一下,你在 RSC 组件里写代码,结果调用了 fetch('/api/trpc/...')。这就像是你在厨房做菜(RSC),结果还要去后院给送外卖的人(HTTP 请求)发个短信说:“嘿,帮我递个盘子。” 虽然也能办到,但不仅慢,还绕远路。

更糟糕的是,传统的 fetch 意味着 JSON 序列化。在 RSC 这种高性能场景下,你不想把数据在内存里转来转去。你想要的是:输入数据,直接输出结果。

这就是我们要解决的痛点。我们要建立一条“物理连接”,让 RSC 组件在编写时,就能直接调用 tRPC 的过程,就像调用本地函数一样。

第二章:揭秘“物理连接”——Server-side Client

那么,这个魔法怎么实现?答案是:createTRPCProxyClient 配合 server: true

你可能习惯了浏览器端的客户端创建方式:

// 浏览器端(经典做法)
const client = createTRPCProxyClient<AppRouter>({
  links: [
    httpLink({
      url: 'http://localhost:3000/api/trpc',
    }),
  ],
  transformer: superjson,
});

这东西是给浏览器用的,它会把调用打包成 HTTP 请求发给服务器。

而在服务器端,特别是 RSC 场景下,我们需要一个“变体”。我们需要告诉 tRPC:“嘿,别发 HTTP 请求了,直接找到运行时的实例,帮我执行这个过程。”

这个选项叫做 server

让我们来定义这个神秘的“服务器端客户端”。

// utils/server-trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';
import superjson from 'superjson';

// 关键点:我们创建了一个连接,但配置不同
export const serverClient = createTRPCProxyClient<AppRouter>({
  transformer: superjson,
  links: [
    // 注意:这里其实不需要 httpLink,但我们为了兼容性或者未来扩展,依然保留配置结构
    // 在 server: true 模式下,tRPC 会拦截这些调用,直接在 Node.js 环境中执行
    httpBatchLink({
      url: 'http://localhost:3000/api/trpc', 
      // 这里的 url 在 server: true 模式下会被忽略
    }),
  ],
  // 这里的 true 是核心!
  server: true, 
});

等等,别急着惊呼“魔法发生了”。这只是第一步。server: true 告诉 tRPC:“我是一个服务端组件,我不走网络,我走内存。”

但是,仅仅开启 server: true 是不够的。如果只是开启它,tRPC 会找不到 AppRouter 的定义,或者找不到运行时。我们需要确保这个 serverClient 拥有访问 tRPC 核心实例的能力。

这通常需要配合一个全局的 tRPC 实例或者特定的依赖注入机制。

第三章:实战演练——构建类型安全的“内联”调用

让我们把目光放回到我们的项目结构。为了在 RSC 中实现直接调用,我们需要修改我们的 tRPC 初始化逻辑。

首先,我们需要确保我们的 tRPC 服务器(createTRPC 那个实例)是可访问的。通常,我们在 server/routers/_app.ts 中创建它。

// server/routers/_app.ts
import { initTRPC } from '@trpc/server';
import * as trpc from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import superjson from 'superjson';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return shape;
  },
});

// 中间件:简单的认证检查
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session) {
    throw new trpc.TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, session: ctx.session } });
});

const protectedProcedure = t.procedure.use(isAuthed);

export const router = t.router({
  // 用户查询
  getUserById: t.procedure
    .input((val: unknown) => {
      if (typeof val !== 'string') throw new Error('ID must be string');
      return val;
    })
    .query(async ({ input }) => {
      // 这里直接调用数据库,不通过 HTTP
      const user = await prisma.user.findUnique({ where: { id: input } });
      return user;
    }),

  // 复杂的业务逻辑
  updateSettings: protectedProcedure
    .input(z.object({ theme: z.string() }))
    .mutation(async ({ input, ctx }) => {
      // 在这里我们可以直接使用 ctx.session
      return await prisma.user.update({
        where: { id: ctx.session.userId },
        data: { theme: input.theme },
      });
    }),
});

export type AppRouter = typeof router;

好,现在我们有了路由器。接下来,我们在组件中如何使用它?

在 Next.js App Router 中,我们通常有一个 layout.tsxpage.tsx

// app/page.tsx
import { serverClient } from '@/utils/server-trpc'; // 引入我们刚刚定义的服务器端客户端
import { prisma } from '@/lib/prisma'; // 假设我们有这个

// 这里的函数不再是 async,因为我们要手动处理数据获取逻辑
export default async function HomePage() {
  // 直接调用!没有任何 HTTP 请求!
  // 类型检查生效!如果你把 'all' 改成 'foo',TypeScript 会直接报错。
  const allUsers = await serverClient.getUserById('all'); // 这里 input 类型错误,TypeScript 会抓包

  // 等等,上面的代码有个小坑。input 定义是 string,我们传 string 没问题。
  // 但是 tRPC 的 input 定义通常更严谨。

  // 正确的调用方式:
  const user = await serverClient.getUserById('user-123');

  return (
    <main>
      <h1>Server Side Magic</h1>
      <pre>{JSON.stringify(user, null, 2)}</pre>
    </main>
  );
}

看!这就对了。没有 fetch,没有 await api.get(...)。这就是我们要的“物理集成”。

但是,这里有个巨大的陷阱:循环依赖

如果你的 serverClient 导入自 utils/server-trpc,而 server-trpc 又依赖于 server/routers/_app,你可能会在构建时报错“Circular dependency detected”。

为了避免这个问题,我们需要一种模式。我们需要把 serverClient 的创建逻辑从导入 AppRouter 的地方移开。

3.1 最佳实践:独立的服务器端客户端工厂

我们要把 serverClient 的初始化逻辑封装在一个单独的文件中,并且只暴露一个变量,而不导入任何复杂的路由定义。

// utils/server-trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';
import superjson from 'superjson';

// 声明一个全局变量,避免循环依赖
let _serverClient: ReturnType<typeof createTRPCProxyClient<AppRouter>> | null = null;

export const getServerClient = () => {
  if (!_serverClient) {
    // 在这里配置 server: true
    _serverClient = createTRPCProxyClient<AppRouter>({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc', 
        }),
      ],
      server: true,
    });
  }
  return _serverClient;
};

这样,在任何组件中,我们只需要 import { getServerClient } from '@/utils/server-trpc',然后调用 getServerClient().getUserById(...)。这就像是一个工厂,源源不断地产出类型安全的服务端调用能力。

第四章:类型体操——当 TypeScript 知道你在服务器上

这部分是 tRPC 和 RSC 结合最迷人的地方。这就是所谓的“End-to-end Type Safety”(端到端类型安全)。

假设你的数据库模型是这样的:

model User {
  id String @id
  name String
  email String @unique
  posts Post[]
}

你的 tRPC 过程定义是这样的:

router: {
  getUser: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
    return prisma.user.findUnique({ where: { id: input.id } });
  })
}

当你调用 serverClient.getUser 时,TypeScript 会知道这个函数接受一个 { id: string } 的对象。如果传入 { name: "John" },编译器会立即罢工。

更重要的是,返回值。

如果 getUser 返回的是 User,那么在你的 RSC 组件中,user 的类型就是 User。你可以直接访问 user.name,如果 User 模型里没有 email 字段,TypeScript 会告诉你“Property ’email’ does not exist on type ‘User’”。

这种一致性贯穿了整个栈。从数据库 Schema,到 tRPC Router,到 Server Client,再到 Client Component。你不需要维护两套类型定义,不需要担心“数据库里加了字段,前端忘了改类型”。

这不仅仅是方便,这是给前端开发者的“安全气囊”。

第五章:上下文与认证——在服务端拿捏 Session

在浏览器端,你通过 Cookie 获取 Session。在 RSC 中,你可以直接通过 tRPC 的 Context 访问 Session。

让我们回顾一下我们在第二章的 tRPC Context 定义:

type Context = {
  session: Session | null; // 假设这是你的 session 对象
  // ... 其他上下文信息
};

当你配置 server: true 时,tRPC 会自动注入这个 Context 到你的过程调用中。

// server/routers/_app.ts
export const router = t.router({
  // 这个过程需要认证
  getMyProfile: protectedProcedure.query(async ({ ctx }) => {
    // 这里的 ctx.session 就是你当前请求的 Session,不需要手动去 cookie 解析了!
    return await prisma.user.findUnique({ where: { id: ctx.session.userId } });
  }),
});

在 RSC 中调用:

// app/dashboard/page.tsx
import { getServerClient } from '@/utils/server-trpc';

export default async function DashboardPage() {
  const client = getServerClient();

  // 调用需要认证的过程
  const profile = await client.getMyProfile();

  return <div>Hello {profile.name}</div>;
}

整个过程全自动。因为我们在服务端运行,我们可以直接读取 HTTP headers 和 cookies,注入到 tRPC 的 Context 中。tRPC 不知道(也不关心)它是通过 HTTP 还是通过内存被调用的,它只知道它有一个 Context,并且会把它传给你的过程。

第六章:性能分析——为什么物理连接更快?

你可能会问:“为了直接调用,我搞这么复杂,性能真的有提升吗?”

答案是:有,而且提升非常显著。

让我们来做一个对比实验(假设场景):

场景 A:HTTP 方式(传统)

  1. RSC 组件发起 fetch('/api/trpc/getUser')
  2. Next.js 接收请求,进入 Router,查询数据库。
  3. 数据序列化为 JSON。
  4. 通过 HTTP 传输回 RSC 组件。
  5. RSC 组件接收到 JSON,反序列化,渲染。
    总耗时:网络 IO + 序列化开销。

场景 B:物理方式(本文主题)

  1. RSC 组件调用 serverClient.getUser
  2. tRPC 直接访问内存中的 Router,查询数据库。
  3. 数据保持对象结构,直接返回给 RSC 组件。
  4. RSC 组件直接渲染。
    总耗时:仅仅是函数调用和数据库查询。

在 RSC 的场景下,数据流通常是“读多写少”。直接在服务端通过内存调用 tRPC,消除了序列化的 CPU 开销和网络延迟。对于数据量大的对象,这种优势更加明显。

第七章:进阶挑战——何时使用,何时停手

虽然“物理集成”听起来很酷,但它不是万能药。我们需要保持警惕。

7.1 避免循环依赖

这是最大的敌人。请务必把 getServerClient 放在 utils 目录下,不要把它放在 server/routers 或者 app 目录下。

7.2 依赖注入

server: true 模式下,tRPC 需要知道如何初始化 Context。通常,我们需要在 createTRPCProxyClient 之外,手动编写一段逻辑来构建 Context,或者依赖全局状态。

如果你使用 Next.js,你可以在一个独立的文件中处理 Context 的构建逻辑,然后传递给 server: true 的客户端配置。

7.3 边缘运行时与 Node.js 运行时的差异

tRPC 默认假设运行在 Node.js 环境中。如果你使用了 Cloudflare Workers 或 Edge Runtime,直接访问 Node.js API(如 fs)会报错。在使用物理集成时,请确保你的 serverClient 运行在兼容的环境中。

第八章:终极架构图

为了让你彻底理解,我画(虽然是用文字画)一张脑图:

[浏览器]
   |
   |  (只包含 Client Component,发送 fetch 请求)
   v
[Next.js API Route /api/trpc]  <-- 这里处理客户端的请求
   |
   |  (转发/路由)
   v
[tRPC Router (内存实例)]
   |
   |  <-- 这里的连接是物理的,没有 HTTP
   |  <-- 直接访问 Context (Session, DB connection)
   v
[数据库 / 外部服务]

在页面层级:

// app/page.tsx
export default async function Page() {
  // 1. 获取服务端客户端工厂
  const client = getServerClient();

  // 2. 直接调用,物理连接建立
  const user = await client.getUserById('123');

  return <div>User: {user.name}</div>;
}

第九章:代码示例大合集——复制即用

好了,理论讲完了,我们来点干货。下面是一个完整的、可以直接拿去用的示例。这包含从配置到使用的全流程。

Step 1: 定义 Context 和 Middleware

// server/context.ts
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { PrismaClient } from '@prisma/client';
import { z } from 'zod';

type Session = {
  userId: string;
  name: string;
};

type CreateContextOptions = {
  req: CreateNextContextOptions['req'];
  res: CreateNextContextOptions['res'];
  session: Session | null;
};

export const createContext = ({ req, res, session }: CreateContextOptions) => {
  // 这里可以注入任何依赖,比如 Prisma 实例
  const prisma = new PrismaClient();

  return {
    req,
    res,
    prisma,
    session,
  };
};

export type Context = Awaited<ReturnType<typeof createContext>>;

Step 2: 初始化 Router

// server/routers/_app.ts
import { initTRPC } from '@trpc/server';
import type { Context } from './context';
import superjson from 'superjson';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
});

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session) {
    throw new trpc.TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({ ctx: { ...ctx, session: ctx.session } });
});

export const router = t.router({
  hello: t.procedure.query(() => {
    return { greeting: 'hello from server!' };
  }),

  getUser: t.procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const user = await ctx.prisma.user.findUnique({
        where: { id: input.id },
        select: { id: true, name: true, email: true },
      });
      return user;
    }),

  protectedRoute: isAuthed.mutation(async ({ ctx }) => {
    // 这个 mutation 只有登录用户能执行
    return { success: true, message: `Hello ${ctx.session.name}` };
  }),
});

export type AppRouter = typeof router;

Step 3: 创建服务端客户端工厂

// utils/server-trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/_app';
import superjson from 'superjson';

// 避免循环依赖的静态存储
let _serverClient: ReturnType<typeof createTRPCProxyClient<AppRouter>> | null = null;

export const getServerClient = () => {
  if (!_serverClient) {
    _serverClient = createTRPCProxyClient<AppRouter>({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc', 
        }),
      ],
      server: true, // 关键!开启服务端模式
    });
  }
  return _serverClient;
};

Step 4: 在 RSC 中使用

// app/page.tsx
import { getServerClient } from '@/utils/server-trpc';

export default async function Home() {
  const client = getServerClient();

  // 直接调用,享受类型安全
  const result = await client.hello();

  // 这里的 result.greeting 是确定的类型
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm">
        <h1>Server Side tRPC Integration</h1>
        <p>{result.greeting}</p>
      </div>
    </main>
  );
}

第十章:总结——拥抱物理连接

我们今天探讨了如何将 React Server Components 与 tRPC 进行“物理集成”。

我们看到了传统的 HTTP 方式是多么的啰嗦,我们看到了 server: true 模式是如何让我们绕过网络层,直接在内存中调用类型安全的代码的。

这种方法不仅保留了 tRPC 最引以为傲的类型安全,还利用了 RSC 的高性能特性,消除了序列化开销。它让我们在编写服务端逻辑时,体验和编写客户端组件一样流畅。

当然,这需要一点架构上的小心思(比如处理循环依赖),但当你看到那个闪烁的光标,输入 client.,IDE 就自动补全了你所有的过程和输入类型时,你会发现这一切都是值得的。

这就是前端开发的未来——代码在服务端自由穿梭,类型在逻辑中无缝传递。

感谢大家的聆听,祝你们在 React 的世界里,代码如诗,Bug 无踪!

发表回复

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