Ein Meilenstein für die TypeScript-Performance in TanStack Router

von Christopher Horobin am 17. September 2024. TanStack Router verschiebt die Grenzen für typsicheres Routing.

Die Komponenten des Routers wie <Link> und seine Hooks wie useSearch, useParams, useRouteContext und useLoaderData, leiten von den Routendefinitionen ab, um eine hervorragende Typsicherheit zu bieten. Es ist üblich, dass Anwendungen, die TanStack Router verwenden, externe Abhängigkeiten mit komplexen Typen für validateSearch, context, beforeLoad und loader in ihren Routendefinitionen verwenden.

Während die DX (Developer Experience) großartig ist, kann die Editor-Erfahrung langsam werden, wenn sich Routendefinitionen zu einem Routenbaum ansammeln und dieser groß wird. Wir haben viele Verbesserungen an der TypeScript-Performance von TanStack Router vorgenommen, sodass Probleme erst auftreten, wenn die Ableitungskomplexität sehr groß wird. Wir beobachten Diagnosen wie Instanziierungen genau und versuchen, die Zeit zu reduzieren, die TypeScript für die Typenprüfung jeder einzelnen Routendefinition benötigt.

Trotz all dieser bisherigen Bemühungen (die sicherlich geholfen haben), mussten wir das offensichtliche Problem angehen. Das grundlegende Problem, das für eine großartige Editor-Erfahrung in TanStack Router gelöst werden musste, bezog sich nicht unbedingt auf die gesamte TypeScript-Prüfzeit. Das Problem, das wir zu lösen versuchten, ist der Engpass im TypeScript-Sprachdienst, wenn es um die Typenprüfung des angesammelten Routenbaums geht. Für diejenigen, die mit dem Tracing von TypeScript vertraut sind, könnte ein Trace für eine große TanStack Router-Anwendung wie folgt aussehen:

Tracing showing the route tree being inferred

Für diejenigen, die es nicht wissen, können Sie einen Trace von TypeScript mit dem folgenden Befehl generieren:

tsc --generatetrace trace
tsc --generatetrace trace

Dieses Beispiel hat 400 Routendefinitionen, die alle validateSearch mit zod und TanStack Query Integration durch die Routen context und loader verwenden - es ist ein extremes Beispiel. Die große Wand am Anfang des Traces ist das, was TypeScript beim ersten Auftreffen auf eine Instanz der <Link>-Komponente überprüfte.

Der Sprachserver funktioniert, indem er eine Datei (oder einen Dateibereich) von Anfang an überprüft, aber nur für diese Datei/diesen Bereich. Das bedeutete, dass der Sprachdienst diese Arbeit jedes Mal ausführen musste, wenn Sie mit einer Instanz einer <Link>-Komponente interagierten. Es stellt sich heraus, dass dies der Engpass war, auf den wir beim Ableiten aller notwendigen Typen aus dem angesammelten Routenbaum stießen. Wie erwähnt, können Routendefinitionen selbst komplexe Typen von externen Validierungsbibliotheken enthalten, die dann ebenfalls abgeleitet werden müssen.

Es wurde frühzeitig offensichtlich, dass dies die Editor-Erfahrung deutlich verlangsamen würde.

Aufteilung der Arbeit für den Sprachdienst

Idealerweise müsste der Sprachdienst nur von einer Routendefinition ableiten, basierend darauf, wohin ein <Link> navigiert, anstatt den gesamten Routenbaum durchkriechen zu müssen. So müsste der Sprachdienst sich nicht mit dem Ableiten der Typen von Routendefinitionen beschäftigen, die nicht das Navigationsziel sind.

Leider sind Code-basierte Routenbaumstrukturen auf Ableitung angewiesen, um den Routenbaum zu erstellen, was die oben im Trace gezeigte Wand auslöst. TanStack Routers dateibasierte Routing hat jedoch den Routenbaum, der automatisch generiert wird, wenn eine Route erstellt oder geändert wird. Dies bedeutete, dass es einige Erkundungen zu tun gab, um zu sehen, ob wir etwas bessere Leistung erzielen konnten.

Zuvor wurden Routenbäume selbst für dateibasiertes Routing wie folgt erstellt:

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 }),
})

Die Generierung des Routenbaums war eine Folge der Reduzierung der mühsamen Konfiguration eines Routenbaums, wobei die Ableitung dort beibehalten wurde, wo sie wichtig ist. Hier wird die erste wichtige Änderung eingeführt, die zu einer besseren Editorleistung führt. Anstatt den Routenbaum abzuleiten, können wir diesen Generierungsschritt nutzen, um den Routenbaum zu *deklararieren*.

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)

Beachten Sie die Verwendung einer Interface zur Deklaration der Kindelemente, die den Routenbaum bilden. Dieser Vorgang wird für alle Routen und ihre Kindelemente bei der Generierung des Routenbaums wiederholt. Mit dieser Änderung gab uns die Ausführung eines Traces eine viel bessere Vorstellung davon, was im Sprachdienst geschah.

Tracing showing the route tree being declared

Dies ist immer noch langsam und wir sind noch nicht ganz da, aber es gibt etwas – *der Trace ist anders*. Die Typableitung für den gesamten Routenbaum fand immer noch statt, aber sie wurde jetzt *woanders* durchgeführt. Nach der Arbeit an unseren Typen stellte sich heraus, dass sie in einem Typ namens ParseRoute stattfand.

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

Dieser Typ durchläuft den Routenbaum, um eine Union aller Routen zu erstellen. Die Union wird wiederum verwendet, um eine Typzuordnung von id -> Route, from -> Route und auch to -> Route zu erstellen. Ein Beispiel für diese Zuordnung existiert als zugeordneter Typ.

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
}

Die wichtige Erkenntnis war hier, dass wir bei der Verwendung von dateibasiertem Routing den ParseRoute-Typ vollständig überspringen konnten, indem wir diese Zuordnungstypen selbst ausgaben, wann immer der Routenbaum generiert wurde. Stattdessen würden wir Folgendes generieren können:

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

Zusätzlich zur Deklaration von Kindelementen deklarieren wir auch Schnittstellen, die Pfade einer Route zuordnen.

Diese Änderung zusammen mit anderen typografischen Änderungen, um ParseRoute bedingt nur zu verwenden, wenn diese Typen nicht registriert sind, führte zu einem Trace, der unser Ziel von Anfang an war 🥳

Tracing route tree declaration being inferred faster

Die erste Datei, die auf eine <Link> verweist, löst keine Ableitung aus dem gesamten Routenbaum mehr aus, was die wahrgenommene Geschwindigkeit des Sprachdienstes erheblich erhöht.

Dadurch leitet TypeScript die für eine bestimmte Route erforderlichen Typen ab, wenn sie von einer <Link> referenziert wird. Dies führt möglicherweise nicht zu einer insgesamt besseren TypeScript-Typenprüfzeit, wenn alle Routen verlinkt sind, aber es ist eine deutliche Geschwindigkeitssteigerung für den Sprachdienst in einer Datei/einem Bereich.

Der Unterschied zwischen den beiden ist frappierend, wie diese großen Routenbäume mit komplexer Ableitung (400 in diesem Beispiel unten) zeigen.

Sie denken vielleicht, dass dies ein *Schummeln* ist, da wir hier in der Generierungsphase des Routenbaums viel Vorarbeit leisten. Unsere Antwort darauf ist, dass dieser Generierungsschritt für dateibasiertes Routing (und jetzt auch für virtuelles dateibasiertes Routing) bereits vorhanden war und immer ein notwendiger Schritt war, wann immer Sie eine Route modifiziert oder eine neue Route erstellt haben.

Sobald eine Route erstellt wurde und der Routenbaum generiert ist, bleibt die Ableitung für alles innerhalb der Routendefinition gleich. Das bedeutet, dass Sie Änderungen an validateSearch, beforeLoad, loader und anderen vornehmen können, wobei die abgeleiteten Typen immer sofort reflektiert werden.

Die DX hat sich nicht geändert, aber die Performance in Ihrem Editor fühlt sich großartig an (insbesondere wenn Sie mit großen Routenbäumen arbeiten).

Die Grundregeln

Diese Änderung erforderte eine Verbesserung vieler Exporte von TanStack Router, um die Nutzung dieser generierten Typen performanter zu gestalten und dennoch auf die Ableitung des gesamten Routenbaums bei der Verwendung von codebasiertem Routing zurückgreifen zu können. Wir haben auch noch Bereiche im Code, die weiterhin auf die vollständige Ableitung des Routenbaums angewiesen sind. Diese Bereiche sind unsere Version eines losen/nicht-strengen Modus.

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 })} />

Alle drei oben genannten Verwendungen von <Link> erfordern die Ableitung des gesamten Routenbaums und führen daher zu einer schlechteren Editor-Erfahrung, wenn Sie mit ihnen interagieren.

In den ersten beiden Fällen weiß TanStack Router nicht, zu welcher Route Sie navigieren möchten, daher versucht er sein Bestes, eine sehr lose Typableitung aus allen Routen in Ihrem Routenbaum zu erraten. Die dritte Verwendung von <Link> aus dem obigen Beispiel verwendet das prev-Argument in der search-Updater-Funktion, aber in diesem Fall weiß TanStack Router nicht, von welcher Route Sie navigieren, weshalb er erneut versuchen muss, den losen Typ von prev durch Scannen des gesamten Routenbaums zu erraten.

Die performanteste Verwendung von <Link> in Ihrem Editor wäre die folgende:

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 kann die Typen in diesen Fällen auf bestimmte Routen eingrenzen. Das bedeutet, dass Sie eine bessere Typsicherheit und eine bessere Editor-Performance erhalten, wenn Ihre Anwendung skaliert. Daher empfehlen wir die Verwendung von from und/oder to in diesen Fällen. Um es klarzustellen: Im dritten Beispiel ist die Verwendung von from nur dann notwendig, wenn das prev-Argument verwendet wird, andernfalls muss TanStack Router nicht den gesamten Routenbaum ableiten.

Diese lockereren Typen treten auch in strict: false-Modi auf.

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

In diesem Fall kann eine bessere Editor-Performance und Typsicherheit durch die Verwendung der empfohlenen from-Eigenschaft erzielt werden.

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' })

Fortschritt

Wir glauben, dass TanStack Router gut positioniert ist, um in Zukunft die beste Balance zwischen Typsicherheit und TypeScript-Performance zu bieten, ohne Kompromisse bei der Qualität der Typableitung in dateibasierten (und virtuellen dateibasierten) Routen eingehen zu müssen. Alles in Ihren Routendefinitionen wird weiterhin abgeleitet, wobei die Änderungen im generierten Routenbaum nur dem Sprachdienst helfen, indem die notwendigen Typen dort deklariert werden, wo sie wichtig sind – etwas, das Sie niemals selbst schreiben würden.

Dieser Ansatz scheint auch für den Sprachdienst skalierbar zu sein. Wir konnten Tausende von Routendefinitionen erstellen, wobei der Sprachdienst stabil blieb, vorausgesetzt, Sie halten sich an die strengen Teile von TanStack Router.

Wir werden die TypeScript-Performance von TanStack Router weiterhin verbessern, um die Gesamtprüfzeit zu reduzieren und die Leistung des Sprachdienstes weiter zu steigern, aber wir hielten es dennoch für einen wichtigen Meilenstein, den wir teilen wollten und von dem wir hoffen, dass die Benutzer von TanStack Router ihn zu schätzen wissen.