作者:Christopher Horobin,于 2024 年 9 月 17 日发布。
TanStack Router 突破了类型安全路由的界限。
路由器的组件(如 <Link>)及其 Hook(如 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 的 validateSearch,并通过路由 context 和 loader 集成 TanStack Query - 这是一个极端的例子。跟踪开始时的大墙是 TypeScript 在首次命中 <Link> 组件的实例时正在进行的类型检查。
语言服务器的工作方式是从头开始类型检查文件(或文件的某个区域),但仅针对该文件/区域。因此,这意味着每当您与 <Link> 组件的实例交互时,语言服务都必须执行此工作。事实证明,这就是我们在从累积的路由树中推断所有必要类型时遇到的瓶颈。如前所述,路由定义本身可以包含来自外部验证库的复杂类型,这些类型也需要推断。
早期很明显,这显然会减慢编辑器体验。
理想情况下,语言服务应该只需要根据 <Link> 导航到的位置从路由定义中推断,而不是必须爬取整个路由树。这样,语言服务就不需要忙于推断非导航目标的路由定义的类型。
不幸的是,基于代码的路由树依赖于推断来构建路由树,这会触发上面跟踪中显示的大墙。但是,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 的用户**会欣赏**的东西。