作为库作者,我们的目标是为同行提供最佳的开发体验 (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 的审阅!