跳到主要内容

撰写一个微小的 tRPC 客户端

· 阅读 12 分钟
Julius Marminge

是否曾想过 tRPC 如何工作?也许你想开始为该项目做出贡献,但你害怕内部结构?这篇文章的目的是让你熟悉 tRPC 的内部结构,方法是编写一个涵盖 tRPC 工作原理主要部分的最小客户端。

信息

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

概述

让我们假设我们有一个简单的 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 類型,這就像說「這個鍵不存在」,如果我們嘗試存取它,將會導致類型錯誤。

🤯 代理重新對應

現在我們已經設定好所有類型,我們需要實際實作功能,它將在客戶端擴充伺服器的路由器定義,以便我們可以像一般函式一樣呼叫程序。

我們將首先建立一個用於建立遞迴代理的輔助函式 - 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。
  • 當我們在代理上呼叫函式,例如 .query(args) 時,會呼叫 apply 方法。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,您可能會認出萬用 /api/trpc/[trpc].ts 檔案,它將符合所有程序路徑。

我們還在 fetch 請求上有一個 TRPCResponse 類型註解。這決定了伺服器回應的 JSONRPC 相容回應格式。您可以在 這裡 閱讀更多相關資訊。簡而言之,我們會取得 resulterror 物件,我們可以使用它來判斷請求是否成功,並在發生錯誤時進行適當的錯誤處理。

這樣就完成了!這是您在客戶端呼叫 tRPC 程序時所需的所有程式碼,就像它們是本機函式一樣。表面上看起來,我們只是透過一般屬性存取呼叫 publicProcedure.query / mutation 的解析器函式,但我們實際上跨越了網路邊界,因此我們可以使用伺服器端函式庫,例如 Prisma,而不會洩漏資料庫憑證。

試試看!

現在,建立客戶端並提供伺服器的網址,當您呼叫程序時,您將獲得完整的自動完成和類型安全性!

ts
const url = 'https://127.0.0.1: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 = 'https://127.0.0.1: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 上找我。