您是否曾想過 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。- 當我們在代理上呼叫函式(例如
.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
檔案,它將符合所有程序路徑。
我們還有一個 TRPCResponse
類型註解在 fetch
請求上。這決定了伺服器回應的 JSONRPC 相容回應格式。您可以在 這裡 閱讀更多相關資訊。簡而言之,我們會取得一個 result
或 error
物件,我們可以使用它來判斷請求是否成功,並在發生錯誤時進行適當的錯誤處理。
就這樣!這是您在客戶端上呼叫 tRPC 程序時所需的所有程式碼,就像它們是本機函式一樣。表面上看起來,我們只是透過一般的屬性存取來呼叫 publicProcedure.query / mutation
的解析器函式,但我們實際上跨越了網路界線,因此我們可以使用伺服器端程式庫,例如 Prisma,而不會洩漏資料庫憑證。
試試看!
現在,建立客戶端並提供伺服器的 URL,當您呼叫程序時,您將獲得完整的自動完成和類型安全性!
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 等的查詢選項...
- 連結
- 程序批次處理
- WebSocket/訂閱
- 友善的錯誤處理
- 資料轉換器
- 處理邊緣案例,例如當我們沒有收到符合 tRPC 的回應時
我們今天也沒有涵蓋太多伺服器端的事物,也許我們會在未來的文章中涵蓋。如果你有任何問題,請隨時在 Twitter 上騷擾我。