作為函式庫作者,我們的目標是為我們的同儕提供最佳的開發人員體驗 (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 的步驟
將程式庫連結至範例應用程式。這樣一來,您就可以變更程式庫程式碼,並立即在本地端測試變更。
在範例應用程式中執行此命令
shtsc --generateTrace ./trace --incremental falseshtsc --generateTrace ./trace --incremental false您會在電腦上取得一個
trace/trace.json
檔案。您可以在追蹤分析應用程式中開啟該檔案(我使用 Perfetto)或chrome://tracing
。
這時就會變得有趣,我們可以開始了解應用程式中類型的效能概況。以下是第一個追蹤的樣子:
長條圖越長,表示執行該程序所花費的時間越多。我已為此螢幕截圖選取最上方的綠色長條圖,表示 src/pages/index.ts
是瓶頸。在 Duration
欄位下方,您會看到它花了 332 毫秒,這表示花費大量時間進行類型檢查!藍色的 checkVariableDeclaration
長條圖告訴我們,編譯器將大部分時間花費在一個變數上。按一下該長條圖,我們會知道是哪一個變數:
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 routerexport 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 routerexport type MigrateV9Router<TV9Router extends V9Router> = V10Router<{[TKey in keyof TV9Router['procedures']]: MigrateProcedure<TV9Router['procedures'][TKey]> &LegacyV9ProcedureTag;}>;
如果您回想上述的 DecoratedProcedureUtilsRecord
類型,您會看到我們在此附加了 LegacyV9ProcedureTag
,以在類型層級區分 v9
和 v10
程序,並強制執行 v9
程序不會從 v10
客戶端呼叫。
新類型
ts
export type V10Router<TProcedureRecord> = {record: TProcedureRecord;// by default, no legacy procedureslegacy: {};};export type MigrateV9Router<TV9Router extends V9Router> = {// v9 routers inject their procedures into a `legacy` fieldlegacy: {// v9 clients require that we filter queries, mutations, subscriptions at the top-levelqueries: 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 procedureslegacy: {};};export type MigrateV9Router<TV9Router extends V9Router> = {// v9 routers inject their procedures into a `legacy` fieldlegacy: {// v9 clients require that we filter queries, mutations, subscriptions at the top-levelqueries: 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
類型。我們也可以移除使用 LegacyV9ProcedureTag
對 v9
程序的篩選。
有成功嗎?
我們的追蹤顯示瓶頸已移除:
大幅改善!類型檢查時間從 332 毫秒降至 136 毫秒 🤯!這在整體上看起來可能沒什麼,但卻是巨大的勝利。200 毫秒一次可能不多,但想想
- 專案中有多少其他 TS 函式庫
- 今天有多少開發人員使用 tRPC
- 他們在工作階段中重新評估類型多少次
許多 200 毫秒加起來會變成一個非常大的數字。
我們始終在尋找更多機會來改善 TypeScript 開發人員的體驗,無論是透過 tRPC 或是在其他專案中解決基於 TS 的問題。如果您想討論 TypeScript,請在 Twitter 上標記我。
感謝 Anthony Shew 協助撰寫這篇文章,以及 Alex 審閱!