你是否曾经好奇 tRPC 是如何工作的?也许你想开始为该项目做出贡献,但你害怕内部机制?这篇文章的目的是通过编写一个涵盖 tRPC 工作原理的主要部分的最小客户端,让你熟悉 tRPC 的内部机制。
建议你了解 TypeScript 中的一些核心概念,例如泛型、条件类型、extends
关键字和递归。如果你不熟悉这些概念,我建议你阅读 Matt Pocock 的 TypeScript 初学者教程,在继续阅读之前熟悉这些概念。
概述
假设我们有一个简单的 tRPC 路由器,它包含三个看起来像这样的过程
ts
typePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
ts
typePost = {id : string;title : string };constposts :Post [] = [];constappRouter =router ({post :router ({byId :publicProcedure .input (z .object ({id :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .id ===input .id );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),byTitle :publicProcedure .input (z .object ({title :z .string () })).query (({input }) => {constpost =posts .find ((p ) =>p .title ===input .title );if (!post ) throw newTRPCError ({code : "NOT_FOUND" });returnpost ;}),create :publicProcedure .input (z .object ({title :z .string () })).mutation (({input }) => {constpost = {id :uuid (), ...input };posts .push (post );returnpost ;}),}),});
我们的客户端的目标是在客户端模拟这个对象结构,以便我们可以像这样调用过程
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.byId
和 post.create
分别具有 .query
和 .mutate
方法 - 在 tRPC 中,我们称之为装饰过程。在 @trpc/server
中,我们有一些推断帮助程序,它们将使用这些解析的方法推断我们过程的输入和输出类型,我们将使用这些帮助程序来推断这些函数的类型,所以让我们编写一些代码!
让我们考虑一下我们想要实现的目标,以便在路径上提供自动完成以及推断过程的输入和输出类型
- 如果我们位于路由器上,我们希望能够访问它的子路由器和过程。(我们稍后会讨论这一点)
- 如果我们位于查询过程中,我们希望能够在它上面调用
.query
。 - 如果我们位于变异过程中,我们希望能够在它上面调用
.mutate
。 - 如果我们试图访问其他任何内容,我们希望得到一个类型错误,表明该过程在后端不存在。
所以让我们创建一个类型来为我们做到这一点
ts
typeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
ts
typeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
我们将使用 tRPC 的一些内置推断帮助程序来推断我们过程的输入和输出类型,以定义 Resolver
类型。
ts
import type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
ts
import type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
让我们在 post.byId
过程中试一试
ts
typePostById =Resolver <AppRouter ['post']['byId']>;
ts
typePostById =Resolver <AppRouter ['post']['byId']>;
很好,这就是我们预期的 - 我们现在可以在我们的过程中调用 .query
并获得推断的正确输入和输出类型!
最后,我们将创建一个类型,它将递归地遍历路由器并在途中装饰所有过程
ts
import type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
ts
import type {TRPCRouterRecord } from "@trpc/server";import type {AnyTRPCRouter } from "@trpc/server";typeDecorateRouterRecord <TRecord extendsTRPCRouterRecord > = {[TKey in keyofTRecord ]:TRecord [TKey ] extends infer$Value ?$Value extendsTRPCRouterRecord ?DecorateRouterRecord <$Value >:$Value extendsAnyTRPCProcedure ?DecorateProcedure <$Value >: never: never;};
让我们稍微消化一下这个类型
- 我们将
TRPCRouterRecord
作为泛型传递给该类型,它是一个包含 tRPC 路由器上所有过程和子路由器的类型。 - 我们迭代记录的键,这些键是过程或路由器名称,并执行以下操作
- 如果键映射到路由器,我们将递归地对该路由器的过程记录调用该类型,这将装饰该路由器中的所有过程。这将在我们遍历路径时提供自动完成。
- 如果键映射到过程,我们将使用前面创建的
DecorateProcedure
类型来装饰该过程。 - 如果键不映射到过程或路由器,我们将分配
never
类型,这就像说“此键不存在”,如果我们试图访问它,将会导致类型错误。
🤯 Proxy 重新映射
现在我们已经设置了所有类型,我们需要实际实现该功能,它将增强客户端上服务器的路由器定义,以便我们可以像普通函数一样调用过程。
我们首先将创建一个用于创建递归代理的帮助程序函数 - createRecursiveProxy
这几乎与生产环境中使用的实现完全相同,只是我们没有处理一些边缘情况。 自己看看!
ts
interfaceProxyCallbackOptions {path : string[];args : unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
ts
interfaceProxyCallbackOptions {path : string[];args : unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : string[]) {constproxy : unknown = newProxy (() => {// dummy no-op function since we don't have any// client-side target we want to remap to},{get (_obj ,key ) {if (typeofkey !== 'string') returnundefined ;// Recursively compose the full path until a function is invokedreturncreateRecursiveProxy (callback , [...path ,key ]);},apply (_1 ,_2 ,args ) {// Call the callback function with the entire path we// recursively created and forward the argumentsreturncallback ({path ,args ,});},},);returnproxy ;}
这看起来有点神奇,它做了什么?
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 })
时如何工作的可视化表示
🧩 将所有内容整合在一起
现在我们有了这个帮助程序,并且知道它做了什么,让我们使用它来创建我们的客户端。我们将为 createRecursiveProxy
提供一个回调函数,它将接收路径和参数,并使用 fetch
请求服务器。我们需要在函数中添加一个泛型,它将接受任何 tRPC 路由器类型(AnyTRPCRouter
),然后我们将返回类型强制转换为我们之前创建的 DecorateRouterRecord
类型
ts
import {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
ts
import {TRPCResponse } from '@trpc/server/rpc';export constcreateTinyRPCClient = <TRouter extendsAnyTRPCRouter >(baseUrl : string,) =>createRecursiveProxy (async (opts ) => {constpath = [...opts .path ]; // e.g. ["post", "byId", "query"]constmethod =path .pop ()! as 'query' | 'mutate';constdotPath =path .join ('.'); // "post.byId" - this is the path procedures have on the backendleturi = `${baseUrl }/${dotPath }`;const [input ] =opts .args ;conststringifiedInput =input !==undefined &&JSON .stringify (input );letbody : undefined | string =undefined ;if (stringifiedInput !== false) {if (method === 'query') {uri += `?input=${encodeURIComponent (stringifiedInput )}`;} else {body =stringifiedInput ;}}constjson :TRPCResponse = awaitfetch (uri , {method :method === 'query' ? 'GET' : 'POST',headers : {'Content-Type': 'application/json',},body ,}).then ((res ) =>res .json ());if ('error' injson ) {throw newError (`Error: ${json .error .message }`);}// No error - all good. Return the data.returnjson .result .data ;}, []) asDecorateRouterRecord <TRouter ['_def']['record']>;// ^? provide empty array as path to begin with
这里最值得注意的是,我们的路径是 .
分隔的,而不是 /
。这使我们能够在服务器上拥有一个单一的 API 处理程序来处理所有请求,而不是为每个过程创建一个。如果你使用的是具有基于文件的路由的框架,例如 Next.js,你可能会认识到通配符 /api/trpc/[trpc].ts
文件,它将匹配所有过程路径。
我们还在 fetch
请求上有一个 TRPCResponse
类型注释。这决定了服务器响应的 JSONRPC 兼容响应格式。你可以阅读更多关于它的信息 这里。TL;DR,我们得到一个 result
或一个 error
对象,我们可以使用它来确定请求是否成功,以及如果出现错误,进行适当的错误处理。
就是这样!这是你在客户端调用 tRPC 过程所需的所有代码,就像它们是本地函数一样。从表面上看,它看起来就像我们只是通过正常的属性访问来调用 publicProcedure.query / mutation
的解析器函数,但实际上我们正在跨越网络边界,这样我们就可以使用服务器端库,例如 Prisma,而不会泄露数据库凭据。
试一试!
现在,创建客户端并为它提供服务器的 URL,你将在调用过程时获得完整的自动完成和类型安全!
ts
consturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
ts
consturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
客户端的完整代码可以在 这里找到,显示使用情况的测试可以在 这里找到。
结论
我希望你喜欢这篇文章,并了解了一些关于 tRPC 如何工作的知识。你可能不应该使用它,而应该使用 @trpc/client,它只比这里展示的内容大几 KB - 它比这里展示的内容具有更大的灵活性
- 用于中止信号、ssr 等的查询选项...
- 链接
- 过程批处理
- WebSockets / 订阅
- 良好的错误处理
- 数据转换器
- 边缘情况处理,例如当我们没有收到 tRPC 兼容响应时
我们今天也没有涵盖太多服务器端的内容,也许我们会在以后的文章中讨论。如果你有任何问题,请随时在 Twitter 上联系我。