Christopher Horobin 写于 2024年9月17日. TanStack Router 推动了类型安全路由的边界。
路由器的组件(如 <Link>)及其钩子(如 useSearch、useParams、useRouteContext 和 useLoaderData)会从路由定义中推断类型,以提供出色的类型安全性。使用 TanStack Router 的应用程序通常会在其路由定义中使用具有复杂类型的外部依赖项,用于 validateSearch、context、beforeLoad 和 loader。
虽然开发者体验(DX)很棒,但当路由定义累积成一个庞大的路由树时,编辑器体验可能会开始变慢。我们对 TanStack Router 进行了许多 TypeScript 性能改进,因此只有当推断复杂度变得非常大时,问题才会开始出现。我们密切关注诸如实例化之类的诊断信息,并努力减少 TypeScript 对每个单独路由定义进行类型检查所需的时间。
尽管过去付出了所有这些努力(这无疑有所帮助),但我们不得不解决这个显而易见的问题。为 TanStack Router 提供出色编辑器体验需要解决的根本问题不一定与整体 TypeScript 检查时间有关。我们一直致力于解决的问题是 TypeScript 语言服务在对累积的路由树进行类型检查时的瓶颈。对于熟悉 TypeScript 追踪的人来说,一个大型 TanStack Router 应用程序的追踪可能类似于以下内容:

对于那些不知道的人,您可以使用以下命令从 TypeScript 生成追踪:
tsc --generatetrace trace
tsc --generatetrace trace
此示例有 400 个路由定义,所有定义都通过路由的 context 和 loader 使用 zod 和 TanStack Query 集成了 validateSearch——这是一个极端的示例。追踪开始处的那堵大墙是 TypeScript 在首次遇到 <Link> 组件实例时进行类型检查的内容。
语言服务的工作方式是从头开始对文件(或文件的某个区域)进行类型检查,但仅限于该文件/区域。因此,这意味着每当您与 <Link> 组件的实例交互时,语言服务都必须执行此工作。事实证明,这就是我们在从累积的路由树中推断所有必要类型时遇到的瓶颈。如前所述,路由定义本身可以包含来自外部验证库的复杂类型,这些类型��需要推断。
很早就很明显,这肯定会减慢编辑器体验。
理想情况下,语言服务应该只需要根据 <Link> 导航的 to 从路由定义中推断,而不必遍历整个路由树。这样,语言服务就不必忙于推断非导航目标的路由定义的类型。
不幸的是,基于代码的路由树依赖于推断来构建路由树,这会触发上面追踪中显示的那堵墙。然而,TanStack Router 的基于文件的路由,其路由树会在创建或修改路由时自动生成。这意味着这里有一些探索的空间,看看我们是否可以榨取一些更好的性能。
以前,即使对于基于文件的路由,路由树也是这样创建的:
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 }),
})
生成路由树是为了减少路由树繁琐的配置,同时在重要的地方保留推断。这就是引入第一个重要更改以提高编辑器性能的地方。我们可以利用这个生成步骤来声明路由树,而不是推断路由树。
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 来声明组成路由树的子级。在生成路由树时,会对所有路由及其子级重复此过程。通过此更改,运行追踪使我们更清楚地了解了语言服务内部发生的情况。

这仍然很慢,我们还没有完全达到目标,但有一些东西——追踪不同了。整个路由树的类型推断仍在发生,但现在它是在其他地方完成的。在检查了我们的类型之后,发现它发生在一个名为 ParseRoute 的类型中。
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 -> Route、from -> Route 以及 to -> Route 的类型映射。此映射的一个示例如下所示:
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 类型。取而代之的是,我们将能够生成以下内容:
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)产生的追踪正是我们一直追求的目标 🥳

第一个引用 <Link> 的文件不再触发整个路由树的推断,这显著提高了语言服务的感知速度。
通过这样做,TypeScript 将在 <Link> 引用特定路由时推断该路由所需的类型。当所有路由都被链接到时,这可能不会转化为整体更好的 TypeScript 类型检查时间,但对于文件/区域中的语言服务来说,这是一个显著的速度提升。
两者之间的差异是惊人的,正如在这些具有复杂推断的大型路由树中看到的那样(下面的示例中有 400 个):
您可能认为这是作弊,因为我们在路由树生成阶段做了很多繁重的工作。我们的回应是,基于文件(现在是虚拟的基于文件)的路由的这个生成步骤已经存在,并且在您修改或创建新路由时始终是必要步骤。
因此,一旦创建了路由并生成了路由树,整个路由定义中的推断将保持不变。这意味着您可以更改 validateSearch、beforeLoad、loader 等,推断的类型将始终即时反映出来。
开发者体验没有改变,但编辑器中的性能感觉非常棒(尤其是在处理大型路由树时)。
此更改涉及对 TanStack Router 的许多导出进行改进,以便在仍然能够在使用基于代码的路由时回退到整个路由树推断的情况下,更高效地使用这些生成的类型。我们的代码库中仍然有一些区域依赖于完整的路由树推断。这些区域是我们宽松/非严格模式的版本。
<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> 用法如下:
<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 模式中。
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 属性来实现更好的编辑器性能和类型安全性。
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 的用户会喜欢它。