框架
版本

关于开发者体验的决定

当人们第一次开始使用 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. 为什么优先推荐基于文件的路由而非基于代码的路由?:我们推荐基于文件的路由作为定义路由的首选方式。

TLDR; 使用 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

这个声明的东西对我来说太复杂了...

一旦您将路由构建成树并将其传递到您的 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 文档中注意到,我们推崇基于文件的路由作为定义路由的首选方法。这是因为我们发现基于文件的路由是定义路由最可扩展和可维护的方式。

提示

在继续之前,您必须对基于代码的路由基于文件的路由有充分的了解。

如开头所述,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 要求所有路由(包括根路由)都拼接成一个**_路由树_**,以便将其传递给 createRouter 函数,然后才能在模块上声明 Router 实例以进行类型推断。这是路由配置的另一个关键部分,如果操作不当,就会导致故障。

🤯 如果对于一个拥有约 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 Bundler 插件提供支持。它执行 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 Bundler 插件会为您处理所有这些。

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

查阅基于文件的路由代码分割指南,以更深入地了解它们在 TanStack Router 中的工作方式。

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

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

Bytes

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

订阅 Bytes

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

Bytes

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