大家好,欢迎来到今天的讲座。
如果前端开发是一场浪漫的邂逅,那么 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.tsx 或 page.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 方式(传统)
- RSC 组件发起
fetch('/api/trpc/getUser')。 - Next.js 接收请求,进入 Router,查询数据库。
- 数据序列化为 JSON。
- 通过 HTTP 传输回 RSC 组件。
- RSC 组件接收到 JSON,反序列化,渲染。
总耗时:网络 IO + 序列化开销。
场景 B:物理方式(本文主题)
- RSC 组件调用
serverClient.getUser。 - tRPC 直接访问内存中的 Router,查询数据库。
- 数据保持对象结构,直接返回给 RSC 组件。
- 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 无踪!