TanStack
自动

TanStack Router 中 TypeScript 性能的里程碑

作者:Christopher Horobin,于 2024 年 9 月 17 日发布。

TanStack Router 突破了类型安全路由的界限。

路由器的组件(如 <Link>)及其 Hook(如 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 个路由定义,全部使用 zodvalidateSearch,并通过路由 contextloader 集成 TanStack Query - 这是一个极端的例子。跟踪开始时的大墙是 TypeScript 在首次命中 <Link> 组件的实例时正在进行的类型检查。

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

早期很明显,这显然会减慢编辑器体验。

分解语言服务的工作

理想情况下,语言服务应该只需要根据 <Link> 导航的位置从路由定义中推断,而不是必须爬取整个路由树。这样,语言服务就不需要忙于推断非导航目标的路由定义的类型。

不幸的是,基于代码的路由树依赖于推断来构建路由树,这会触发上面跟踪中显示的大墙。但是,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

此类型向下遍历路由树以创建所有路由的联合。反过来,联合用于创建从 id -> Routefrom -> Route 以及 to -> Route 的类型映射。此映射的示例以映射类型形式存在。

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 个)

您可能会认为这是*作弊*,因为我们在这里做了很多**繁重的工作**,即在路由树生成阶段。我们对此的回应是,文件路由(以及现在的虚拟文件路由)的**生成步骤**已经存在,并且一直是**必要的步骤**,每当您修改或创建新路由时。

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

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 的用户**会欣赏**的东西。