跳至主要内容

编写一个微型的 tRPC 客户端

·阅读时长 12 分钟
Julius Marminge

你有没有想过 tRPC 是如何工作的?也许你想开始为这个项目做贡献,但你害怕内部实现?这篇文章的目的是通过编写一个涵盖 tRPC 工作原理的主要部分的最小客户端,让你熟悉 tRPC 的内部实现。

信息

建议你了解 TypeScript 中的一些核心概念,例如泛型、条件类型、extends 关键字和递归。如果你不熟悉这些概念,我建议你阅读 Matt PocockTypeScript 入门教程,在继续阅读之前熟悉这些概念。

概述

假设我们有一个简单的 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 类型,这就像说“此键不存在”,如果我们尝试访问它,这将导致类型错误。

🤯 Proxy 重映射

现在我们已经设置了所有类型,我们需要实际实现该功能,它将在客户端上增强服务器的路由器定义,以便我们可以像正常函数一样调用过程。

我们首先将创建一个用于创建递归代理的辅助函数 - createRecursiveProxy

信息

这几乎是生产中使用的完全相同的实现,只是我们没有处理一些边缘情况。 自己看看

ts
interface ProxyCallbackOptions {
path: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: 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: string[];
args: unknown[];
}
 
type ProxyCallback = (opts: ProxyCallbackOptions) => unknown;
 
function createRecursiveProxy(callback: ProxyCallback, path: 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。键是我们访问的属性名称,因此当我们键入 post 时,我们的 key 将是 post,当我们键入 post.byId 时,我们的 key 将是 byId。递归代理将所有这些键组合成一个最终路径,例如["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,你可能会认出 catchall /api/trpc/[trpc].ts 文件,它将匹配所有过程路径。

我们还在 fetch 请求上有一个 TRPCResponse 类型注释。这确定了服务器响应的 JSONRPC 兼容响应格式。你可以阅读更多关于它的信息 这里。TL;DR,我们得到一个 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 如何工作的知识。你可能不应该使用它,而应该使用 @trpc/client,它只比这里展示的内容大几 KB - 它比这里展示的内容具有更大的灵活性

  • 用于中止信号、ssr 等的查询选项...
  • 链接
  • 过程批处理
  • WebSockets / 订阅
  • 良好的错误处理
  • 数据转换器
  • 边缘情况处理,例如当我们没有得到 tRPC 兼容的响应时

我们今天也没有涵盖太多服务器端的内容,也许我们会在以后的文章中介绍。如果你有任何问题,请随时在 Twitter 上联系我。