类型安全

TanStack Router 的构建目标是在 TypeScript 编译器和运行时的限制范围内尽可能地实现类型安全。这意味着它不仅用 TypeScript 编写,而且还**完全推断提供的类型,并顽固地将它们贯穿整个路由体验**。

最终,这意味着作为开发者的您需要编写**更少的类型**,并且在代码演进过程中拥有**更高的信心**。

路由定义

基于文件的路由

路由是分层的,它们的定义也是如此。如果您使用基于文件的路由,很多类型安全已经为您处理好了。

基于代码的路由

如果您直接使用 Route 类,您需要了解如何使用 RoutegetParentRoute 选项来确保您的路由得到正确的类型。这是因为子路由需要了解**所有**其父路由的类型。没有这一点,那些从 3 层以上的布局无路径布局路由中解析出来的宝贵的搜索参数,就会丢失在 JavaScript 的虚空中。

所以,请不要忘记将父路由传递给子路由!

tsx
const parentRoute = createRoute({
  getParentRoute: () => parentRoute,
})
const parentRoute = createRoute({
  getParentRoute: () => parentRoute,
})

导出的钩子、组件和实用程序

为了让您的路由的类型能够与顶级导出(如 LinkuseNavigateuseParams 等)协同工作,它们必须穿透 TypeScript 模块边界并直接注册到库中。为此,我们对导出的 Register 接口使用了声明合并。

ts
const router = createRouter({
  // ...
})

declare module '@tanstack/solid-router' {
  interface Register {
    router: typeof router
  }
}
const router = createRouter({
  // ...
})

declare module '@tanstack/solid-router' {
  interface Register {
    router: typeof router
  }
}

通过将您的路由注册到模块中,您现在就可以使用导出的钩子、组件和实用程序,并精确匹配您路由的类型了。

解决组件上下文问题

组件上下文是 React 及其他框架中用于提供组件依赖项的绝佳工具。然而,如果该上下文在组件层级结构中移动时类型发生变化,TypeScript 就无法推断这些变化。要解决此问题,基于上下文的钩子和组件要求您提供关于它们如何以及在哪里使用的线索。

tsx
export const Route = createFileRoute('/posts')({
  component: PostsComponent,
})

function PostsComponent() {
  // Each route has type-safe versions of most of the built-in hooks from TanStack Router
  const params = Route.useParams()
  const search = Route.useSearch()

  // Some hooks require context from the *entire* router, not just the current route. To achieve type-safety here,
  // we must pass the `from` param to tell the hook our relative position in the route hierarchy.
  const navigate = useNavigate({ from: Route.fullPath })
  // ... etc
}
export const Route = createFileRoute('/posts')({
  component: PostsComponent,
})

function PostsComponent() {
  // Each route has type-safe versions of most of the built-in hooks from TanStack Router
  const params = Route.useParams()
  const search = Route.useSearch()

  // Some hooks require context from the *entire* router, not just the current route. To achieve type-safety here,
  // we must pass the `from` param to tell the hook our relative position in the route hierarchy.
  const navigate = useNavigate({ from: Route.fullPath })
  // ... etc
}

每个需要上下文提示的钩子和组件都会有一个 from 参数,您可以在其中传递正在渲染的路由的 ID 或路径。

🧠 快速提示:如果您的组件是代码分割的,您可以使用 getRouteApi 函数,这样就无需传递 Route.fullPath 即可访问类型化的 useParams()useSearch() 钩子。

如果我不知道路由怎么办?如果它是共享组件怎么办?

from 属性是可选的,这意味着如果您不传递它,您将得到路由对可用类型的最佳猜测。通常这意味着您将获得路由器中所有路由类型的并集。

如果我传递了错误的 from 路径怎么办?

技术上可以传递一个满足 TypeScript 但可能与运行时实际渲染的路由不匹配的 from。在这种情况下,每个支持 from 的钩子和组件都会检测您的期望是否与实际渲染的路由不匹配,并抛出运行时错误。

如果我不知道路由,或者它是共享组件,并且我无法传递 from 怎么办?

如果您正在渲染一个跨多个路由共享的组件,或者您正在渲染一个不在路由内的组件,您可以传递 strict: false 而不是 from 选项。这不仅会消除运行时错误,还会为您正在调用的钩子提供宽松但准确的类型。一个很好的例子是从共享组件调用 useSearch

tsx
function MyComponent() {
  const search = useSearch({ strict: false })
}
function MyComponent() {
  const search = useSearch({ strict: false })
}

在这种情况下,search 变量将被类型化为路由器中所有路由的所有可能搜索参数的并集。

路由器上下文

路由上下文非常有用,因为它是最终的层级依赖注入。您可以为路由器和它渲染的每个路由提供上下文。当您构建这个上下文时,TanStack Router 会将其与路由层级结构合并,以便每个路由都可以访问其所有父路由的上下文。

createRootRouteWithContext 工厂会创建一个具有实例化类型的新路由器,然后要求您满足相同的类型契约,并确保您的上下文在整个路由树中得到正确的类型化。

tsx
const rootRoute = createRootRouteWithContext<{ whateverYouWant: true }>()({
  component: App,
})

const routeTree = rootRoute.addChildren([
  // ... all child routes will have access to `whateverYouWant` in their context
])

const router = createRouter({
  routeTree,
  context: {
    // This will be required to be passed now
    whateverYouWant: true,
  },
})
const rootRoute = createRootRouteWithContext<{ whateverYouWant: true }>()({
  component: App,
})

const routeTree = rootRoute.addChildren([
  // ... all child routes will have access to `whateverYouWant` in their context
])

const router = createRouter({
  routeTree,
  context: {
    // This will be required to be passed now
    whateverYouWant: true,
  },
})

性能建议

随着您的应用程序规模的增长,TypeScript 的检查时间自然会增加。当您的应用程序规模扩大时,有几点需要注意,以保持 TS 检查时间较低。

仅推断您需要的类型

与客户端数据缓存(TanStack Query 等)的一个很好的模式是预取数据。例如,使用 TanStack Query,您可能会有一个路由在 loader 中调用 queryClient.ensureQueryData

tsx
export const Route = createFileRoute('/posts/$postId/deep')({
  loader: ({ context: { queryClient }, params: { postId } }) =>
    queryClient.ensureQueryData(postQueryOptions(postId)),
  component: PostDeepComponent,
})

function PostDeepComponent() {
  const params = Route.useParams()
  const data = useSuspenseQuery(postQueryOptions(params.postId))

  return <></>
}
export const Route = createFileRoute('/posts/$postId/deep')({
  loader: ({ context: { queryClient }, params: { postId } }) =>
    queryClient.ensureQueryData(postQueryOptions(postId)),
  component: PostDeepComponent,
})

function PostDeepComponent() {
  const params = Route.useParams()
  const data = useSuspenseQuery(postQueryOptions(params.postId))

  return <></>
}

这看起来可能没问题,对于小型路由树,您可能不会注意到任何 TS 性能问题。然而,在这种情况下,TS 必须推断 loader 的返回类型,尽管它从未在您的路由中使用过。如果 loader 数据是一个复杂类型,并且有许多路由以这种方式进行预取,那么它会减慢编辑器性能。在这种情况下,更改非常简单,让 TypeScript 推断 Promise。.

tsx
export const Route = createFileRoute('/posts/$postId/deep')({
  loader: async ({ context: { queryClient }, params: { postId } }) => {
    await queryClient.ensureQueryData(postQueryOptions(postId))
  },
  component: PostDeepComponent,
})

function PostDeepComponent() {
  const params = Route.useParams()
  const data = useSuspenseQuery(postQueryOptions(params.postId))

  return <></>
}
export const Route = createFileRoute('/posts/$postId/deep')({
  loader: async ({ context: { queryClient }, params: { postId } }) => {
    await queryClient.ensureQueryData(postQueryOptions(postId))
  },
  component: PostDeepComponent,
})

function PostDeepComponent() {
  const params = Route.useParams()
  const data = useSuspenseQuery(postQueryOptions(params.postId))

  return <></>
}

这样,loader 数据就不会被推断,它将推断从路由树中移出,直到您第一次使用 useSuspenseQuery

尽可能将范围缩小到相关路由

考虑以下 Link 的用法

tsx
<Link to=".." search={{ page: 0 }} />
<Link to="." search={{ page: 0 }} />
<Link to=".." search={{ page: 0 }} />
<Link to="." search={{ page: 0 }} />

这些示例对 TS 性能非常不利。这是因为 search 解析为所有路由的所有 search 参数的并集,而 TS 必须检查您传递给 search prop 的任何内容是否与这个潜在的大并集匹配。随着应用程序的增长,这个检查时间将随着路由和搜索参数的数量呈线性增长。我们已经尽力优化了这种情况(TypeScript 通常会执行此工作一次并缓存它),但与这个大并集进行初始检查是昂贵的。这也适用于 params 和其他 API,如 useSearchuseParamsuseNavigate 等。

相反,您应该尝试使用 fromto 将范围缩小到相关的路由。

tsx
<Link from={Route.fullPath} to=".." search={{page: 0}} />
<Link from="/posts" to=".." search={{page: 0}} />
<Link from={Route.fullPath} to=".." search={{page: 0}} />
<Link from="/posts" to=".." search={{page: 0}} />

请记住,您可以随时将并集传递给 tofrom 来缩小您感兴趣的路由范围。

tsx
const from: '/posts/$postId/deep' | '/posts/' = '/posts/'
<Link from={from} to='..' />
const from: '/posts/$postId/deep' | '/posts/' = '/posts/'
<Link from={from} to='..' />

您还可以将分支传递给 from,以仅从该分支的任何后代解析 searchparams

tsx
const from = '/posts'
<Link from={from} to='..' />
const from = '/posts'
<Link from={from} to='..' />

/posts 可以是一个分支,它有许多共享相同 searchparams 的后代。

考虑使用 addChildren 的对象语法

路由通常有 paramssearchloaderscontext,这些甚至可以引用外部依赖项,而这些外部依赖项对 TS 推断也很重。对于这类应用程序,使用对象来创建路由树可能比元组更具性能。

createChildren 也可以接受一个对象。对于具有复杂路由和外部库的大型路由树,对象比大型元组对 TS 进行类型检查要快得多。性能提升取决于您的项目、您拥有的外部依赖项以及那些库的类型是如何编写的。

tsx
const routeTree = rootRoute.addChildren({
  postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
  indexRoute,
})
const routeTree = rootRoute.addChildren({
  postsRoute: postsRoute.addChildren({ postRoute, postsIndexRoute }),
  indexRoute,
})

请注意,此语法更冗长,但 TS 性能更好。使用基于文件的路由时,路由树是为您生成的,因此冗长的路由树不是问题。

避免在未缩小范围的情况下使用内部类型

您可能想重用导出的类型,这很常见。例如,您可能会尝试如下使用 LinkProps

tsx
const props: LinkProps = {
  to: '/posts/',
}

return (
  <Link {...props}>
)
const props: LinkProps = {
  to: '/posts/',
}

return (
  <Link {...props}>
)

这对 TS 性能非常不利。问题在于 LinkProps 没有类型参数,因此它是一个非常大的类型。它包含 search(它是所有 search 参数的并集),它包含 params(它是所有 params 的并集)。在将此对象与 Link 合并时,它将对这个巨大的类型进行结构比较。

相反,您可以使用 as const satisfies 来推断一个精确的类型,而不是直接使用 LinkProps,以避免巨大的检查。

tsx
const props = {
  to: '/posts/',
} as const satisfies LinkProps

return (
  <Link {...props}>
)
const props = {
  to: '/posts/',
} as const satisfies LinkProps

return (
  <Link {...props}>
)

由于 props 的类型不是 LinkProps,因此此检查成本较低,因为类型更加精确。您还可以通过缩小 LinkProps 的范围来进一步提高类型检查。

tsx
const props = {
  to: '/posts/',
} as const satisfies LinkProps<RegisteredRouter, string '/posts/'>

return (
  <Link {...props}>
)
const props = {
  to: '/posts/',
} as const satisfies LinkProps<RegisteredRouter, string '/posts/'>

return (
  <Link {...props}>
)

这比在组件中直接使用 LinkProps 更快,因为我们正在检查范围缩小的 LinkProps 类型。

您还可以使用它来缩小 LinkProps 的类型到一个特定类型,以便将其用作属性或函数的参数。

tsx
export const myLinkProps = [
  {
    to: '/posts',
  },
  {
    to: '/posts/$postId',
    params: { postId: 'postId' },
  },
] as const satisfies ReadonlyArray<LinkProps>

export type MyLinkProps = (typeof myLinkProps)[number]

const MyComponent = (props: { linkProps: MyLinkProps }) => {
  return <Link {...props.linkProps} />
}
export const myLinkProps = [
  {
    to: '/posts',
  },
  {
    to: '/posts/$postId',
    params: { postId: 'postId' },
  },
] as const satisfies ReadonlyArray<LinkProps>

export type MyLinkProps = (typeof myLinkProps)[number]

const MyComponent = (props: { linkProps: MyLinkProps }) => {
  return <Link {...props.linkProps} />
}

这比在组件中使用 LinkProps 直接使用要快,因为 MyLinkProps 是一个更精确的类型。

另一个解决方案是不使用 LinkProps,而是提供一种控制反转方式来渲染一个范围缩小到特定路由的 Link 组件。渲染 props 是将控制反转给组件用户的绝佳方法。

tsx
export interface MyComponentProps {
  readonly renderLink: () => React.ReactNode
}

const MyComponent = (props: MyComponentProps) => {
  return <div>{props.renderLink()}</div>
}

const Page = () => {
  return <MyComponent renderLink={() => <Link to="/absolute" />} />
}
export interface MyComponentProps {
  readonly renderLink: () => React.ReactNode
}

const MyComponent = (props: MyComponentProps) => {
  return <div>{props.renderLink()}</div>
}

const Page = () => {
  return <MyComponent renderLink={() => <Link to="/absolute" />} />
}

这个特定的例子非常快,因为我们将导航到的位置的控制反转给了组件用户。 Link 被缩小到我们想要导航到的确切路由。

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

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

Bytes

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

订阅 Bytes

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

Bytes

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