编写微型 tRPC 客户端
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
你是否好奇过 tRPC 的工作原理?或许你想为项目贡献代码,却被内部实现吓退?本文旨在通过编写一个覆盖 tRPC 核心机制的微型客户端,带你深入理解 tRPC 的内部运作。
建议掌握 TypeScript 的核心概念,包括泛型、条件类型、extends 关键字和递归。若不熟悉这些概念,推荐先学习 Matt Pocock 的 TypeScript 入门教程,再继续阅读本文。
概述
假设我们有以下包含三个过程(procedure)的简易 tRPC 路由器:
tstypePost = {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 ;}),}),});
tstypePost = {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 ;}),}),});
客户端的目标是在前端复现这个对象结构,从而能够这样调用过程:
tsconst 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' });
tsconst 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方法 -
当尝试访问不存在的过程时,触发类型错误提示
为此我们创建以下类型:
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
tstypeDecorateProcedure <TProcedure > =TProcedure extendsAnyTRPCQueryProcedure ? {query :Resolver <TProcedure >;}:TProcedure extendsAnyTRPCMutationProcedure ? {mutate :Resolver <TProcedure >;}: never;
使用 tRPC 内置的推断工具获取过程输入输出类型,定义 Resolver 类型。
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
tsimport type {AnyTRPCProcedure ,inferProcedureInput ,inferProcedureOutput ,AnyTRPCQueryProcedure ,AnyTRPCMutationProcedure } from '@trpc/server';typeResolver <TProcedure extendsAnyTRPCProcedure > = (input :inferProcedureInput <TProcedure >,) =>Promise <inferProcedureOutput <TProcedure >>;
在 post.byId 过程上测试:
tstypePostById =Resolver <AppRouter ['post']['byId']>;
tstypePostById =Resolver <AppRouter ['post']['byId']>;
完美符合预期!现在可以在过程上调用 .query 并获取正确的输入输出类型推断!
最后创建递归遍历路由器的类型,并装饰所有过程:
tsimport 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;};
tsimport 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类型(相当于声明"此键不存在"),尝试访问时将触发类型错误。
🤯 代理重映射
设置好所有类型后,我们需要实际实现功能:在客户端增强服务器路由定义的结构,从而能像调用普通函数一样调用过程。
首先创建用于生成递归代理的辅助函数——createRecursiveProxy:
这与生产环境实现几乎完全相同(仅未处理某些边缘情况)。查看源码!
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly 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 ;}
tsinterfaceProxyCallbackOptions {path : readonly string[];args : readonly unknown[];}typeProxyCallback = (opts :ProxyCallbackOptions ) => unknown;functioncreateRecursiveProxy (callback :ProxyCallback ,path : readonly 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)。key即访问的属性名——访问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类型:
tsimport {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
tsimport {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兼容格式(详情参阅rpc文档)。简而言之:响应包含result(结果)或error(错误)对象,据此判断请求是否成功,并在出错时执行相应错误处理。
大功告成!这就是在客户端像调用本地函数一样调用tRPC过程所需的全部代码。表面上看,我们似乎只是通过普通属性访问调用publicProcedure.query / mutation的解析函数,但实际上我们跨越了网络边界——因此可以使用Prisma等服务器端库,同时避免数据库凭证泄漏。
动手实践!
现在创建客户端并配置服务器URL,调用过程时即可享受完整的自动补全和类型安全!
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
tsconsturl = 'http://localhost:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
完整客户端代码可在此处查看查看,使用示例测试用例参见此处测试用例。
结语
希望本文能帮助您理解tRPC的工作原理。虽然不建议在生产环境使用此简化版(建议使用仅大几KB的@trpc/client),但官方客户端提供了更多强大功能:
-
支持查询选项:中止信号、SSR等
-
链接中间件
-
过程批处理
-
WebSocket/订阅功能
-
完善的错误处理
-
数据转换器
-
边缘情况处理(如非tRPC标准响应)
本文未深入探讨服务端实现细节,未来可能会另撰文详述。如有疑问,欢迎通过Twitter与我交流。
