关于开发者体验的决策

当人们初次使用 TanStack Router 时,他们经常会提出围绕以下主题的许多问题:

为什么我必须这样做?

为什么这样做?而不是那样做?

我习惯这样做,为什么要改变?

这些都是有效的问题。在大多数情况下,人们习惯使用非常相似的路由库。它们都拥有相似的 API、相似的概念和相似的操作方式。

但 TanStack Router 不同。它不是一个普通的路由库。它也不是一个普通的无状态管理库。它什么都不是普通的。

TanStack Router 的起源故事

重要的是要记住,TanStack Router 的起源源于 Nozzle.io 对客户端路由解决方案的需求,该解决方案提供了业界一流的URL 查询参数体验,同时又不损害其复杂仪表板所需的类型安全

因此,从 TanStack Router 诞生之初,其设计的每一个方面都经过了精心考虑,以确保其类型安全和开发者体验无与伦比。

TanStack Router 是如何实现这一点的?

TypeScript!TypeScript!TypeScript!

TanStack Router 的每个方面都设计成尽可能类型安全,这是通过充分利用 TypeScript 的类型系统来实现的。这包括使用一些非常高级和复杂的类型、类型推断以及其他功能,以确保开发者体验尽可能顺畅。

但是,为了实现这一点,我们必须做出一些与路由领域规范不同的决策。

  1. 路由配置样板代码?:您必须以允许 TypeScript 尽可能多地推断路由类型的方式定义您的路由。
  2. 用于类型推断的 TypeScript 模块声明?:您必须通过 TypeScript 的模块声明将 Router 实例传递到您的应用程序的其余部分。
  3. 为什么倾向于使用基于文件的路由而不是基于代码的路由?:我们倾向于使用基于文件的路由作为定义路由的首选方式。

总结;在 TanStack Router 的开发者体验方面,所有设计决策都是为了让您获得一流的类型安全体验,而不会牺牲路由配置的控制、灵活性和可维护性。

1. 为什么路由器的配置是这样做的?

当您想充分利用 TypeScript 的推断功能时,您会很快意识到泛型是您最好的朋友。因此,TanStack Router 在各处都使用泛型来确保尽可能多地推断您的路由类型。

这意味着您必须以允许 TypeScript 尽可能多地推断路由类型的方式定义您的路由。

我可以使用 JSX 定义路由吗?

使用 JSX 定义路由是不可行的,因为 TypeScript 将无法推断路由器的路由配置类型。

tsx
// ⛔️ This is not possible
function App() {
  return (
    <Router>
      <Route path="/posts" component={PostsPage} />
      <Route path="/posts/$postId" component={PostIdPage} />
      {/* ... */}
    </Router>
    // ^? TypeScript cannot infer the routes in this configuration
  )
}
// ⛔️ This is not possible
function App() {
  return (
    <Router>
      <Route path="/posts" component={PostsPage} />
      <Route path="/posts/$postId" component={PostIdPage} />
      {/* ... */}
    </Router>
    // ^? TypeScript cannot infer the routes in this configuration
  )
}

而且,这意味着您必须手动为 <Link> 组件的 to 属性键入,并且直到运行时才能捕获任何错误,因此这不是一个可行的选项。

也许我可以将我的路由定义为嵌套对象的树?

tsx
// ⛔️ This file will just keep growing and growing...
const router = createRouter({
  routes: {
    posts: {
      component: PostsPage, // /posts
      children: {
        $postId: {
          component: PostIdPage, // /posts/$postId
        },
      },
    },
    // ...
  },
})
// ⛔️ This file will just keep growing and growing...
const router = createRouter({
  routes: {
    posts: {
      component: PostsPage, // /posts
      children: {
        $postId: {
          component: PostIdPage, // /posts/$postId
        },
      },
    },
    // ...
  },
})

乍一看,这似乎是个好主意。您可以一目了然地轻松可视化整个路由层级。但这种方法有几个很大的缺点,使其不适合大型应用程序。

  • 它扩展性不强:随着应用程序的增长,树会不断增长,管理起来会变得更加困难。而且由于它都定义在一个文件中,因此维护起来会非常困难。
  • 它不适合代码拆分:您必须手动拆分每个组件,然后将其传递到路由的 component 属性中,进一步使路由配置复杂化,并且路由配置文件会不断增长。

随着您开始使用路由器的更多功能,例如嵌套上下文、加载器、搜索参数验证等,这种情况只会变得更糟。

那么,定义路由的最佳方式是什么?

我们发现的最佳定义路由的方式是将路由配置的定义抽象到路由树之外。然后将您的路由配置组合成一个单一的、连贯的路由树,该路由树随后被传递到 createRouter 函数。

您可以阅读更多关于 基于代码的路由 的信息,了解如何以这种方式定义您的路由。

提示

觉得基于代码的路由有点太麻烦了?了解为什么 基于文件的路由 是定义路由的首选方式。

2. 声明 Router 实例以进行类型推断

为什么我必须声明 Router

这些声明的东西对我来说太复杂了……

一旦您将路由构建成一棵树,并将其(使用 createRouter)与所有泛型正确工作后传递到路由器实例,您就需要以某种方式将此信息传递到您的应用程序的其余部分。

我们考虑了两种方法:

  1. 导入:您可以从创建 Router 实例的文件中导入它,并在组件中直接使用它。
tsx
import { router } from '@/src/app'
export const PostsIdLink = () => {
  return (
    <Link<typeof router> to="/posts/$postId" params={{ postId: '123' }}>
      Go to post 123
    </Link>
  )
}
import { router } from '@/src/app'
export const PostsIdLink = () => {
  return (
    <Link<typeof router> to="/posts/$postId" params={{ postId: '123' }}>
      Go to post 123
    </Link>
  )
}

这种方法的缺点是,您必须将整个 Router 实例导入到您要使用它的每个文件中。这可能导致捆绑包大小增加,并且管理起来很麻烦,随着应用程序的增长以及您使用路由器更多功能的需要,情况只会变得更糟。

  1. 模块声明:您可以使用 TypeScript 的模块声明将 Router 实例声明为一个模块,该模块可用于在应用程序的任何地方进行类型推断,而无需导入它。

您将在应用程序中执行一次此操作。

tsx
// src/app.tsx
declare module '@tanstack/solid-router' {
  interface Register {
    router: typeof router
  }
}
// src/app.tsx
declare module '@tanstack/solid-router' {
  interface Register {
    router: typeof router
  }
}

然后,您就可以在应用程序的任何地方受益于它的自动完成功能,而无需导入它。

tsx
export const PostsIdLink = () => {
  return (
    <Link
      to="/posts/$postId"
      // ^? TypeScript will auto-complete this for you
      params={{ postId: '123' }} // and this too!
    >
      Go to post 123
    </Link>
  )
}
export const PostsIdLink = () => {
  return (
    <Link
      to="/posts/$postId"
      // ^? TypeScript will auto-complete this for you
      params={{ postId: '123' }} // and this too!
    >
      Go to post 123
    </Link>
  )
}

我们选择了模块声明,因为它被认为是最具可扩展性和可维护性的方法,具有最少的开销和样板代码。

3. 为什么基于文件的路由是定义路由的首选方式?

为什么文档推荐基于文件的路由?

我习惯在单个文件中定义我的路由,为什么要改变?

您很快会在 TanStack Router 文档中注意到,我们之所以推荐基于文件的路由作为定义路由的首选方法,是因为我们发现它是定义路由的最具可扩展性和可维护性的方法。

提示

在继续之前,重要的是您要对 基于代码的路由基于文件的路由 有一个很好的理解。

正如开头提到的,TanStack Router 是为需要高度类型安全和可维护性的复杂应用程序设计的。为了实现这一点,路由器的配置方式非常精确,可以尽可能多地让 TypeScript 推断您的路由类型。

使用 TanStack Router 设置基本应用程序的一个关键区别是,您的路由配置需要为 getParentRoute 提供一个函数,该函数返回当前路由的父路由。

tsx
import { createRoute } from '@tanstack/solid-router'
import { postsRoute } from './postsRoute'

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '/',
})
import { createRoute } from '@tanstack/solid-router'
import { postsRoute } from './postsRoute'

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '/',
})

在此阶段,这样做是为了使 postsIndexRoute 的定义能够了解其在路由树中的位置,并且能够正确推断父路由返回的 contextpath paramssearch params 的类型。错误地定义 getParentRoute 函数意味着子路由将无法正确推断父路由的属性。

因此,这是路由配置的关键部分,如果未正确完成,则是一个故障点。

但这只是设置基本应用程序的一部分。TanStack Router 要求所有路由(包括根路由)都组合成一个路由树,以便将其传递到 createRouter 函数,然后在模块上声明 Router 实例以进行类型推断。这是路由配置的另一个关键部分,如果未正确完成,则是一个故障点。

🤯 如果对于一个拥有约 40-50 条路由的应用程序,这个路由树放在它自己的文件中,它很容易增长到 700 多行。

tsx
const routeTree = rootRoute.addChildren([
  postsRoute.addChildren([postsIndexRoute, postsIdRoute]),
])
const routeTree = rootRoute.addChildren([
  postsRoute.addChildren([postsIndexRoute, postsIdRoute]),
])

当您开始使用路由器的更多功能时,例如嵌套上下文、加载器、搜索参数验证等,这种复杂性只会增加。因此,在一个文件中定义路由不再可行。所以,用户最终会构建自己的半一致性方式来在多个文件中定义他们的路由。这可能导致路由配置中的不一致和错误。

最后, comes the issue of code-splitting。随着应用程序的增长,您会想要代码拆分您的组件以减小应用程序的初始捆绑包大小。当您在一个文件甚至多个文件中定义路由时,这可能是一件令人头疼的事情。

tsx
import { createRoute, lazyRouteComponent } from '@tanstack/solid-router'
import { postsRoute } from './postsRoute'

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '/',
  component: lazyRouteComponent(() => import('../page-components/posts/index')),
})
import { createRoute, lazyRouteComponent } from '@tanstack/solid-router'
import { postsRoute } from './postsRoute'

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '/',
  component: lazyRouteComponent(() => import('../page-components/posts/index')),
})

所有这些样板代码,无论它对提供一流的类型推断体验多么重要,都可能有点压倒性,并可能导致路由配置中的不一致和错误。

……而这个示例配置仅用于渲染一个代码拆分路由。想象一下为 40-50 条路由执行此操作。现在请记住,您还没有触及路由器的 contextloaderssearch param validation 以及其他功能 🤕。

那么,为什么基于文件的路由是首选方式?

TanStack Router 的基于文件的路由旨在解决所有这些问题。它允许您以一种可预测的方式定义路由,易于管理和维护,并且随着应用程序的增长而具有可扩展性。

基于文件的路由方法由 TanStack Router Bundler Plugin 提供支持。它执行 3 项基本任务,解决了使用基于代码的路由进行路由配置时的痛点:

  1. 路由配置样板代码:它生成路由配置的样板代码。
  2. 路由树拼接:它将您的路由配置拼接成一个单一的、连贯的路由树。同时,它会在后台正确更新路由配置,以定义 getParentRoute 函数以匹配路由及其父路由。
  3. 代码拆分:它会自动代码拆分您的路由内容组件,并使用正确的组件更新路由配置。此外,在运行时,它确保在访问路由时加载正确的组件。

让我们看一下使用基于文件的路由,上一个示例的路由配置将是什么样子。

tsx
// src/routes/posts/index.ts
import { createFileRoute } from '@tanstack/solid-router'

export const Route = createFileRoute('/posts/')({
  component: () => 'Posts index component goes here!!!',
})
// src/routes/posts/index.ts
import { createFileRoute } from '@tanstack/solid-router'

export const Route = createFileRoute('/posts/')({
  component: () => 'Posts index component goes here!!!',
})

就是这样!无需担心定义 getParentRoute 函数、拼接路由树或代码拆分您的组件。TanStack Router Bundler Plugin 会为您处理这一切。

TanStack Router Bundler Plugin 绝不会剥夺您对路由配置的控制权。它旨在尽可能灵活,允许您以适合您应用程序的方式定义路由,同时减少路由配置的样板代码和复杂性。

请查看 基于文件的路由代码拆分 的指南,以更深入地了解它们在 TanStack Router 中的工作原理。

我们的合作伙伴
Code Rabbit
Netlify
Neon
Clerk
Convex
Sentry
订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。

订阅 Bytes

您的每周 JavaScript 资讯。每周一免费发送给超过 10 万开发者。

Bytes

无垃圾邮件。您可以随时取消订阅。