mcpField
T
tanstack-router-migration

tanstack-router-migration

github.com/redpanda-data开发4.3k63 次安装今天更新
代码仓库

关于 tanstack-router-migration

面向将应用从 React Router 迁移到 TanStack Router 的 React 开发者,提供分阶段及渐进式迁移指导,涵盖基于文件的路由、类型安全 Hook、搜索参数验证和测试。

主要功能

  1. 01分阶段迁移工作流
  2. 02基于文件的路由结构
  3. 03类型安全的 Hook 与导航
  4. 04使用 Zod 验证搜索参数
  5. 05测试配置与迁移检查

适用场景

  1. 01规划 React Router 迁移
  2. 02逐步迁移路由
  3. 03更新 Hook 和搜索参数
  4. 04为迁移后的路由准备测试

安装

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

$npx skills add https://github.com/redpanda-data/console --skill tanstack-router-migration

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

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
build-configuration.md
incremental-migration.md
migration-patterns.md
route-templates.md

详细介绍

从 React Router 迁移到 TanStack Router

使用基于文件的路由,将 React 应用从 React Router 迁移到 TanStack Router。本技能为渐进式迁移和全新迁移提供了结构化方案。

关键规则

务必:

  • 使用基于文件的路由,并将路由放在 src/routes/ 目录中
  • 在所有 Hook 中使用 from 参数以确保类型安全(useParams({ from: '/path' }))
  • 使用 @tanstack/zod-adapter 和 Zod 模式验证搜索参数
  • 创建路由之前配置构建工具插件
  • 注册路由器类型,以获得完整的 TypeScript 类型推断
  • 对可选搜索参数使用 fallback() 包装器

切勿:

  • 编辑 routeTree.gen.ts(自动生成的文件)
  • 在迁移期间于新代码中使用 React Router Hook
  • 忘记 from 参数(会失去类型安全)
  • 仅使用字符串验证搜索参数
  • 跳过构建插件配置

依赖项

# Core dependencies
bun add @tanstack/react-router @tanstack/zod-adapter

# Build plugin (choose one based on your bundler)
bun add -d @tanstack/router-plugin

# Optional integrations
bun add nuqs                    # URL state management
bun add @sentry/react           # Error tracking with router integration

迁移阶段

阶段 1:评估

审查现有 React Router 用法:

# Find all React Router imports
grep -r "from 'react-router" src/ --include="*.tsx" --include="*.ts"
grep -r 'from "react-router' src/ --include="*.tsx" --include="*.ts"

# Find hook usages
grep -r "useParams\|useSearchParams\|useNavigate\|useLocation\|useMatch" src/

记录:

  • React Router 版本(v5 或 v6)
  • 路由数量
  • useParams 用法数量
  • useSearchParams 用法数量
  • useNavigate 用法数量
  • 自定义 Link 组件
  • 路由守卫/受保护路由
  • 现有路由结构
阶段 2:设置

1. 配置构建工具

完整配置请参阅 references/build-configuration.md。

Rspack/Rsbuild:

// rsbuild.config.ts
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';

export default {
  tools: {
    rspack: (config) => {
      config.plugins?.push(
        TanStackRouterRspack({
          target: 'react',
          autoCodeSplitting: true,
          routesDirectory: './src/routes',
          generatedRouteTree: './src/routeTree.gen.ts',
          quoteStyle: 'single',
          semicolons: true,
        })
      );
      // Prevent rebuild loop
      config.watchOptions = { ignored: ['**/routeTree.gen.ts'] };
      return config;
    },
  },
};

Vite:

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

export default defineConfig({
  plugins: [
    TanStackRouterVite({
      target: 'react',
      autoCodeSplitting: true,
      routesDirectory: './src/routes',
      generatedRouteTree: './src/routeTree.gen.ts',
    }),
    react(),
  ],
});

2. 配置 Linter

// biome.jsonc or eslint config
{
  "files": {
    "ignore": ["**/routeTree.gen.ts"]
  },
  "overrides": [
    {
      "include": ["**/routes/**/*"],
      "linter": {
        "rules": {
          "style": {
            "useFilenamingConvention": "off"  // Allow $param.tsx naming
          }
        }
      }
    }
  ]
}

3. 创建路由目录

mkdir -p src/routes
阶段 3:创建路由器

创建路由器实例:

// src/app.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';
import { NotFoundPage } from './components/misc/not-found-page';

const queryClient = new QueryClient();

const router = createRouter({
  routeTree,
  context: {
    basePath: getBasePath(),
    queryClient,
  },
  basepath: getBasePath(),
  trailingSlash: 'never',
  defaultNotFoundComponent: NotFoundPage,
});

// Register router type for full TypeScript inference
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }

  // Extend HistoryState for typed navigation state
  interface HistoryState {
    // Add your custom state properties here
    returnUrl?: string;
    documentId?: string;
    documentName?: string;
  }
}

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

定义路由器上下文类型:

// src/routes/__root.tsx
import type { QueryClient } from '@tanstack/react-query';

export type RouterContext = {
  basePath: string;
  queryClient: QueryClient;
};
阶段 4:迁移路由

创建根布局:

// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import type { QueryClient } from '@tanstack/react-query';
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';

export type RouterContext = {
  basePath: string;
  queryClient: QueryClient;
};

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootLayout,
});

function RootLayout() {
  return (
    <>
      <NuqsAdapter>
        <ErrorBoundary>
          <AppLayout>
            <Outlet />
          </AppLayout>
        </ErrorBoundary>
      </NuqsAdapter>
      {process.env.NODE_ENV === 'development' && (
        <TanStackRouterDevtools position="bottom-right" />
      )}
    </>
  );
}

基于文件的路由结构:

src/routes/
├── __root.tsx                    # Root layout
├── index.tsx                     # / (root redirect)
├── overview/
│   └── index.tsx                 # /overview
├── topics/
│   ├── index.tsx                 # /topics
│   └── $topicName/
│       ├── index.tsx             # /topics/:topicName
│       └── edit.tsx              # /topics/:topicName/edit
├── security/
│   ├── index.tsx                 # /security (redirect)
│   ├── acls/
│   │   ├── index.tsx             # /security/acls
│   │   ├── create.tsx            # /security/acls/create
│   │   └── $aclName/
│   │       └── details.tsx       # /security/acls/:aclName/details

完整模板请参阅 references/route-templates.md。

阶段 5:迁移 Hook
React RouterTanStack Router
useParams()useParams({ from: '/path/$param' })
useSearchParams()使用 Zod 验证的 routeApi.useSearch()
useNavigate()useNavigate({ from: '/path' })
useLocation()useLocation()(API 相同)
<Link to="/path"><Link to="/path">(类型安全)
<Navigate to="/path" /><Navigate to="/path" />

详细的迁移前后示例请参阅 references/migration-patterns.md。

导航状态:

使用 HistoryState 在路由之间传递类型化状态:

// Navigating with state
const navigate = useNavigate();
navigate({
  to: '/documents/$documentId',
  params: { documentId },
  state: {
    returnUrl: location.pathname,
    documentName: 'My Document',
  },
});

// Reading state in destination component
import { useLocation } from '@tanstack/react-router';

function DocumentPage() {
  const location = useLocation();
  const { returnUrl, documentName } = location.state;
  // Use state values...
}

迁移 useParams:

// Before (React Router)
import { useParams } from 'react-router-dom';
const { id } = useParams<{ id: string }>();

// After (TanStack Router)
import { useParams } from '@tanstack/react-router';
const { id } = useParams({ from: '/items/$id' });

使用 Zod 验证 useSearch:

// In route file
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';

const searchSchema = z.object({
  tab: fallback(z.string().optional(), undefined),
  page: fallback(z.number().optional(), 1),
  q: fallback(z.string().optional(), undefined),
});

export const Route = createFileRoute('/items/')({
  validateSearch: zodValidator(searchSchema),
  component: ItemsPage,
});

// In component
import { getRouteApi, useNavigate } from '@tanstack/react-router';

const routeApi = getRouteApi('/items/');

function ItemsPage() {
  const { tab, page, q } = routeApi.useSearch();
  const navigate = useNavigate({ from: '/items/' });

  const handleTabChange = (newTab: string) => {
    navigate({ search: (prev) => ({ ...prev, tab: newTab }) });
  };
}
阶段 6:测试

创建测试工具:

// src/test-utils.tsx
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import { routeTree } from './routeTree.gen';
import type { RouterContext } from './routes/__root';

interface RenderWithFileRoutesOptions extends Omit<RenderOptions, 'wrapper'> {
  initialLocation?: string;
  routerContext?: Partial<RouterContext>;
}

export function renderWithFileRoutes(
  ui: React.ReactElement | null = null,
  { initialLocation = '/', routerContext = {}, ...renderOptions }: RenderWithFileRoutesOptions = {}
) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const router = createRouter({
    routeTree,
    history: createMemoryHistory({ initialEntries: [initialLocation] }),
    context: { basePath: '', queryClient, ...routerContext },
  });

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router}>{children}</RouterProvider>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui ?? <div />, { wrapper: Wrapper, ...renderOptions }),
    router,
  };
}

export async function renderRoute(location: string, options?: RenderWithFileRoutesOptions) {
  const result = renderWithFileRoutes(null, { initialLocation: location, ...options });
  await result.router.load();
  return result;
}

配置 Vitest:

// vitest.config.integration.mts
import { tanstackRouter } from '@tanstack/router-plugin/vite';

export default defineConfig({
  plugins: [
    tanstackRouter({
      target: 'react',
      routesDirectory: './src/routes',
      generatedRouteTree: './src/routeTree.gen.ts',
    }),
    react(),
  ],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});
阶段 7:集成

Sentry 集成:

// src/app.tsx
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  integrations: [
    Sentry.tanstackRouterBrowserTracingIntegration(router),
  ],
  tracesSampleRate: 1.0,
});

nuqs 集成:

// src/routes/__root.tsx
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';

function RootLayout() {
  return (
    <NuqsAdapter>
      <Outlet />
    </NuqsAdapter>
  );
}

渐进式迁移(兼容旧版):

迁移期间同时运行两个路由器的模式请参阅 references/incremental-migration.md。

快速参考

路由文件命名
模式文件URL
索引路由topics/index.tsx/topics
动态参数topics/$topicName.tsx/topics/:topicName
嵌套动态路由topics/$topicName/edit.tsx/topics/:topicName/edit
无路径布局_layout.tsx(无 URL 路径段)
捕获所有路由$.tsx/*
常见 Zod 模式
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';

const searchSchema = z.object({
  // Optional string with undefined default
  tab: fallback(z.string().optional(), undefined),

  // Optional number with default value
  page: fallback(z.number().optional(), 1),

  // Required string
  id: z.string(),

  // Enum with default
  sort: fallback(z.enum(['asc', 'desc']).optional(), 'asc'),

  // Boolean
  expanded: fallback(z.boolean().optional(), false),
});
from 参数中的尾部斜杠

from 参数必须与定义的路由路径完全一致:

// Index routes (files named index.tsx) include trailing slash:
useParams({ from: '/topics/$topicName/' })  // Route: topics/$topicName/index.tsx

// Non-index routes do NOT include trailing slash:
useParams({ from: '/topics/$topicName/edit' })  // Route: topics/$topicName/edit.tsx
类型安全的导航
// With params
<Link to="/topics/$topicName" params={{ topicName: 'my-topic' }}>
  View Topic
</Link>

// With search params
<Link to="/topics" search={{ page: 2, sort: 'desc' }}>
  Page 2
</Link>

// Programmatic navigation
const navigate = useNavigate({ from: '/topics/$topicName' });
navigate({
  to: '/topics/$topicName/edit',
  params: { topicName },
  search: { tab: 'settings' },
});

检查清单

迁移前
  • 已安装依赖项(@tanstack/react-router、@tanstack/router-plugin、@tanstack/zod-adapter)
  • 已配置构建工具插件
  • 已配置 Linter,以允许 $param.tsx 命名
  • 已创建 src/routes/ 目录
路由迁移
  • 创建包含 providers 和布局的 __root.tsx
  • 创建用于根路径重定向的 index.tsx
  • 将所有路由迁移到基于文件的结构
  • 使用 Zod schemas 验证搜索参数
  • 为标题/图标添加 staticData
Hook 迁移
  • 更新所有 useParams 调用,添加 from 参数
  • 将所有 useSearchParams 替换为 routeApi.useSearch()
  • 更新所有 useNavigate 调用,添加 from 参数
  • 确认所有 Link 组件均能正常工作
测试
  • 创建 renderWithFileRoutes 工具
  • 配置 Vitest 并使用 TanStack Router 插件
  • 更新现有测试以使用新工具
集成
  • 配置 Sentry 集成(如有使用)
  • 在根布局中包装 nuqs adapter(如有使用)
清理(完整迁移后)
  • 移除 React Router 依赖
  • 删除旧版路由定义
  • 移除 BrowserRouter 包装器
  • 移除 RouterSync 组件

常见问题

  1. 缺少 from 参数 - 为确保类型安全,始终在 hooks 中指定 from
  2. 忘记使用 fallback() 包装 - 可选搜索参数需要使用 fallback(z.string().optional(), undefined)
  3. 尾部斜杠不一致 - 配置 trailingSlash: 'never' 并保持一致
  4. 编辑 routeTree.gen.ts - 切勿编辑;文件发生更改时会自动生成
  5. 缺少构建插件 - 没有 bundler 插件就不会生成路由
  6. 异步导航警告 - navigate() 会返回 Promise;请使用 void navigate() 或等待其完成
  7. 使用 <Navigate> 进行区块重定向 - 请改用 beforeLoad 和 throw redirect(),以避免嵌入模式下出现导航循环:
    beforeLoad: () => {
      throw redirect({ to: '/section/$tab', params: { tab: 'default' }, replace: true });
    }
    
  8. 索引路由的 from 参数末尾缺少斜杠 - 索引路由(文件名为 index.tsx)的 from 参数必须带有尾部斜杠:
    // Index route: /topics/$topicName/index.tsx
    useParams({ from: '/topics/$topicName/' })  // ✅ Correct (trailing slash)
    useParams({ from: '/topics/$topicName' })   // ❌ Wrong
    
  9. 缺少 HistoryState 扩展 - 扩展 HistoryState 接口,以实现类型化导航状态(参见第 3 阶段)

文档

评论

支持 Markdown