是否曾想过 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
類型,這就像說「這個鍵不存在」,如果我們嘗試存取它,將會導致類型錯誤。
🤯 代理重新對應
現在我們已經設定好所有類型,我們需要實際實作功能,它將在客戶端擴充伺服器的路由器定義,以便我們可以像一般函式一樣呼叫程序。
我們將首先建立一個用於建立遞迴代理的輔助函式 - 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。- 當我們在代理上呼叫函式,例如
.query(args)
時,會呼叫apply
方法。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 相容回應格式。您可以在 這裡 閱讀更多相關資訊。簡而言之,我們會取得 result
或 error
物件,我們可以使用它來判斷請求是否成功,並在發生錯誤時進行適當的錯誤處理。
這樣就完成了!這是您在客戶端呼叫 tRPC 程序時所需的所有程式碼,就像它們是本機函式一樣。表面上看起來,我們只是透過一般屬性存取呼叫 publicProcedure.query / mutation
的解析器函式,但我們實際上跨越了網路邊界,因此我們可以使用伺服器端函式庫,例如 Prisma,而不會洩漏資料庫憑證。
試試看!
現在,建立客戶端並提供伺服器的網址,當您呼叫程序時,您將獲得完整的自動完成和類型安全性!
ts
consturl = 'https://127.0.0.1:3000/api/trpc';constclient =createTinyRPCClient <AppRouter >(url );// 🧙♀️ magic autocompletionclient .post .b ;//// 👀 fully typesafeconstpost = awaitclient .post .byId .query ({id : '123' });
ts
consturl = 'https://127.0.0.1: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 上找我。