由 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 个路由定义,所有定义都使用 zod 和 TanStack Query 集成通过路由 context 和 loader 进行 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 等,推断的类型将始终即时反映。
DX 没有改变,但您的编辑器的性能感觉很棒(尤其是在处理大型路由树时)。
此更改涉及 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 用户会欣赏的东西。