mcpField
T
tanstack-router

tanstack-router

github.com/tanstack-skills开发365.0k 次安装39 周前更新
代码仓库

关于 tanstack-router

tanstack-router 是用于 React 和 Solid 应用的类型安全路由器,支持基于文件的路由、搜索参数管理、数据加载、代码拆分,并深度集成 TypeScript。

主要功能

  1. 01基于文件和嵌套路由
  2. 02类型安全的路径与搜索参数
  3. 03路由数据加载器
  4. 04代码拆分与预加载
  5. 05导航拦截与重定向

适用场景

  1. 01组织 React 或 Solid 应用的路由
  2. 02验证和读取 URL 搜索参数
  3. 03匹配路由时加载数据
  4. 04延迟加载路由组件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tanstack-skills/tanstack-skills --skill tanstack-router

安装后在 Claude Code 中输入/tanstack-router确认已加载。

详细介绍

概览

TanStack Router 是一款适用于 React(以及 Solid)应用的完全类型安全路由器。它提供基于文件的路由、一流的搜索参数管理、内置数据加载、代码拆分以及深度 TypeScript 集成。它是 TanStack Start(全栈框架)的路由基础。

Package: @tanstack/react-router CLI: @tanstack/router-cli 或 @tanstack/router-plugin(Vite/Rspack/Webpack) Devtools: @tanstack/react-router-devtools

安装

npm install @tanstack/react-router
# For file-based routing with Vite:
npm install -D @tanstack/router-plugin
# Or standalone CLI:
npm install -D @tanstack/router-cli

核心概念

路由树

路由以树形结构组织。根路由是顶层布局,子路由嵌套在其下方。

import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'

const rootRoute = createRootRoute({
  component: RootLayout,
})

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: HomePage,
})

const aboutRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/about',
  component: AboutPage,
})

const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])
const router = createRouter({ routeTree })
基于文件的路由

基于文件的路由会根据文件结构自动生成路由树。使用 Vite 插件进行配置:

// vite.config.ts
import { defineConfig } from 'vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'

export default defineConfig({
  plugins: [
    TanStackRouterVite(),
    // ... other plugins
  ],
})
文件命名约定
文件模式路由类型示例路径
__root.tsx根布局N/A(包装所有路由)
index.tsx索引路由/
about.tsx静态路由/about
$postId.tsx动态参数/posts/$postId
posts.tsx布局路由/posts/*(布局)
posts/index.tsx嵌套索引/posts
posts/$postId.tsx嵌套动态路由/posts/123
posts_.$postId.tsx无路径布局/posts/123(不同布局)
_layout.tsx无路径布局N/A(路由分组)
_layout/dashboard.tsx分组路由/dashboard
$.tsx通配符/兜底路由/*
posts.$postId.edit.tsx点号记法/posts/123/edit
特殊前缀
  • _ 前缀:无路径路由(不带 URL 路径段的布局分组)
  • $ 前缀:动态路径参数
  • (folder) 括号:路由分组(用于组织,不影响 URL)
路由配置

每个路由都可以定义:

// routes/posts.$postId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  // Validation for path params
  params: {
    parse: (params) => ({ postId: Number(params.postId) }),
    stringify: (params) => ({ postId: String(params.postId) }),
  },

  // Search params validation
  validateSearch: (search: Record<string, unknown>) => {
    return {
      page: Number(search.page ?? 1),
      filter: (search.filter as string) || '',
    }
  },

  // Data loading
  loader: async ({ params, context, abortController }) => {
    return fetchPost(params.postId)
  },

  // Loader dependencies (re-run loader when these change)
  loaderDeps: ({ search }) => ({ page: search.page }),

  // Stale time for cached loader data
  staleTime: 5_000,

  // Preloading
  preloadStaleTime: 30_000,

  // Error component
  errorComponent: PostErrorComponent,

  // Pending/loading component
  pendingComponent: PostLoadingComponent,

  // 404 component
  notFoundComponent: PostNotFoundComponent,

  // Before load hook (authentication, redirects)
  beforeLoad: async ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },

  // Head/meta management
  head: () => ({
    meta: [{ title: 'Post Details' }],
  }),

  // Component
  component: PostComponent,
})

function PostComponent() {
  const { postId } = Route.useParams()
  const post = Route.useLoaderData()
  const { page, filter } = Route.useSearch()

  return <div>{post.title}</div>
}

数据加载

路由加载器
export const Route = createFileRoute('/posts')({
  loader: async ({ context }) => {
    // Access router context (e.g., queryClient)
    const posts = await context.queryClient.ensureQueryData({
      queryKey: ['posts'],
      queryFn: fetchPosts,
    })
    return { posts }
  },
  component: PostsComponent,
})

function PostsComponent() {
  const { posts } = Route.useLoaderData()
  // ...
}
加载器依赖项

控制加载器何时重新执行:

export const Route = createFileRoute('/posts')({
  loaderDeps: ({ search: { page, filter } }) => ({ page, filter }),
  loader: async ({ deps: { page, filter } }) => {
    return fetchPosts({ page, filter })
  },
})
延迟数据加载

流式传输非关键数据:

import { Await, defer } from '@tanstack/react-router'

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const criticalData = await fetchCriticalData()
    const deferredData = defer(fetchSlowData())
    return { criticalData, deferredData }
  },
  component: DashboardComponent,
})

function DashboardComponent() {
  const { criticalData, deferredData } = Route.useLoaderData()

  return (
    <div>
      <CriticalSection data={criticalData} />
      <Suspense fallback={<Loading />}>
        <Await promise={deferredData}>
          {(data) => <SlowSection data={data} />}
        </Await>
      </Suspense>
    </div>
  )
}
基于上下文的数据加载

通过路由器上下文提供共享依赖项:

// Create router with context
const router = createRouter({
  routeTree,
  context: {
    queryClient,
    auth: undefined!, // Will be provided by RouterProvider
  },
})

// In root/app component
function App() {
  const auth = useAuth()
  return <RouterProvider router={router} context={{ auth }} />
}

// In routes
export const Route = createFileRoute('/protected')({
  beforeLoad: ({ context }) => {
    if (!context.auth.user) throw redirect({ to: '/login' })
  },
  loader: ({ context }) => {
    return context.queryClient.ensureQueryData(userQueryOptions())
  },
})

搜索参数

验证
import { z } from 'zod'

const postSearchSchema = z.object({
  page: z.number().default(1),
  filter: z.string().default(''),
  sort: z.enum(['date', 'title']).default('date'),
})

export const Route = createFileRoute('/posts')({
  validateSearch: postSearchSchema,
  // Or manual validation:
  // validateSearch: (search) => postSearchSchema.parse(search),
})
读取搜索参数
function PostsComponent() {
  // From route
  const { page, filter, sort } = Route.useSearch()

  // Or from any component with useSearch hook
  const search = useSearch({ from: '/posts' })
}
更新搜索参数
import { useNavigate } from '@tanstack/react-router'

function Pagination() {
  const navigate = useNavigate()
  const { page } = Route.useSearch()

  return (
    <button
      onClick={() =>
        navigate({
          search: (prev) => ({ ...prev, page: prev.page + 1 }),
        })
      }
    >
      Next Page
    </button>
  )
}

// Or via Link component
<Link
  to="/posts"
  search={(prev) => ({ ...prev, page: 2 })}
>
  Page 2
</Link>
搜索参数选项
const router = createRouter({
  routeTree,
  // Custom serialization
  search: {
    strict: true, // Reject unknown params
  },
  // Default search param serializer
  stringifySearch: defaultStringifySearch,
  parseSearch: defaultParseSearch,
})

导航

Link 组件
import { Link } from '@tanstack/react-router'

// Static route
<Link to="/about">About</Link>

// Dynamic route with params
<Link to="/posts/$postId" params={{ postId: '123' }}>
  Post 123
</Link>

// With search params
<Link to="/posts" search={{ page: 2, filter: 'react' }}>
  Page 2
</Link>

// Active link styling
<Link
  to="/posts"
  activeProps={{ className: 'active' }}
  inactiveProps={{ className: 'inactive' }}
  activeOptions={{ exact: true }}
>
  Posts
</Link>

// Preloading
<Link to="/posts" preload="intent">Posts</Link>
<Link to="/dashboard" preload="viewport">Dashboard</Link>

// Hash
<Link to="/docs" hash="api-reference">API Reference</Link>
编程式导航
import { useNavigate, useRouter } from '@tanstack/react-router'

function MyComponent() {
  const navigate = useNavigate()
  const router = useRouter()

  // Navigate to a route
  navigate({ to: '/posts', search: { page: 1 } })

  // Navigate with replace
  navigate({ to: '/posts', replace: true })

  // Relative navigation
  navigate({ to: '.', search: (prev) => ({ ...prev, page: 2 }) })

  // Go back/forward
  router.history.back()
  router.history.forward()

  // Invalidate and reload current route
  router.invalidate()
}
重定向
import { redirect } from '@tanstack/react-router'

// In beforeLoad or loader
throw redirect({
  to: '/login',
  search: { redirect: location.href },
  // Optional status code
  statusCode: 301, // Permanent redirect (SSR)
})
导航拦截
import { useBlocker } from '@tanstack/react-router'

function FormComponent() {
  const [isDirty, setIsDirty] = useState(false)

  useBlocker({
    shouldBlockFn: () => isDirty,
    withResolver: true, // Shows confirm dialog
  })

  // Or with custom UI
  const { proceed, reset, status } = useBlocker({
    shouldBlockFn: () => isDirty,
  })

  if (status === 'blocked') {
    return (
      <div>
        <p>Are you sure you want to leave?</p>
        <button onClick={proceed}>Leave</button>
        <button onClick={reset}>Stay</button>
      </div>
    )
  }
}

代码拆分

自动拆分(基于文件的路由)

使用基于文件的路由时,创建一个惰性加载文件:

routes/
  posts.tsx          # Critical: loader, beforeLoad, meta
  posts.lazy.tsx     # Lazy: component, pendingComponent, errorComponent
// posts.tsx (loaded eagerly)
export const Route = createFileRoute('/posts')({
  loader: () => fetchPosts(),
})

// posts.lazy.tsx (loaded lazily)
import { createLazyFileRoute } from '@tanstack/react-router'

export const Route = createLazyFileRoute('/posts')({
  component: PostsComponent,
  pendingComponent: PostsLoading,
  errorComponent: PostsError,
})
手动代码拆分
const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts',
  loader: () => fetchPosts(),
}).lazy(() => import('./posts.lazy').then((d) => d.Route))

预加载

// Router-level defaults
const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // 'intent' | 'viewport' | 'render' | false
  defaultPreloadStaleTime: 30_000, // 30 seconds
})

// Route-level
export const Route = createFileRoute('/posts/$postId')({
  // Stale time for the loader data
  staleTime: 5_000,
  // How long preloaded data stays fresh
  preloadStaleTime: 30_000,
})

// Link-level
<Link to="/posts" preload="intent" preloadDelay={100}>
  Posts
</Link>

类型安全

注册路由器类型
// Declare module for type inference
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
类型安全的 Hooks

所有 Hooks 均根据路由树提供完整类型:

// useParams - typed to route's params
const { postId } = useParams({ from: '/posts/$postId' })

// useSearch - typed to route's search schema
const { page } = useSearch({ from: '/posts' })

// useLoaderData - typed to loader return
const data = useLoaderData({ from: '/posts/$postId' })

// useRouteContext - typed to route context
const { auth } = useRouteContext({ from: '/protected' })
路由泛型
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/$postId')({
  // TypeScript infers:
  // params: { postId: string }
  // search: validated search schema type
  // loaderData: return type of loader
  // context: router context type
})

已验证身份的路由

// __root.tsx
export const Route = createRootRouteWithContext<{
  auth: AuthContext
}>()({
  component: RootComponent,
})

// _authenticated.tsx (pathless layout for auth)
export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
})

// _authenticated/dashboard.tsx
export const Route = createFileRoute('/_authenticated/dashboard')({
  component: Dashboard, // Only accessible when authenticated
})

滚动位置恢复

const router = createRouter({
  routeTree,
  // Enable scroll restoration
  defaultScrollRestoration: true,
})

// Or per-route
export const Route = createFileRoute('/posts')({
  // Scroll to top on navigation
  scrollRestoration: true,
})

// Custom scroll restoration key
<ScrollRestoration
  getKey={(location) => location.pathname}
/>

路由遮罩

显示与实际路由不同的 URL:

<Link
  to="/photos/$photoId"
  params={{ photoId: photo.id }}
  mask={{ to: '/photos', search: { photoId: photo.id } }}
>
  View Photo
</Link>

// Or programmatically
navigate({
  to: '/photos/$photoId',
  params: { photoId: photo.id },
  mask: { to: '/photos', search: { photoId: photo.id } },
})

未找到处理

// Global 404
const router = createRouter({
  routeTree,
  defaultNotFoundComponent: () => <div>Page not found</div>,
})

// Route-level 404
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw notFound()
    return post
  },
  notFoundComponent: () => <div>Post not found</div>,
})

Head 管理

export const Route = createFileRoute('/posts/$postId')({
  head: ({ loaderData }) => ({
    meta: [
      { title: loaderData.title },
      { name: 'description', content: loaderData.excerpt },
      { property: 'og:title', content: loaderData.title },
    ],
    links: [
      { rel: 'canonical', href: `https://example.com/posts/${loaderData.id}` },
    ],
  }),
})

与 TanStack Query 集成

import { queryOptions } from '@tanstack/react-query'

const postsQueryOptions = queryOptions({
  queryKey: ['posts'],
  queryFn: fetchPosts,
})

export const Route = createFileRoute('/posts')({
  loader: ({ context: { queryClient } }) => {
    // Ensure data is in cache, won't refetch if fresh
    return queryClient.ensureQueryData(postsQueryOptions)
  },
  component: PostsComponent,
})

function PostsComponent() {
  // Use the same query options for reactive updates
  const { data: posts } = useSuspenseQuery(postsQueryOptions)
  return <PostsList posts={posts} />
}

路由器 Hooks 参考

Hook用途
useRouter()访问路由器实例
useRouterState()订阅路由器状态
useParams()获取路由路径参数
useSearch()获取经过验证的搜索参数
useLoaderData()获取路由加载器数据
useRouteContext()获取路由上下文
useNavigate()获取导航函数
useLocation()获取当前位置
useMatches()获取所有匹配的路由
useMatch()获取特定的路由匹配项
useBlocker()拦截导航
useLinkProps()获取自定义组件的链接属性
useMatchRoute()检查路由是否匹配

最佳实践

  1. 大多数应用使用基于文件的路由——这样更简单,还能自动生成路由树
  2. 使用 Zod 或自定义验证器校验搜索参数,确保类型安全
  3. 使用 loaderDeps,根据搜索参数的变化控制加载器何时重新执行
  4. 利用上下文进行依赖注入(QueryClient、身份验证状态)
  5. **使用 beforeLoad**进行身份验证守卫,不要在组件中处理
  6. 区分关键代码和延迟加载代码——将加载器保留在主文件中,将组件放在 .lazy.tsx 中
  7. 在 Links 上使用 preload="intent",提升感知性能
  8. 使用 staleTime,避免导航时不必要的重新获取
  9. 注册路由器类型,以便在整个应用中获得完整的 TypeScript 类型推断
  10. **使用 notFound()**处理 404 状态,而不是条件渲染
  11. 将搜索参数逻辑与拥有这些参数的路由放在一起
  12. 使用无路径布局(_authenticated),在不添加 URL 路径段的情况下共享身份验证和布局逻辑

常见陷阱

  • 忘记注册路由器类型(declare module)
  • 加载器依赖搜索参数时未使用 loaderDeps(会导致数据过期)
  • 在组件中而不是 beforeLoad 中检查身份验证(会短暂显示受保护的内容)
  • 未通过 pendingComponent 处理加载状态
  • 使用 useEffect 获取数据,而不是使用路由加载器
  • 直接修改搜索参数,而不是使用 navigate/Link
  • 未使用 RouterProvider 包裹应用
  • 在基于代码的路由定义中忘记使用 getParentRoute

评论

支持 Markdown