框架
版本

开发者体验的决策

当人们首次开始使用 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. Router 的 TypeScript 模块声明?:您必须使用 TypeScript 的模块声明将 Router 实例传递到应用程序的其余部分。
  3. 为什么推动基于文件的路由而不是基于代码的路由?:我们推动基于文件的路由作为定义路由的首选方式。

TLDR;使用 TanStack Router 的开发者体验中的所有设计决策都是为了让您拥有最佳的类型安全体验,而不会影响路由配置的控制、灵活性和可维护性。

1. 为什么 Router 的配置是这样完成的?

当您想要充分利用 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 的定义可以知道其在路由树中的位置,以便它可以正确推断 context路径参数搜索参数的类型,这些类型由父路由返回。错误地定义 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 个路由执行此操作。现在请记住,您仍然没有接触路由器的 contextloaders搜索参数验证和其他功能 🤕。

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

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

基于文件的路由方法由 TanStack Router Bundler Plugin 提供支持。它执行 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 Plugin 为您处理所有这些。

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

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

订阅 Bytes

您每周的 JavaScript 新闻。每周一免费发送给超过 100,000 名开发人员。

Bytes

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