自动

TanStack Router 的 TypeScript 性能里程碑

由 Christopher Horobin 于 2024 年 9 月 17 日发布。TanStack Router 在类型安全路由方面不断突破界限。

路由器的组件,如 <Link>,及其钩子,如 useSearchuseParamsuseRouteContextuseLoaderData,会根据路由定义进行推断,提供出色的类型安全性。TanStack Router 的应用程序在使用外部依赖项时,通常会为路由定义中的 validateSearchcontextbeforeLoadloader 提供复杂的类型。

虽然 DX(开发者体验)很棒,但当路由定义积累成一个路由树并且它变得很大时,编辑器的体验可能会开始显得缓慢。我们对 TanStack Router 进行了许多 TypeScript 性能改进,这样问题只会在推断的复杂性变得非常大时才开始出现。我们密切关注诸如实例化之类的诊断,并努力减少 TypeScript 检查每个单独路由定义所需的时间。

尽管付出了所有这些过去的努力(这些努力肯定有所帮助),但我们不得不正视这个问题。TanStack Router 中解决良好编辑器体验的基本问题并不一定与整体 TypeScript 检查时间有关。我们一直致力于解决的问题是 TypeScript 语言服务在类型检查累积路由树方面的瓶颈。对于熟悉 TypeScript 跟踪的人来说,大型 TanStack Router 应用程序的跟踪可能看起来与以下内容类似:

Tracing showing the route tree being inferred

对于那些不知道的人,您可以使用以下方法从 TypeScript 生成跟踪:

tsc --generatetrace trace
tsc --generatetrace trace

此示例有 400 个路由定义,所有定义都使用 zod 和 TanStack Query 集成通过路由 contextloader 进行 validateSearch - 这是一个极端示例。跟踪开头的大块是 TypeScript 在首次遇到 <Link> 组件实例时正在类型检查的内容。

语言服务器的工作方式是通过从头开始类型检查文件(或文件的一个区域),但仅针对该文件/区域。因此,这意味着每当您与 <Link> 组件的实例进行交互时,语言服务都必须执行此工作。事实证明,这正是我们在从累积的路由树推断所有必需类型时遇到的瓶颈。如前所述,路由定义本身可以包含来自外部验证库的复杂类型,这些类型也需要推断。

很早就发现这显然会减慢编辑器的体验。

为语言服务分解工作

理想情况下,语言服务应仅根据 <Link> 导航 to 的位置来推断路由定义,而不是必须遍历整个路由树。这样,语言服务就不需要忙于推断不是导航目标的路由定义的类型。

不幸的是,基于代码的路由树依赖于推断来构建路由树,这会触发上面跟踪中显示的瓶颈。但是,TanStack Router 的基于文件的路由,会在创建或修改路由时自动生成路由树。这意味着我们需要进行一些探索,看看是否可以榨取一些更好的性能。

以前,路由树的创建方式如下,即使是基于文件的路由

tsx
export const routeTree = rootRoute.addChildren({
  IndexRoute,
  LayoutRoute: LayoutRoute.addChildren({
    LayoutLayout2Route: LayoutLayout2Route.addChildren({
      LayoutLayout2LayoutARoute,
      LayoutLayout2LayoutBRoute,
    }),
  }),
  PostsRoute: PostsRoute.addChildren({ PostsPostIdRoute, PostsIndexRoute }),
})
export const routeTree = rootRoute.addChildren({
  IndexRoute,
  LayoutRoute: LayoutRoute.addChildren({
    LayoutLayout2Route: LayoutLayout2Route.addChildren({
      LayoutLayout2LayoutARoute,
      LayoutLayout2LayoutBRoute,
    }),
  }),
  PostsRoute: PostsRoute.addChildren({ PostsPostIdRoute, PostsIndexRoute }),
})

生成路由树是减少路由树繁琐配置的后果,同时在推断重要内容的地方保留推断。这就是引入第一个重要更改,从而提高编辑器性能的地方。而不是推断路由树,我们可以利用此生成步骤来声明路由树

tsx
export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  LayoutRoute: typeof LayoutRouteWithChildren
  PostsRoute: typeof PostsRouteWithChildren
}

const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  LayoutRoute: LayoutRouteWithChildren,
  PostsRoute: PostsRouteWithChildren,
}

export const routeTree = rootRoute._addFileChildren(rootRouteChildren)
export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  LayoutRoute: typeof LayoutRouteWithChildren
  PostsRoute: typeof PostsRouteWithChildren
}

const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  LayoutRoute: LayoutRouteWithChildren,
  PostsRoute: PostsRouteWithChildren,
}

export const routeTree = rootRoute._addFileChildren(rootRouteChildren)

注意使用 interface 来声明构成路由树的子项。在生成路由树时,此过程会为所有路由及其子项重复。通过此更改,运行跟踪可以让我们更好地了解语言服务内部发生的情况。

Tracing showing the route tree being declared

这仍然很慢,我们还没有完全达到目标,但有些东西——跟踪不同了。仍然会发生整个路由树的类型推断,但现在它是在其他地方进行的。在处理完我们的类型后,事实证明它发生在名为 ParseRoute 的类型中。

tsx
export type ParseRoute<TRouteTree, TAcc = TRouteTree> = TRouteTree extends {
  types: { children: infer TChildren }
}
  ? unknown extends TChildren
    ? TAcc
    : TChildren extends ReadonlyArray<any>
    ? ParseRoute<TChildren[number], TAcc | TChildren[number]>
    : ParseRoute<TChildren[keyof TChildren], TAcc | TChildren[keyof TChildren]>
  : TAcc
export type ParseRoute<TRouteTree, TAcc = TRouteTree> = TRouteTree extends {
  types: { children: infer TChildren }
}
  ? unknown extends TChildren
    ? TAcc
    : TChildren extends ReadonlyArray<any>
    ? ParseRoute<TChildren[number], TAcc | TChildren[number]>
    : ParseRoute<TChildren[keyof TChildren], TAcc | TChildren[keyof TChildren]>
  : TAcc

此类型会遍历路由树以创建所有路由的联合。然后,该联合用于创建从 idRoute、从 fromRoute 以及 toRoute 的类型映射。此映射的示例存在于映射类型中。

tsx
export type RoutesByPath<TRouteTree extends AnyRoute> = {
  [K in ParseRoute<TRouteTree> as K['fullPath']]: K
}
export type RoutesByPath<TRouteTree extends AnyRoute> = {
  [K in ParseRoute<TRouteTree> as K['fullPath']]: K
}

这里重要的认识是,在使用基于文件的路由时,我们可以通过在路由树生成时自行输出映射类型,从而完全跳过 ParseRoute 类型。取而代之的是,我们将能够生成以下内容:

tsx
export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/posts': typeof PostsRouteWithChildren
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts/': typeof PostsIndexRoute
  '/layout-a': typeof LayoutLayout2LayoutARoute
  '/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRoutesByTo {
  '/': typeof IndexRoute
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts': typeof PostsIndexRoute
  '/layout-a': typeof LayoutLayout2LayoutARoute
  '/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRoutesById {
  __root__: typeof rootRoute
  '/': typeof IndexRoute
  '/_layout': typeof LayoutRouteWithChildren
  '/posts': typeof PostsRouteWithChildren
  '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts/': typeof PostsIndexRoute
  '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
  '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRouteTypes {
  fileRoutesByFullPath: FileRoutesByFullPath
  fullPaths:
    | '/'
    | '/posts'
    | '/posts/$postId'
    | '/posts/'
    | '/layout-a'
    | '/layout-b'
  fileRoutesByTo: FileRoutesByTo
  to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b'
  id:
    | '__root__'
    | '/'
    | '/_layout'
    | '/posts'
    | '/_layout/_layout-2'
    | '/posts/$postId'
    | '/posts/'
    | '/_layout/_layout-2/layout-a'
    | '/_layout/_layout-2/layout-b'
  fileRoutesById: FileRoutesById
}

export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  LayoutRoute: typeof LayoutRouteWithChildren
  PostsRoute: typeof PostsRouteWithChildren
}

const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  LayoutRoute: LayoutRouteWithChildren,
  PostsRoute: PostsRouteWithChildren,
}

export const routeTree = rootRoute
  ._addFileChildren(rootRouteChildren)
  ._addFileTypes<FileRouteTypes>()
export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/posts': typeof PostsRouteWithChildren
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts/': typeof PostsIndexRoute
  '/layout-a': typeof LayoutLayout2LayoutARoute
  '/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRoutesByTo {
  '/': typeof IndexRoute
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts': typeof PostsIndexRoute
  '/layout-a': typeof LayoutLayout2LayoutARoute
  '/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRoutesById {
  __root__: typeof rootRoute
  '/': typeof IndexRoute
  '/_layout': typeof LayoutRouteWithChildren
  '/posts': typeof PostsRouteWithChildren
  '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
  '/posts/$postId': typeof PostsPostIdRoute
  '/posts/': typeof PostsIndexRoute
  '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
  '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
}

export interface FileRouteTypes {
  fileRoutesByFullPath: FileRoutesByFullPath
  fullPaths:
    | '/'
    | '/posts'
    | '/posts/$postId'
    | '/posts/'
    | '/layout-a'
    | '/layout-b'
  fileRoutesByTo: FileRoutesByTo
  to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b'
  id:
    | '__root__'
    | '/'
    | '/_layout'
    | '/posts'
    | '/_layout/_layout-2'
    | '/posts/$postId'
    | '/posts/'
    | '/_layout/_layout-2/layout-a'
    | '/_layout/_layout-2/layout-b'
  fileRoutesById: FileRoutesById
}

export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  LayoutRoute: typeof LayoutRouteWithChildren
  PostsRoute: typeof PostsRouteWithChildren
}

const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  LayoutRoute: LayoutRouteWithChildren,
  PostsRoute: PostsRouteWithChildren,
}

export const routeTree = rootRoute
  ._addFileChildren(rootRouteChildren)
  ._addFileTypes<FileRouteTypes>()

除了声明子项之外,我们还声明将路径映射到路由的接口。

此更改以及其他类型级别的更改,用于有条件地仅在未注册这些类型时使用 ParseRoute,从而实现了我们一直追求的目标 🥳

Tracing route tree declaration being inferred faster

第一个引用 <Link> 的文件不再触发整个路由树的推断,这显著提高了感知的语言服务速度。

通过这样做,TypeScript 将在 <Link> 引用特定路由时推断其所需的类型。当所有路由都被链接时,这可能不会转化为整体更好的 TypeScript 类型检查时间,但它对于语言服务在文件/区域中操作时是显着的提速。

两者之间的差异非常明显,如这些大型路由树和复杂推断(下面的示例中有 400 个)所示。

您可能会认为这是作弊,因为我们在路由树生成阶段做了很多繁重的工作。我们对此的回应是,基于文件的路由(现在是虚拟基于文件的路由)的此生成步骤已经存在,并且在修改或创建新路由时始终是必需的步骤。

因此,一旦创建了路由并生成了路由树,推断在路由定义中的所有内容都将保持不变。这意味着您可以更改 validateSearchbeforeLoadloader 等,推断的类型将始终即时反映。

DX 没有改变,但您的编辑器的性能感觉很棒(尤其是在处理大型路由树时)。

基本规则

此更改涉及 TanStack Router 的许多导出都得到了改进,以使其消耗这些生成类型更加高效,同时仍能在使用基于代码的路由时回退到整个路由树推断。我们代码库中仍有一些区域依赖于完整的路由树推断。这些区域是我们松散/非严格模式的等效项。

tsx
<Link to="." search={{ page: 0 }} />
<Link to=".." search={{page: 0}} />
<Link to="/dashboard" search={prev => ({..prev, page: 0 })} />
<Link to="." search={{ page: 0 }} />
<Link to=".." search={{page: 0}} />
<Link to="/dashboard" search={prev => ({..prev, page: 0 })} />

上面 <Link> 的所有三种用法都需要推断整个路由树,因此在与它们交互时会导致糟糕的编辑器体验。

在前两个实例中,TanStack Router 不知道您要导航到哪个路由,因此它会尽力猜测您的路由树中所有路由的松散类型。上面 <Link> 的第三个实例使用了 search 更新器函数中的 prev 参数,但在这种情况下,TanStack Router 不知道您正在从哪个 Route 导航,因此它需要再次尝试通过扫描整个路由树来猜测 prev 的松散类型。

在编辑器中最具性能的 <Link> 用法将是以下用法:

tsx
<Link from="/dashboard" search={{ page: 0 }} />
<Link from="/dashboard" to=".." search={{page: 0}} />
<Link from="/users" to="/dashboard" search={prev => ({...prev, page: 0 })} />
<Link from="/dashboard" search={{ page: 0 }} />
<Link from="/dashboard" to=".." search={{page: 0}} />
<Link from="/users" to="/dashboard" search={prev => ({...prev, page: 0 })} />

TanStack Router 可以在这些情况下将类型缩小到特定路由。因此,随着应用程序的扩展,您可以获得更好的类型安全和更好的编辑器性能。因此,我们鼓励在这些情况下使用 from 和/或 to。需要明确的是,在第三个示例中,仅当使用 prev 参数时,才需要使用 from,否则 TanStack Router 不需要推断整个路由树。

这些更宽松的类型也出现在 strict: false 模式中。

tsx
const search = useSearch({ strict: false })
const params = useParams({ strict: false })
const context = useRouteContext({ strict: false })
const loaderData = useLoaderData({ strict: false })
const match = useMatch({ strict: false })
const search = useSearch({ strict: false })
const params = useParams({ strict: false })
const context = useRouteContext({ strict: false })
const loaderData = useLoaderData({ strict: false })
const match = useMatch({ strict: false })

在这种情况下,通过使用推荐的 from 属性,可以实现更好的编辑器性能和类型安全。

tsx
const search = useSearch({ from: '/dashboard' })
const params = useParams({ from: '/dashboard' })
const context = useRouteContext({ from: '/dashboard' })
const loaderData = useLoaderData({ from: '/dashboard' })
const match = useMatch({ from: '/dashboard' })
const search = useSearch({ from: '/dashboard' })
const params = useParams({ from: '/dashboard' })
const context = useRouteContext({ from: '/dashboard' })
const loaderData = useLoaderData({ from: '/dashboard' })
const match = useMatch({ from: '/dashboard' })

展望未来

展望未来,我们相信 TanStack Router 已经处于非常有利的地位,可以在类型安全性和 TypeScript 性能之间取得最佳平衡,而无需在基于文件的(以及虚拟基于文件的)路由中使用的整个路由定义中妥协类型推断的质量。您的路由定义中的所有内容都将被推断,而生成路由树中的更改仅通过声明必需的类型来辅助语言服务,而这正是您自己永远不想编写的内容。

这种方法看起来也很适合语言服务的扩展。我们能够创建数千个路由定义,并且语言服务保持稳定,只要您坚持使用 TanStack Router 的 strict 部分。

我们将继续改进 TanStack Router 上的 TypeScript 性能,以减少整体检查时间并进一步提高语言服务性能,但我们仍然认为这是一个重要的里程碑,值得分享,并且是我们希望 TanStack Router 用户会欣赏的东西。