框架
版本

开发者体验决策

当人们第一次开始使用 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 函数。

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

Tip

发现基于代码的路由有点太繁琐?看看��什么基于文件的路由是定义路由的首选方式。

2. 声明路由器实例以进行类型推断

为什么我必须声明 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/react-router' {
  interface Register {
    router: typeof router
  }
}
// src/app.tsx
declare module '@tanstack/react-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 文档中很快注意到的一点是,我们推崇基于文件的路由作为定义路由的首选方法。这是因为我们发现基于文件的路由是定义路由最具可扩展性和可维护性的方式。

Tip

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

如开头所述,TanStack Router 是为需要高度类型安全性和可维护性的复杂应用程序而设计的。为了实现这一点,路由器的配置以精确的方式完成,允许 TypeScript 尽可能推断路由的类型。

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

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

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

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

在这个阶段,这样做是为了让 postsIndexRoute 的定义能够知道它在路由树中的位置,以便它能够正确推断父路由返回的 contextpath paramssearch params 的类型。错误地定义 getParentRoute 函数意味着父路由的属性将不会被子路由正确推断。

因此,这是路由配置的关键部分,如果做得不正确就会成为故障点。

但这只是设置基本应用程序的一部分。TanStack Router 要求将所有路由(包括根路由)拼接成一个**路由树**,以便在模块上声明 Router 实例进行类型推断之前将其传递给 createRouter 函数。这是路由配置的另一个关键部分,如果做得不正确也会成为故障点。

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

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

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

最后,出现了代码分割的问题。随着应用程序的增长,您会希望对组件进行代码分割以减少应用程序的初始包大小。当您在单个文件中甚至跨多个文件定义路由时,这可能会有点令人头疼。

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

export const postsIndexRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '/',
  component: lazyRouteComponent(() => import('../page-components/posts/index')),
})
import { createRoute, lazyRouteComponent } from '@tanstack/react-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 打包器插件提供支持。它执行 3 个基本任务,解决使用基于代码的路由时路由配置中的痛点:

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

让我们看看使用基于文件的路由,前面示例的路由配置会是什么样子。

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

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

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

就是这样!无需担心定义 getParentRoute 函数、拼接路由树或对组件进行代码分割。TanStack Router 打包器插件为您处理所有这些。

TanStack Router 打包器插件在任何时候都不会夺走您对路由配置的控制权。它设计得尽可能灵活,允许您以适合应用程序的方式定义路由,同时减少路由配置的样板代码和复杂性。

查看基于文件的路由代码分割指南,了解它们在 TanStack Router 中如何工作的更深入解释。