跳至主内容

v10重构过程中的TypeScript性能优化经验

· 1 分钟阅读
Sachin Raja
Sachin Raja
tRPC Core Team Member (alumni)
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

作为库作者,我们的目标是为同行提供最佳的开发者体验(DX)。减少错误排查时间并提供直观的API,能够消除开发者的心智负担,让他们专注于最重要的目标:打造出色的终端用户体验。

众所周知,TypeScript是tRPC提供卓越开发者体验的核心驱动力。采用TypeScript已成为当今构建优质JavaScript体验的现代标准——但这种类型安全性提升也伴随着某些取舍。

当前TypeScript类型检查器确实存在性能瓶颈(尽管TS 4.9等版本展现出改进潜力!)。库中往往包含最复杂的TypeScript类型魔法,将TS编译器推向极限。正因如此,像我们这样的库作者必须谨慎控制对编译性能的影响,全力确保您的IDE保持流畅运行。

自动化库性能测试

在tRPC v9阶段,我们开始收到开发者反馈:大型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 文件可用 Perfettochrome://tracing 工具分析。

这里开始变得有趣——我们能观察到应用中类型的性能特征。首次追踪结果如下: 追踪条显示 src/pages/index.ts 类型检查耗时 332ms

长条表示该过程耗时较长。截图中选中的绿色长条表明 src/pages/index.ts 是瓶颈。Duration 字段显示耗时 332ms——对类型检查而言这是巨大的开销!蓝色 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 和查询失效操作,理论上不该产生沉重的类型开销,说明问题藏在更深层。让我们检查其类型定义:

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see 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,
> = {
/**
* @see 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 操作,并强制阻止 v10 客户端调用 v9 操作。

新类型实现:

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 类型检查耗时 136ms

显著提升!类型检查时间从 332ms 降至 136ms 🤯!单看数值可能不明显,但这是重大胜利。200ms 看似短暂,但请思考:

  • 项目中还存在多少其他 TS 库

  • 当前有多少开发者使用 tRPC

  • 单个工作会话中类型重新评估的次数

无数个 200ms 累加后将形成惊人数字。

我们持续探索优化 TypeScript 开发者体验的方案——无论是在 tRPC 还是其他 TS 项目中。若想探讨 TypeScript,欢迎在 Twitter @ 我。

感谢 Anthony Shew 协助撰写本文,并感谢 Alex 的审阅工作!