メインコンテンツにスキップ

v10へのリファクタリングにおけるTypeScriptのパフォーマンスレッスン

·9分
Sachin Raja

ライブラリ作者としての私たちの目標は、仲間の開発者に可能な限り最高の開発体験(DX)を提供することです。エラー発生までの時間を短縮し、直感的なAPIを提供することで、開発者の精神的な負担を軽減し、最も重要なこと、つまり優れたエンドユーザーエクスペリエンスに集中できるようにします。


tRPCが素晴らしいDXを提供する方法の背後にある原動力がTypeScriptであることは周知の事実です。TypeScriptの採用は、今日、優れたJavaScriptベースのエクスペリエンスを提供するための現代の標準となっています。しかし、型に関するこの確実性の向上には、いくつかのトレードオフがあります。

今日、TypeScriptの型チェッカーは低速になる傾向があります(TS 4.9のようなリリースは有望ですが!)。ライブラリには、ほとんどの場合、コードベースの中で最も凝ったTypeScriptの記述が含まれており、TSコンパイラを限界まで押し上げています。このため、私たちのようなライブラリ作者は、その負担への貢献を意識し、IDEをできるだけ高速に動作させるために最善を尽くす必要があります。

ライブラリのパフォーマンスの自動化

tRPCがv9だった頃、大規模なtRPCルーターが型チェッカーに悪影響を及ぼし始めているという報告が開発者から寄せられるようになりました。これは、tRPCの開発のv9フェーズで大きな採用を目の当たりにしたtRPCにとって、新しい経験でした。より多くの開発者がtRPCを使用してより大規模な製品を作成するにつれて、いくつかの問題が現れ始めました。

あなたのライブラリは今は遅くないかもしれませんが、ライブラリが成長し変化するにつれてパフォーマンスに注意を払うことが重要です。自動テストは、各コミットでライブラリコードをプログラムでテストすることにより、ライブラリの作成(およびアプリケーションの構築!)から大きな負担を取り除くことができます。

tRPCの場合、私たちは生成テストによって、3,500のプロシージャと1,000のルーターを持つルーターを確保するために最善を尽くしています。しかし、これはTSコンパイラが壊れるまでどれくらいプッシュできるかをテストするだけで、型チェックにどれくらいの時間がかかるかはテストしません。ライブラリの3つの部分(サーバー、バニラクライアント、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. NODE_OPTIONS='--trace-deprecation --trace-sync-io' npx tsc --extendedDiagnostics

マシン上にtrace/trace.jsonファイルが作成されます。そのファイルは、トレース分析アプリ(私はPerfettoを使用しています)またはchrome://tracingで開くことができます。

ここで物事が面白くなり、アプリケーションの型のパフォーマンスプロファイルについて学び始めることができます。最初のトレースは次のようになりました:src/pages/index.tsの型チェックに332ミリ秒かかったことを示すトレースバー

バーが長いほど、そのプロセスに費やされた時間が長くなります。このスクリーンショットでは、一番上の緑色のバーを選択しました。これは、src/pages/index.tsがボトルネックであることを示しています。 Durationフィールドの下に、332ミリ秒かかったことがわかります。これは、型チェックに費やすには非常に長い時間です!青いcheckVariableDeclarationバーは、コンパイラがほとんどの時間を1つの変数に費やしたことを示しています。そのバーをクリックすると、それがどれであるかがわかります:変数の位置が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;

しかし、どうしてこうなるのでしょうか?単純なフックを使っているだけなのに!コードを見てみましょう

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;
}>;

わかりました、ここにはあまり見るべきものはありません。単一のuseContextとクエリの無効化のみが表示されます。表面上はTypeScriptに負荷がかかるはずがないものは何もありません。これは、問題がスタックの奥深くにある必要があることを示しています。この変数の背後にある型を見てみましょう

さて、ここで学ぶべきことがいくつかあります。まず、このコードが何をしているのかを理解しましょう。

再帰型DecoratedProcedureUtilsRecordがあり、ルーター内のすべての手続きをウォークスルーし、invalidateQueriesなどのReact Queryユーティリティで「装飾」(メソッドを追加)します。

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に表示されないようにする部分です。

しかし、これは大きなパフォーマンスの問題を引き起こします。neverであるかどうかを確認するために、TypeScriptにすべての型の値を評価するように強制しました。

どうすればこれを修正できますか?より少ないことを行うように型を変更しましょう。

遅延を取得する

v10 APIがレガシーv9ルーターにより適切に適応する方法を見つける必要があります。新しいtRPCプロジェクトは、相互運用モードのTypeScriptパフォーマンスの低下に悩まされるべきではありません。

アイデアは、コ chromatographyア型自体を再配置することです。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型を思い出してください。型レベルでv9v10のプロシージャを区別し、v9プロシージャがv10クライアントから呼び出されないようにするために、ここにLegacyV9ProcedureTagを添付したことがわかります。

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プロシージャが含まれます。巨大なDecoratedProcedureUtilsRecord型を完全に評価するようにTypeScriptに強制することはなくなりました。 LegacyV9ProcedureTagを使用してv9プロシージャのフィルタリングを削除することもできます。

うまくいきましたか?

新しいトレースは、ボトルネックが解消されたことを示しています:src/pages/index.tsの型チェックに136ミリ秒かかったことを示すトレースバー

  • 大幅な改善!型チェック時間が332ミリ秒から136ミリ秒になりました🤯!これは全体像ではそれほど多くないように見えるかもしれませんが、大きな勝利です。200ミリ秒は一度に少量ですが、次のことについて考えてみてください
  • 現在、tRPCを使用している開発者は何人いますか?
  • 作業セッション中に型の再評価が何回行われますか?

200ミリ秒の積み重ねは、非常に大きな数字になります。

私たちは、tRPCを使用する場合でも、他のプロジェクトで解決すべきTSベースの問題がある場合でも、TypeScript開発者のエクスペリエンスを向上させるための機会を常に探しています。TypeScriptについて話したい場合は、Twitterで私に連絡してください。

この記事の執筆にご協力いただいたAnthony Shew氏と、レビューをしていただいたAlex氏に感謝します。