跳到主要內容

· 閱讀時間 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 類型,這就像在說「此鍵不存在」,如果我們嘗試存取它,將會導致類型錯誤。

🤯 Proxy 重新對應

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

我們會先建立一個用於建立遞迴代理的輔助函式 - 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 檔案,它將符合所有程序路徑。

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

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

試試看!

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

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 等的查詢選項...
  • 連結
  • 程序批次處理
  • WebSocket/訂閱
  • 友善的錯誤處理
  • 資料轉換器
  • 處理邊緣案例,例如當我們沒有收到符合 tRPC 的回應時

我們今天也沒有涵蓋太多伺服器端的事物,也許我們會在未來的文章中涵蓋。如果你有任何問題,請隨時在 Twitter 上騷擾我。

· 閱讀 9 分鐘
Sachin Raja

作為函式庫作者,我們的目標是為同儕提供最佳的開發人員體驗 (DX)。縮短錯誤時間並提供直觀的 API,可以消除開發人員心裡的精神負擔,讓他們專注於最重要的事情:絕佳的最終使用者體驗。


眾所周知,TypeScript 是 tRPC 提供其驚人 DX 的驅動力。TypeScript 的採用是當今提供絕佳基於 JavaScript 的體驗的現代標準,但這種改進的類型確定性確實有一些折衷。

如今,TypeScript 類型檢查器容易變慢(儘管像 TS 4.9 這樣的版本很有希望!)。函式庫幾乎總是包含程式碼庫中最花俏的 TypeScript 咒語,將你的 TS 編譯器逼到極限。因此,像我們這樣的函式庫作者必須注意我們對這種負擔的貢獻,並盡力讓你的 IDE 盡可能快速地運作。

自動化函式庫效能

當 tRPC 在 v9 中時,我們開始看到開發人員回報說他們大型的 tRPC 路由器開始對他們的類型檢查器產生不利影響。這對 tRPC 來說是一種新的體驗,因為我們在 tRPC 開發的 v9 階段看到了巨大的採用。隨著越來越多的開發人員使用 tRPC 建立越來越大的產品,一些裂痕開始顯現。

你的程式庫現在可能不會很慢,但隨著程式庫的成長和變化,持續關注效能非常重要。自動化測試可以透過在每次提交時以程式化方式測試你的程式庫程式碼,減輕你的程式庫撰寫(和應用程式建置!)的巨大負擔。

對於 tRPC,我們盡力透過產生測試一個有 3,500 個程序和 1,000 個路由器的路由器來確保這一點。但這只會測試我們可以在 TS 編譯器崩潰之前將它推到多遠,而不是類型檢查需要多長時間。我們測試程式庫的所有三個部分(伺服器、純粹用戶端和 React 用戶端),因為它們都有不同的程式碼路徑。過去,我們已經看到只孤立於程式庫某一部分的回歸,並依賴我們的測試在這些意外行為發生時向我們顯示。我們仍然想做更多來衡量編譯時間。

tRPC 不是一個執行時期繁重的程式庫,因此我們的效能指標集中在類型檢查上。因此,我們注意

  • 使用 tsc 進行類型檢查時速度很慢
  • 有很長的初始載入時間
  • 如果 TypeScript 語言伺服器花很長時間才能回應變更

最後一點是 tRPC 必須最注意的。你永遠不希望你的開發人員在變更後必須等待語言伺服器更新。這是 tRPC 必須維持效能的地方,以便你可以享受出色的 DX。

我在 tRPC 中如何找到效能機會

TypeScript 精確度和編譯器效能之間總是有取捨。這兩者都是其他開發人員的重要考量,因此我們必須非常注意我們如何撰寫類型。應用程式是否可能因為某個類型「太寬鬆」而遇到嚴重錯誤?效能提升是否值得?

是否甚至會有任何有意義的效能提升?好問題。

讓我們來看看如何找出 TypeScript 程式碼中可以提升效能的時機。我們將探討我建立 PR #2716 的過程,結果讓 TS 編譯時間減少了 59%。


TypeScript 有內建的 追蹤工具,可以協助你找出類型中的瓶頸。它並非完美,但它是目前最好的工具。

理想的做法是在真實世界的應用程式上測試你的函式庫,模擬你的函式庫對真實開發人員的作用。對於 tRPC,我建立了一個基本的 T3 應用程式,類似於許多使用者使用的應用程式。

以下是追蹤 tRPC 的步驟

  1. 將函式庫連結到範例應用程式。這樣一來,你可以變更函式庫程式碼,並立即在本地端測試變更。

  2. 在範例應用程式中執行此指令

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. 你會在電腦上取得一個 trace/trace.json 檔案。你可以使用追蹤分析應用程式 (我使用 Perfetto) 或 chrome://tracing 開啟該檔案。

這時事情變得有趣,我們可以開始了解應用程式中類型的效能概況。以下是第一個追蹤的結果: 追蹤長條圖顯示 src/pages/index.ts 花費 332 毫秒進行類型檢查

長條圖越長,表示花費在該程序上的時間越多。我已在此螢幕截圖中選取最上方的綠色長條圖,表示 src/pages/index.ts 是瓶頸。在 Duration 欄位下方,你會看到它花了 332 毫秒,這是在類型檢查上花費的龐大時間!藍色的 checkVariableDeclaration 長條圖告訴我們,編譯器將大部分時間花在一個變數上。按一下該長條圖,會告訴我們是哪一個變數: 追蹤資訊顯示變數位置為 275 pos 欄位顯示變數在檔案文字中的位置。前往 src/pages/index.ts 中的位置,會發現罪魁禍首是 utils = trpc.useContext()

但這怎麼可能?我們只是使用一個簡單的掛勾!讓我們來看看程式碼

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

好吧,這裡沒什麼好注意的。我們只看到一個 useContext 和一個查詢無效化。從表面上來看,沒有什麼 應該 會讓 TypeScript 變得很重,這表示問題一定出在堆疊的更深處。讓我們來看看這個變數背後的類型

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @link https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

好吧,現在我們有一些東西可以解開並了解。讓我們先找出這段程式碼在做什麼。

我們有一個遞迴類型 DecoratedProcedureUtilsRecord,它會遍歷路由器中的所有程序,並使用 React Query 實用程式(例如 invalidateQueries)來「裝飾」(新增方法)這些程序。

在 tRPC v10 中,我們仍然支援舊的 v9 路由器,但 v10 用戶端無法呼叫 v9 路由器的程序。因此,對於每個程序,我們都會檢查它是否為 v9 程序(extends LegacyV9ProcedureTag),如果是,則將其移除。這對 TypeScript 來說是一項繁重的任務...如果它沒有進行延遲評估

延遲評估

這裡的問題是 TypeScript 會在類型系統中評估所有這些程式碼,即使它沒有立即使用。我們的程式碼只使用 utils.r49.greeting.invalidate,因此 TypeScript 只需要展開 r49 屬性(一個路由器),然後展開 greeting 屬性(一個程序),最後展開該程序的 invalidate 函式。該程式碼中不需要其他類型,而且立即找到所有 tRPC 程序的每個 React Query 實用程式方法的類型會不必要地降低 TypeScript 的速度。TypeScript 會延遲評估物件上的屬性的類型,直到它們被直接使用,因此理論上我們上面的類型應該會進行延遲評估...對吧?

嗯,它並非完全是一個物件。實際上有一個類型包裝了整個東西:OmitNeverKeys。這個類型是一個實用程式,它會從物件中移除值為 never 的鍵。這是我們移除 v9 程序的部分,以便這些屬性不會顯示在 Intellisense 中。

但這會造成巨大的效能問題。我們強制 TypeScript 立即評估所有類型的值,以檢查它們是否為 never

我們如何解決這個問題?讓我們將我們的類型改為減少

延遲

我們需要找到一種方法,讓 v10 API 更優雅地適應舊版的 v9 路由器。新的 tRPC 專案不應受到 互通模式降低 TypeScript 效能的影響。

這個想法是重新排列核心類型本身。v9 程序與 v10 程序是不同的實體,因此它們不應在我們的程式庫程式碼中共享相同的空間。在 tRPC 伺服器端,這表示我們必須做一些工作,才能將類型儲存在路由器中不同欄位上,而不是單一的 record 欄位(請參閱上面提到的 DecoratedProcedureUtilsRecord)。

我們做了一個變更,讓 v9 路由器在轉換為 v10 路由器時,將其程序注入到 legacy 欄位中。

舊類型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

如果您回憶上面 DecoratedProcedureUtilsRecord 類型,您會看到我們在這裡附加 LegacyV9ProcedureTag 以在類型層級中區分 v9v10 程序,並強制執行 v9 程序不會從 v10 客户端呼叫。

新類型

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

現在,我們可以移除 OmitNeverKeys,因為這些程序已預先排序,所以路由器的 record 屬性類型將包含所有 v10 程序,而其 legacy 屬性類型將包含所有 v9 程序。我們不再強制 TypeScript 完全評估龐大的 DecoratedProcedureUtilsRecord 類型。我們也可以移除使用 LegacyV9ProcedureTagv9 程序的篩選。

有成功嗎?

我們的最新追蹤顯示瓶頸已移除:追蹤列顯示 src/pages/index.ts 花費 136 毫秒進行類型檢查

大幅改善!類型檢查時間從 332 毫秒減少到 136 毫秒,令人驚艷!這在整體上看起來可能沒什麼,但卻是一大勝利。200 毫秒一次可能不多,但想想

  • 專案中還有多少其他 TS 函式庫
  • 今天有多少開發人員正在使用 tRPC
  • 他們在工作階段中重新評估其類型的次數

許多 200 毫秒加起來會變成一個非常大的數字。

我們一直在尋找更多機會來改善 TypeScript 開發人員的體驗,無論是使用 tRPC 還是另一個專案中需要解決的基於 TS 的問題。如果您想討論 TypeScript,請在 Twitter 上標記我。

感謝 Anthony Shew 協助撰寫這篇文章,以及 Alex 審閱!

· 閱讀 4 分鐘
Alex / KATT 🐱

tRPC 透過 TypeScript 的強大功能,強制執行嚴謹的完整堆疊類型繫結,提供絕佳的開發人員體驗。沒有 API 契約偏差,沒有程式碼產生。

自 2021 年 8 月發布我們的最後一個主要版本以來,tRPC 社群已大幅成長

今天,我們推出 tRPC v10。我們很興奮地分享 v10 已被許多大型 TypeScript 專案用於生產環境。此官方版本宣布對更廣大的社群提供一般可用性。

對於新專案,您可以使用 範例應用程式 來開始執行並進一步了解 tRPC v10。對於已經享受 tRPC v9 的專案,請參閱 v10 遷移指南

變更總覽

v10 是 tRPC 有史以來最大的版本。這是我們第一次對 tRPC 的結構進行任何基本變更,我們相信這些變更為致力於尖端應用程式的快節奏團隊開啟了新的可能性。

改善開發人員體驗

tRPC v10 擁抱您的 IDE。我們希望統一您的類型 - 但我們也在此版本中匯集了您的前端、後端和編輯體驗。

使用 v10,您可以

  • 使用「前往定義」直接從您的前端消費者跳轉至您的後端程序
  • 使用「重新命名符號」為您的整個應用程式中的輸入參數或程序提供新名稱
  • 更輕鬆地推斷類型,以便您在應用程式中手動使用 tRPC 類型

強大的後端架構

在 v10 中,我們重新檢視了定義後端程序的語法,提供了更多機會以健康的方式引入您想要的邏輯。此版本的 tRPC 特色包括

大幅提升的 TypeScript 效能

TypeScript 讓開發人員可以完成令人難以置信的事情 - 但這可能會付出代價。我們用來讓您的類型保持嚴謹的許多技術,會對 TypeScript 編譯器造成沉重的負擔。我們聽到了社群回饋,指出由於這種編譯器壓力,使用 tRPC v9 的大型應用程式開始在開發人員的 IDE 中出現效能下降的情況。

我們的目標是提升各種規模應用程式的開發人員體驗。在 v10 中,我們大幅提升了 TypeScript 效能(特別是 TS 增量編譯),讓您的編輯器保持靈敏。

增量遷移

我們也投入了大量心力,讓遷移體驗盡可能直接,包括一個 interop() 方法,允許(幾乎)完全向下相容於 v9 路由器。前往遷移指南以取得更多資訊。

核心團隊的Sachin也製作了一個 codemod,可以為您完成大部分遷移的繁重工作。

不斷成長的生態系統

一組豐富的子函式庫持續圍繞 tRPC 形成。以下是一些範例

如需更多外掛程式、範例和轉接器,請瀏覽 Awesome tRPC 彙整

謝謝!

核心團隊和我希望您知道:我們才剛開始。我們已經忙於嘗試React Server Components和 Next.js 13。

我也要對SachinJuliusJamesAhmedChrisTheoAnthony以及所有協助讓此版本發布的貢獻者表達最深的感謝。

感謝您使用和支持 tRPC。


· 閱讀時間 5 分鐘
Alex / KATT 🐱

我是 Alex,或 GitHub 上的「KATT」,我想告訴您一個名為tRPC的函式庫。我尚未發布任何相關文章,所以我只寫這篇簡介來開始進行(但在 GitHub 上,我們已經不知不覺獲得了 530 顆 🌟)。期待文章和影片簡介的到來!如果您想隨時掌握最新資訊或想發問,您可以在 Twitter 上追蹤我,帳號為@alexdotjs

簡而言之,tRPC 為您提供從(節點)伺服器到客戶端的端對端類型安全性,甚至不需要宣告類型。您在後端所做的,就是傳回函式中的資料,而在前端,您根據端點名稱使用所述資料。

執行 tRPC 端點和客戶端呼叫時,它可能會像這樣: 替代文字

我已經為 React 製作了一個函式庫 (@trpc/react),它建立在出色的 react-query 之上,但用戶端函式庫 (@trpc/client) 可以獨立於 React 運作 (如果你想建立一個特定的 Svelte/Vue/Angular/[..] 函式庫,請聯繫我們!)

不涉及任何程式碼產生,而且你可以很輕鬆地將它新增到現有的 Next.js/CRA/Express 專案。

範例

以下是一個名為 hello 的 tRPC 程序 (又稱端點) 範例,它接受一個 字串 參數。

tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;
tsx
const appRouter = trpc.router().query('hello', {
input: z.string().optional(),
resolve: ({ input }) => {
return {
text: `hello ${input ?? 'world'}`,
};
},
});
export type AppRouter = typeof appRouter;

以下是使用該資料的類型安全用戶端

tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `https://127.0.0.1:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();
tsx
import type { AppRouter } from './server';
async function main() {
const client = createTRPCClient<AppRouter>({
url: `https://127.0.0.1:2022`,
});
const result = await client.query('hello', '@alexdotjs');
console.log(result); // --> { text: "hello @alexdotjs" }
}
main();

你只需要這些就能獲得類型安全性! result 的類型會根據後端在函式中回傳的內容推論出來。輸入的資料也會根據驗證器的回傳結果推論出來,因此資料可以直接安全使用 - 事實上,你必須透過驗證器傳遞輸入資料 (而且 tRPC 可與 zod/yup/自訂驗證器搭配使用)。

以下是一個 CodeSandbox 連結,你可以在其中使用上述範例:https://githubbox.com/trpc/trpc/tree/next/examples/standalone-server(請查看終端機輸出,而不是預覽!)

什麼?我要從後端匯入程式碼到我的用戶端嗎? - 不,你實際上並不會

即使看起來像是這樣,但並未從伺服器共用任何程式碼到用戶端;TypeScript 的 import type "[..] 只會匯入用於類型註解和宣告的宣告。它總是會被完全清除,因此在執行階段不會留下任何殘餘。" - TypeScript 3.8 中新增的功能 - 請參閱 TypeScript 文件

不涉及任何程式碼產生,只要你有辦法從伺服器共用類型到用戶端,你就可以在今天將其新增到你的應用程式(希望你已經在使用單一儲存庫)。

但我們才剛開始!

我之前提到有一個 React 函式庫,你可以在 React 中使用上述資料的方式是

tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);
tsx
const { data } = trpc.useQuery(['hello', '@alexdotjs']);

.. 而且你將在用戶端取得類型安全資料。

你可以使用現有的 brownfield 專案新增 tRPC(有 Express/Next.js 的轉接器),而且它與 CRA 搭配使用效果良好,也應該適用於 React Native。它甚至沒有與 React 綁定,所以如果你想建立一個 Svelte 或 Vue 函式庫,請與我聯繫。

變異資料呢?

突變與查詢一樣容易執行,它們實際上在底層是一樣的,但只是以語法糖的形式以不同的方式公開,並產生 HTTP POST 而不是 GET 請求。

以下是使用資料庫的稍微複雜一點的範例,取自我們在 todomvc.trpc.io 上的 TodoMVC 範例 / https://github.com/trpc/trpc/tree/next/examples/next-prisma-todomvc

tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});
tsx
const todoRouter = createRouter().mutation('add', {
input: z.object({
id: z.string().uuid(),
data: z.object({
completed: z.boolean().optional(),
text: z.string().min(1).optional(),
}),
}),
async resolve({ ctx, input }) {
const { id, data } = input;
const todo = await ctx.task.update({
where: { id },
data,
});
return todo;
},
});

React 用法如下所示

tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)
tsx
const addTask = trpc.useMutation('todos.add');
return (
<>
<input
placeholder="What needs to be done?"
onKeyDown={(e) => {
const text = e.currentTarget.value.trim();
if (e.key === 'Enter' && text) {
addTask.mutate({ text });
e.currentTarget.value = '';
}
}}
/>
</>
)

結束,暫時。

無論如何,正如我所說,我只是想讓球滾動起來。還有很多事情

  • 為使用者特定資料的傳入請求建立內容,這些資料會相依性注入解析器中 - 連結
  • 路由器的中間層支援 - 連結
  • 合併路由器(你可能不希望所有後端資料都在一個檔案中) - 連結
  • 使用我們的 @trpc/next 適配器在 React 世界中看過的最簡單的伺服器端渲染 - 連結
  • 類型安全的錯誤格式化 - 連結
  • 資料轉換器(透過網路使用 Date/Map/Set 物件) - 連結
  • React Query 的輔助程式

如果你想開始,在 Next.js 入門 中有一些範例。

在 Twitter 上追蹤我以取得更新!