跳至主内容

编写微型 tRPC 客户端

· 1 分钟阅读
Julius Marminge
tRPC Core Team Member
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

你是否好奇过 tRPC 的工作原理?或许你想为项目贡献代码,却被内部实现吓退?本文旨在通过编写一个覆盖 tRPC 核心机制的微型客户端,带你深入理解 tRPC 的内部运作。

信息

建议掌握 TypeScript 的核心概念,包括泛型、条件类型、extends 关键字和递归。若不熟悉这些概念,推荐先学习 Matt PocockTypeScript 入门教程,再继续阅读本文。

概述

假设我们有以下包含三个过程(procedure)的简易 tRPC 路由器:

ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});
ts
type Post = { id: string; title: string };
const posts: Post[] = [];
 
const appRouter = router({
post: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
byTitle: publicProcedure
.input(z.object({ title: z.string() }))
.query(({ input }) => {
const post = posts.find((p) => p.title === input.title);
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: uuid(), ...input };
posts.push(post);
return post;
}),
}),
});

客户端的目标是在前端复现这个对象结构,从而能够这样调用过程:

ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });
ts
const post1 = await client.post.byId.query({ id: '123' });
const post2 = await client.post.byTitle.query({ title: 'Hello world' });
const newPost = await client.post.create.mutate({ title: 'Foo' });

为此,tRPC 结合了 Proxy 对象和 TypeScript 魔法,在对象结构上动态添加 .query.mutate 方法 —— 这意味着我们实际上"欺骗"了开发者(后文详述),只为提供极致的开发体验!

高层实现思路是:将 post.byId.query() 映射为向服务器发送 GET 请求,将 post.create.mutate() 映射为 POST 请求,并确保类型从后端完整传播到前端。具体如何实现呢?

实现微型 tRPC 客户端

🧙‍♀️ TypeScript 魔法

让我们从有趣的 TypeScript 魔法开始,解锁大家在使用 tRPC 时熟悉的自动补全和类型安全能力。

需要使用递归类型来推断任意深度的路由结构。同时,我们需要为过程 post.byIdpost.create 分别添加 .query.mutate 方法 —— 在 tRPC 中,这称为"装饰"过程。通过 @trpc/server 的推断工具,可以获取已解析方法的输入输出类型,我们将利用这些工具来推断函数类型。现在开始编码吧!

为实现路径自动补全和过程输入输出类型推断,我们需要:

  • 当处于路由器时,能访问其子路由器和过程(稍后详解)

  • 当处于查询过程时,能调用其 .query 方法

  • 当处于变更过程时,能调用其 .mutate 方法

  • 当尝试访问不存在的过程时,触发类型错误提示

为此我们创建以下类型:

ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;
ts
type DecorateProcedure<TProcedure> = TProcedure extends AnyTRPCQueryProcedure
? {
query: Resolver<TProcedure>;
}
: TProcedure extends AnyTRPCMutationProcedure
? {
mutate: Resolver<TProcedure>;
}
: never;

使用 tRPC 内置的推断工具获取过程输入输出类型,定义 Resolver 类型。

ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 
ts
import type {
AnyTRPCProcedure,
inferProcedureInput,
inferProcedureOutput,
AnyTRPCQueryProcedure,
AnyTRPCMutationProcedure
} from '@trpc/server';
 
 
 
type Resolver<TProcedure extends AnyTRPCProcedure> = (
input: inferProcedureInput<TProcedure>,
) => Promise<inferProcedureOutput<TProcedure>>;
 

post.byId 过程上测试:

ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>
ts
type PostById = Resolver<AppRouter['post']['byId']>;
type PostById = (input: { id: string; }) => Promise<Post>

完美符合预期!现在可以在过程上调用 .query 并获取正确的输入输出类型推断!

最后创建递归遍历路由器的类型,并装饰所有过程:

ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};
ts
import type { TRPCRouterRecord } from "@trpc/server";
import type { AnyTRPCRouter } from "@trpc/server";
 
type DecorateRouterRecord<TRecord extends TRPCRouterRecord> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TRPCRouterRecord
? DecorateRouterRecord<$Value>
: $Value extends AnyTRPCProcedure
? DecorateProcedure<$Value>
: never
: never;
};

解析这个类型:

  1. 泛型参数 TRPCRouterRecord 包含 tRPC 路由器上的所有过程和子路由器

  2. 我们遍历记录中的键(即过程或路由器名称),并执行以下操作:

    • 如果键映射到路由器,则递归调用该路由器过程记录上的类型,这将装饰该路由器中的所有过程。在遍历路径时,这能提供自动补全功能。
    • 如果键映射到过程,则使用之前创建的DecorateProcedure类型装饰该过程。
    • 如果键不映射到任何过程或路由器,则分配never类型(相当于声明"此键不存在"),尝试访问时将触发类型错误。

🤯 代理重映射

设置好所有类型后,我们需要实际实现功能:在客户端增强服务器路由定义的结构,从而能像调用普通函数一样调用过程。

首先创建用于生成递归代理的辅助函数——createRecursiveProxy

信息

这与生产环境实现几乎完全相同(仅未处理某些边缘情况)。查看源码

ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}
ts
interface ProxyCallbackOptions {
path: readonly string[];
args: readonly unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: readonly string[]) {
const proxy: unknown = new Proxy(
() => {
// dummy no-op function since we don't have any
// client-side target we want to remap to
},
{
get(_obj, key) {
if (typeof key !== 'string') return undefined;
 
// Recursively compose the full path until a function is invoked
return createRecursiveProxy(callback, [...path, key]);
},
apply(_1, _2, args) {
// Call the callback function with the entire path we
// recursively created and forward the arguments
return callback({
path,
args,
});
},
},
);
 
return proxy;
}

这看起来有些神奇,它究竟做了什么?

  • get方法处理属性访问(如 post.byId)。key即访问的属性名——访问postkeypost,访问post.byIdkeybyId。递归代理将所有键组合成最终路径(例如["post", "byId", "query"]),用于确定请求的目标URL。

  • apply方法在调用代理函数时触发(如.query(args))。args即传入函数的参数——调用post.byId.query(args)时,args即输入数据(根据过程类型作为查询参数或请求体传递)。createRecursiveProxy接收的回调函数将映射apply操作,处理路径和参数。

下图展示了调用trpc.post.byId.query({ id: 1 })时代理的工作流程:

proxy

🧩 整合实现

有了这个辅助函数并理解其原理后,我们用它创建客户端。为createRecursiveProxy提供回调函数,该函数将获取路径和参数,通过fetch向服务器发起请求。需要为函数添加泛型以支持任意tRPC路由器类型(AnyTRPCRouter),然后将返回类型断言为我们之前创建的DecorateRouterRecord类型:

ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with
ts
import { TRPCResponse } from '@trpc/server/rpc';
 
export const createTinyRPCClient = <TRouter extends AnyTRPCRouter>(
baseUrl: string,
) =>
createRecursiveProxy(async (opts) => {
const path = [...opts.path]; // e.g. ["post", "byId", "query"]
const method = path.pop()! as 'query' | 'mutate';
const dotPath = path.join('.'); // "post.byId" - this is the path procedures have on the backend
let uri = `${baseUrl}/${dotPath}`;
 
const [input] = opts.args;
const stringifiedInput = input !== undefined && JSON.stringify(input);
let body: undefined | string = undefined;
if (stringifiedInput !== false) {
if (method === 'query') {
uri += `?input=${encodeURIComponent(stringifiedInput)}`;
} else {
body = stringifiedInput;
}
}
 
const json: TRPCResponse = await fetch(uri, {
method: method === 'query' ? 'GET' : 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
}).then((res) => res.json());
 
if ('error' in json) {
throw new Error(`Error: ${json.error.message}`);
}
// No error - all good. Return the data.
return json.result.data;
}, []) as DecorateRouterRecord<TRouter['_def']['record']>;
// ^? provide empty array as path to begin with

最值得注意的是:路径使用.分隔而非/。这允许我们在服务器端使用单个API处理器处理所有请求(而非为每个过程单独创建)。如果您使用基于文件路由的框架(如Next.js),可能会认出通配路由/api/trpc/[trpc].ts文件——它能匹配所有过程路径。

我们还为fetch请求添加了TRPCResponse类型注解。这定义了服务器响应的JSONRPC兼容格式(详情参阅rpc文档)。简而言之:响应包含result(结果)或error(错误)对象,据此判断请求是否成功,并在出错时执行相应错误处理。

大功告成!这就是在客户端像调用本地函数一样调用tRPC过程所需的全部代码。表面上看,我们似乎只是通过普通属性访问调用publicProcedure.query / mutation的解析函数,但实际上我们跨越了网络边界——因此可以使用Prisma等服务器端库,同时避免数据库凭证泄漏。

动手实践!

现在创建客户端并配置服务器URL,调用过程时即可享受完整的自动补全和类型安全!

ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }
ts
const url = 'http://localhost:3000/api/trpc';
const client = createTinyRPCClient<AppRouter>(url);
 
// 🧙‍♀️ magic autocompletion
client.post.b;
             
//
 
// 👀 fully typesafe
const post = await client.post.byId.query({ id: '123' });
const post: { id: string; title: string; }

完整客户端代码可在此处查看查看,使用示例测试用例参见此处测试用例

结语

希望本文能帮助您理解tRPC的工作原理。虽然不建议在生产环境使用此简化版(建议使用仅大几KB的@trpc/client),但官方客户端提供了更多强大功能:

  • 支持查询选项:中止信号、SSR等

  • 链接中间件

  • 过程批处理

  • WebSocket/订阅功能

  • 完善的错误处理

  • 数据转换器

  • 边缘情况处理(如非tRPC标准响应)

本文未深入探讨服务端实现细节,未来可能会另撰文详述。如有疑问,欢迎通过Twitter与我交流。