Skip to content

类型安全路由

概述

类型安全路由通过TypeScript的强大类型系统,在编译时捕获路由相关的错误,提供完整的类型推断和IDE智能提示。本文深入探讨如何在React应用中实现完全类型安全的路由系统,涵盖路径参数、查询参数、导航等各个方面。

为什么需要类型安全路由

常见的路由类型错误

typescript
// 传统路由的问题

// 问题1:路径拼写错误(运行时才会发现)
navigate('/usres/123'); // 拼写错误:usres -> users

// 问题2:参数类型不匹配
<Link to={`/users/${null}`}>User</Link> // null参数

// 问题3:缺少必需参数
navigate('/products'); // 缺少productId参数

// 问题4:查询参数类型不安全
const page = searchParams.get('page'); // 返回string | null
const numPage = parseInt(page); // 可能是NaN

// 问题5:使用不存在的参数
const { nonExistentParam } = useParams(); // 没有类型检查

类型安全的优势

typescript
const typeSafetyAdvantages = {
  compileTimeErrors: {
    description: '编译时发现错误,而不是运行时',
    example: '路径拼写错误会立即标红'
  },

  autoComplete: {
    description: 'IDE智能提示路径和参数',
    example: '输入时自动补全可用的路由路径'
  },

  refactoringSafety: {
    description: '重构时自动更新所有引用',
    example: '重命名路由时IDE会提示所有使用位置'
  },

  documentationInCode: {
    description: '类型即文档',
    example: '参数类型和必需性一目了然'
  },

  preventBugs: {
    description: '预防常见bug',
    example: '防止传递错误类型的参数'
  }
};

基础类型安全实现

路由类型定义

typescript
// routes/types.ts
// 定义应用的所有路由

// 路由路径类型
export type RoutePath = 
  | '/'
  | '/about'
  | '/users'
  | '/users/:userId'
  | '/users/:userId/posts'
  | '/users/:userId/posts/:postId'
  | '/products'
  | '/products/:productId'
  | '/search'
  | '/settings'
  | '/settings/:section';

// 路径参数类型映射
export interface RouteParams {
  '/': never;
  '/about': never;
  '/users': never;
  '/users/:userId': { userId: string };
  '/users/:userId/posts': { userId: string };
  '/users/:userId/posts/:postId': { userId: string; postId: string };
  '/products': never;
  '/products/:productId': { productId: string };
  '/search': never;
  '/settings': never;
  '/settings/:section': { section: string };
}

// 查询参数类型映射
export interface RouteSearch {
  '/': never;
  '/about': never;
  '/users': {
    page?: number;
    limit?: number;
    sortBy?: 'name' | 'date' | 'activity';
  };
  '/users/:userId': never;
  '/users/:userId/posts': {
    page?: number;
  };
  '/users/:userId/posts/:postId': never;
  '/products': {
    category?: string;
    minPrice?: number;
    maxPrice?: number;
    page?: number;
  };
  '/products/:productId': never;
  '/search': {
    q: string;
    category?: string;
    page?: number;
  };
  '/settings': never;
  '/settings/:section': never;
}

// 辅助类型:提取参数名
type ExtractParams<T extends string> = 
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : T extends `${infer _Start}:${infer Param}`
      ? { [K in Param]: string }
      : never;

// 辅助类型:是否有参数
type HasParams<T extends RoutePath> = 
  T extends keyof RouteParams 
    ? RouteParams[T] extends never 
      ? false 
      : true
    : false;

// 辅助类型:是否有查询参数
type HasSearch<T extends RoutePath> = 
  T extends keyof RouteSearch 
    ? RouteSearch[T] extends never 
      ? false 
      : true
    : false;

类型安全的Link组件

typescript
// components/TypedLink.tsx
import { Link as RouterLink, LinkProps } from 'react-router-dom';
import { RoutePath, RouteParams, RouteSearch } from '../routes/types';

// 类型安全的Link Props
type TypedLinkProps<T extends RoutePath> = Omit<LinkProps, 'to'> & {
  to: T;
} & (RouteParams[T] extends never
  ? { params?: never }
  : { params: RouteParams[T] }
) & (RouteSearch[T] extends never
  ? { search?: never }
  : RouteSearch[T] extends Record<string, any>
    ? { search?: RouteSearch[T] }
    : { search?: never }
);

export function TypedLink<T extends RoutePath>(
  props: TypedLinkProps<T>
): JSX.Element {
  const { to, params, search, ...rest } = props;

  // 构建完整的路径
  let path: string = to;

  // 替换路径参数
  if (params) {
    Object.entries(params).forEach(([key, value]) => {
      path = path.replace(`:${key}`, String(value));
    });
  }

  // 添加查询参数
  if (search) {
    const searchParams = new URLSearchParams();
    Object.entries(search).forEach(([key, value]) => {
      if (value !== undefined) {
        searchParams.set(key, String(value));
      }
    });
    const searchString = searchParams.toString();
    if (searchString) {
      path += `?${searchString}`;
    }
  }

  return <RouterLink to={path} {...rest} />;
}

// 使用示例
function Navigation() {
  return (
    <nav>
      {/* ✅ 正确:没有参数的路由 */}
      <TypedLink to="/">Home</TypedLink>
      
      {/* ✅ 正确:带必需参数 */}
      <TypedLink 
        to="/users/:userId" 
        params={{ userId: '123' }}
      >
        User 123
      </TypedLink>
      
      {/* ✅ 正确:带查询参数 */}
      <TypedLink
        to="/users"
        search={{ page: 1, sortBy: 'name' }}
      >
        Users
      </TypedLink>
      
      {/* ✅ 正确:同时有路径参数和查询参数 */}
      <TypedLink
        to="/users/:userId/posts"
        params={{ userId: '123' }}
        search={{ page: 2 }}
      >
        User Posts
      </TypedLink>
      
      {/* ❌ 错误:缺少必需的参数 */}
      {/* <TypedLink to="/users/:userId">User</TypedLink> */}
      
      {/* ❌ 错误:参数类型不匹配 */}
      {/* <TypedLink 
        to="/users/:userId" 
        params={{ userId: 123 }}
      /> */}
      
      {/* ❌ 错误:不存在的查询参数 */}
      {/* <TypedLink
        to="/users"
        search={{ invalid: 'param' }}
      /> */}
    </nav>
  );
}

类型安全的useNavigate

typescript
// hooks/useTypedNavigate.ts
import { useNavigate as useRouterNavigate } from 'react-router-dom';
import { RoutePath, RouteParams, RouteSearch } from '../routes/types';

type NavigateOptions<T extends RoutePath> = {
  replace?: boolean;
  state?: any;
} & (RouteParams[T] extends never
  ? { params?: never }
  : { params: RouteParams[T] }
) & (RouteSearch[T] extends never
  ? { search?: never }
  : RouteSearch[T] extends Record<string, any>
    ? { search?: RouteSearch[T] }
    : { search?: never }
);

export function useTypedNavigate() {
  const navigate = useRouterNavigate();

  return <T extends RoutePath>(
    to: T,
    options?: NavigateOptions<T>
  ) => {
    let path: string = to;

    // 替换路径参数
    if (options?.params) {
      Object.entries(options.params).forEach(([key, value]) => {
        path = path.replace(`:${key}`, String(value));
      });
    }

    // 添加查询参数
    if (options?.search) {
      const searchParams = new URLSearchParams();
      Object.entries(options.search).forEach(([key, value]) => {
        if (value !== undefined) {
          searchParams.set(key, String(value));
        }
      });
      const searchString = searchParams.toString();
      if (searchString) {
        path += `?${searchString}`;
      }
    }

    navigate(path, {
      replace: options?.replace,
      state: options?.state
    });
  };
}

// 使用示例
function UserActions() {
  const navigate = useTypedNavigate();

  const handleViewUser = (userId: string) => {
    // ✅ 类型安全
    navigate('/users/:userId', {
      params: { userId }
    });
  };

  const handleSearch = (query: string) => {
    // ✅ 类型安全
    navigate('/search', {
      search: { q: query, page: 1 }
    });
  };

  const handleError = () => {
    // ❌ 编译错误:缺少必需参数
    // navigate('/users/:userId');
    
    // ❌ 编译错误:参数类型错误
    // navigate('/users/:userId', { params: { userId: 123 } });
  };

  return (
    <div>
      <button onClick={() => handleViewUser('123')}>
        View User
      </button>
      <button onClick={() => handleSearch('react')}>
        Search
      </button>
    </div>
  );
}

类型安全的useParams

typescript
// hooks/useTypedParams.ts
import { useParams } from 'react-router-dom';
import { RouteParams } from '../routes/types';

export function useTypedParams<T extends keyof RouteParams>(): RouteParams[T] {
  const params = useParams();
  return params as RouteParams[T];
}

// 使用示例
function UserDetail() {
  // ✅ 完全类型安全
  const { userId } = useTypedParams<'/users/:userId'>();
  // userId的类型是 string

  return (
    <div>
      <h1>User ID: {userId}</h1>
    </div>
  );
}

function PostDetail() {
  // ✅ 多个参数也类型安全
  const { userId, postId } = useTypedParams<'/users/:userId/posts/:postId'>();
  // userId和postId都是 string

  return (
    <div>
      <h1>User: {userId}</h1>
      <h2>Post: {postId}</h2>
    </div>
  );
}

类型安全的useSearchParams

typescript
// hooks/useTypedSearchParams.ts
import { useSearchParams } from 'react-router-dom';
import { RouteSearch } from '../routes/types';

export function useTypedSearchParams<T extends keyof RouteSearch>() {
  const [searchParams, setSearchParams] = useSearchParams();

  // 解析查询参数为类型安全的对象
  const typedSearch = Object.fromEntries(searchParams) as RouteSearch[T];

  // 类型安全的更新函数
  const setTypedSearch = (
    updates: Partial<RouteSearch[T]> | ((prev: RouteSearch[T]) => Partial<RouteSearch[T]>)
  ) => {
    const newSearch = typeof updates === 'function'
      ? updates(typedSearch)
      : updates;

    const newParams = new URLSearchParams();
    Object.entries(newSearch).forEach(([key, value]) => {
      if (value !== undefined && value !== null) {
        newParams.set(key, String(value));
      }
    });

    setSearchParams(newParams);
  };

  return [typedSearch, setTypedSearch] as const;
}

// 使用示例
function UsersList() {
  const [search, setSearch] = useTypedSearchParams<'/users'>();

  // ✅ 类型安全的查询参数
  const page = search.page ?? 1;
  const sortBy = search.sortBy ?? 'name';

  const handlePageChange = (newPage: number) => {
    // ✅ 类型检查
    setSearch({ ...search, page: newPage });
  };

  const handleSortChange = (sortBy: 'name' | 'date' | 'activity') => {
    // ✅ 类型检查,只能使用定义的值
    setSearch({ ...search, sortBy });
  };

  return (
    <div>
      <div>Current page: {page}</div>
      <div>Sort by: {sortBy}</div>
      
      <button onClick={() => handlePageChange(page + 1)}>
        Next Page
      </button>
      
      <select 
        value={sortBy} 
        onChange={(e) => handleSortChange(e.target.value as any)}
      >
        <option value="name">Name</option>
        <option value="date">Date</option>
        <option value="activity">Activity</option>
      </select>
    </div>
  );
}

function ProductsFilter() {
  const [search, setSearch] = useTypedSearchParams<'/products'>();

  return (
    <div className="filters">
      <input
        type="text"
        placeholder="Category"
        value={search.category ?? ''}
        onChange={(e) => setSearch({ ...search, category: e.target.value })}
      />
      
      <input
        type="number"
        placeholder="Min Price"
        value={search.minPrice ?? ''}
        onChange={(e) => setSearch({ 
          ...search, 
          minPrice: parseFloat(e.target.value) 
        })}
      />
      
      <input
        type="number"
        placeholder="Max Price"
        value={search.maxPrice ?? ''}
        onChange={(e) => setSearch({ 
          ...search, 
          maxPrice: parseFloat(e.target.value) 
        })}
      />
    </div>
  );
}

高级类型安全模式

路由配置生成器

typescript
// utils/createRoute.ts
import { RouteObject } from 'react-router-dom';
import { RoutePath, RouteParams, RouteSearch } from '../routes/types';

interface RouteConfig<T extends RoutePath> {
  path: T;
  element: React.ReactElement;
  loader?: (params: RouteParams[T], search: RouteSearch[T]) => Promise<any>;
  action?: (params: RouteParams[T], formData: FormData) => Promise<any>;
  errorElement?: React.ReactElement;
  children?: RouteConfig<any>[];
}

export function createRoute<T extends RoutePath>(
  config: RouteConfig<T>
): RouteObject {
  return {
    path: config.path,
    element: config.element,
    errorElement: config.errorElement,
    loader: config.loader ? async ({ params, request }) => {
      const url = new URL(request.url);
      const search = Object.fromEntries(url.searchParams) as RouteSearch[T];
      return config.loader!(params as RouteParams[T], search);
    } : undefined,
    action: config.action ? async ({ params, request }) => {
      const formData = await request.formData();
      return config.action!(params as RouteParams[T], formData);
    } : undefined,
    children: config.children?.map(child => createRoute(child))
  };
}

// 使用示例
const routes = [
  createRoute({
    path: '/',
    element: <HomePage />
  }),
  
  createRoute({
    path: '/users/:userId',
    element: <UserDetail />,
    loader: async (params, search) => {
      // params的类型是 { userId: string }
      const user = await fetchUser(params.userId);
      return { user };
    }
  }),
  
  createRoute({
    path: '/search',
    element: <SearchPage />,
    loader: async (params, search) => {
      // search的类型是 { q: string; category?: string; page?: number }
      const results = await searchAPI(search.q, {
        category: search.category,
        page: search.page ?? 1
      });
      return { results };
    }
  })
];

Zod集成

typescript
// routes/schemas.ts
import { z } from 'zod';

// 定义参数Schema
export const userIdSchema = z.object({
  userId: z.string().uuid()
});

export const postParamsSchema = z.object({
  userId: z.string().uuid(),
  postId: z.string().uuid()
});

export const productsSearchSchema = z.object({
  category: z.string().optional(),
  minPrice: z.number().min(0).optional(),
  maxPrice: z.number().max(10000).optional(),
  page: z.number().int().positive().default(1)
});

export const searchQuerySchema = z.object({
  q: z.string().min(1),
  category: z.string().optional(),
  page: z.number().int().positive().default(1)
});

// 类型推断
export type UserIdParams = z.infer<typeof userIdSchema>;
export type PostParams = z.infer<typeof postParamsSchema>;
export type ProductsSearch = z.infer<typeof productsSearchSchema>;
export type SearchQuery = z.infer<typeof searchQuerySchema>;

// 验证Hook
export function useValidatedParams<T extends z.ZodType>(schema: T): z.infer<T> {
  const params = useParams();
  const result = schema.safeParse(params);

  if (!result.success) {
    throw new Error(`Invalid params: ${result.error.message}`);
  }

  return result.data;
}

export function useValidatedSearch<T extends z.ZodType>(schema: T): z.infer<T> {
  const [searchParams] = useSearchParams();
  const search = Object.fromEntries(searchParams);
  
  // 转换数字类型
  Object.keys(search).forEach(key => {
    const value = search[key];
    if (!isNaN(Number(value))) {
      search[key] = Number(value);
    }
  });

  const result = schema.safeParse(search);

  if (!result.success) {
    throw new Error(`Invalid search params: ${result.error.message}`);
  }

  return result.data;
}

// 使用示例
function UserDetail() {
  // ✅ 参数经过验证且类型安全
  const { userId } = useValidatedParams(userIdSchema);
  // userId是经过UUID验证的string

  return <div>User: {userId}</div>;
}

function SearchResults() {
  // ✅ 查询参数经过验证且类型安全
  const search = useValidatedSearch(searchQuerySchema);
  // search.q: string (必需)
  // search.category?: string
  // search.page: number (默认1)

  return (
    <div>
      <h1>Results for: {search.q}</h1>
      <p>Page: {search.page}</p>
    </div>
  );
}

路由权限类型

typescript
// types/permissions.ts
export type Permission = 
  | 'users:read'
  | 'users:write'
  | 'posts:read'
  | 'posts:write'
  | 'admin:access'
  | 'reports:view';

export type Role = 'admin' | 'moderator' | 'user' | 'guest';

export interface User {
  id: string;
  name: string;
  role: Role;
  permissions: Permission[];
}

// 路由权限映射
export interface RoutePermissions {
  '/': never;
  '/about': never;
  '/users': 'users:read';
  '/users/:userId': 'users:read';
  '/users/:userId/edit': 'users:write';
  '/admin': 'admin:access';
  '/reports': 'reports:view';
}

// 类型安全的权限检查Hook
export function useRequirePermission<T extends keyof RoutePermissions>(
  route: T
): boolean {
  const user = useUser();
  const requiredPermission = RoutePermissions[route];

  if (requiredPermission === never) {
    return true; // 公开路由
  }

  return user?.permissions.includes(requiredPermission as Permission) ?? false;
}

// 权限守卫组件
function PermissionGuard<T extends keyof RoutePermissions>({
  route,
  children,
  fallback
}: {
  route: T;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const hasPermission = useRequirePermission(route);

  if (!hasPermission) {
    return <>{fallback || <Navigate to="/unauthorized" />}</>;
  }

  return <>{children}</>;
}

// 使用示例
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      
      <Route
        path="/users"
        element={
          <PermissionGuard route="/users">
            <Users />
          </PermissionGuard>
        }
      />
      
      <Route
        path="/users/:userId/edit"
        element={
          <PermissionGuard route="/users/:userId/edit">
            <EditUser />
          </PermissionGuard>
        }
      />
    </Routes>
  );
}

实战应用

完整的类型安全路由系统

typescript
// routes/index.ts
// 完整的类型安全路由配置

import { z } from 'zod';
import { createRoute } from '../utils/createRoute';

// 1. 定义所有Schema
const schemas = {
  params: {
    userId: z.object({ userId: z.string().uuid() }),
    postId: z.object({ 
      userId: z.string().uuid(),
      postId: z.string().uuid()
    }),
    productId: z.object({ productId: z.string() })
  },
  
  search: {
    pagination: z.object({
      page: z.number().int().positive().default(1),
      limit: z.number().int().positive().default(10)
    }),
    
    products: z.object({
      category: z.string().optional(),
      minPrice: z.number().min(0).optional(),
      maxPrice: z.number().max(10000).optional(),
      sortBy: z.enum(['name', 'price', 'rating']).default('name'),
      page: z.number().int().positive().default(1)
    }),
    
    search: z.object({
      q: z.string().min(1),
      category: z.string().optional(),
      page: z.number().int().positive().default(1)
    })
  }
};

// 2. 导出类型
export type UserId = z.infer<typeof schemas.params.userId>;
export type PostId = z.infer<typeof schemas.params.postId>;
export type ProductsSearch = z.infer<typeof schemas.search.products>;
export type SearchQuery = z.infer<typeof schemas.search.search>;

// 3. 创建类型安全的路由配置
export const routes = [
  createRoute({
    path: '/',
    element: <HomePage />,
    loader: async () => {
      const featured = await fetchFeaturedContent();
      return { featured };
    }
  }),
  
  createRoute({
    path: '/users',
    element: <UsersLayout />,
    children: [
      {
        path: '',
        element: <UsersList />,
        loader: async (params, search) => {
          const validated = schemas.search.pagination.parse(search);
          const users = await fetchUsers(validated);
          return { users };
        }
      },
      {
        path: ':userId',
        element: <UserDetail />,
        loader: async (params, search) => {
          const { userId } = schemas.params.userId.parse(params);
          const user = await fetchUser(userId);
          
          if (!user) {
            throw new Response('User not found', { status: 404 });
          }
          
          return { user };
        }
      },
      {
        path: ':userId/posts/:postId',
        element: <PostDetail />,
        loader: async (params, search) => {
          const { userId, postId } = schemas.params.postId.parse(params);
          
          const [post, author] = await Promise.all([
            fetchPost(postId),
            fetchUser(userId)
          ]);
          
          if (!post || !author) {
            throw new Response('Not found', { status: 404 });
          }
          
          return { post, author };
        }
      }
    ]
  }),
  
  createRoute({
    path: '/products',
    element: <ProductsLayout />,
    children: [
      {
        path: '',
        element: <ProductsList />,
        loader: async (params, search) => {
          const filters = schemas.search.products.parse(search);
          const products = await fetchProducts(filters);
          const categories = await fetchCategories();
          
          return { products, categories, filters };
        }
      },
      {
        path: ':productId',
        element: <ProductDetail />,
        loader: async (params, search) => {
          const { productId } = schemas.params.productId.parse(params);
          const product = await fetchProduct(productId);
          
          if (!product) {
            throw new Response('Product not found', { status: 404 });
          }
          
          return { product };
        }
      }
    ]
  }),
  
  createRoute({
    path: '/search',
    element: <SearchPage />,
    loader: async (params, search) => {
      const query = schemas.search.search.parse(search);
      const results = await searchAPI(query.q, {
        category: query.category,
        page: query.page
      });
      
      return { results, query };
    }
  })
];

// 4. 类型安全的组件
function ProductsList() {
  const { products, filters } = useLoaderData() as {
    products: Product[];
    filters: ProductsSearch;
  };
  
  const navigate = useTypedNavigate();
  
  const updateFilters = (newFilters: Partial<ProductsSearch>) => {
    navigate('/products', {
      search: { ...filters, ...newFilters }
    });
  };
  
  return (
    <div>
      <ProductsFilter 
        filters={filters}
        onChange={updateFilters}
      />
      
      <div className="products-grid">
        {products.map(product => (
          <TypedLink
            key={product.id}
            to="/products/:productId"
            params={{ productId: product.id }}
          >
            <ProductCard product={product} />
          </TypedLink>
        ))}
      </div>
    </div>
  );
}

路由生成器和辅助函数

typescript
// utils/routes.ts
// 路由URL生成器

import { RoutePath, RouteParams, RouteSearch } from '../routes/types';

// 类型安全的URL生成器
export function generatePath<T extends RoutePath>(
  path: T,
  ...args: RouteParams[T] extends never
    ? []
    : [params: RouteParams[T]]
): string {
  let result: string = path;

  if (args.length > 0) {
    const params = args[0] as Record<string, string>;
    Object.entries(params).forEach(([key, value]) => {
      result = result.replace(`:${key}`, value);
    });
  }

  return result;
}

// 类型安全的查询字符串生成器
export function generateSearch<T extends RoutePath>(
  path: T,
  search: RouteSearch[T]
): string {
  if (!search || Object.keys(search).length === 0) {
    return '';
  }

  const params = new URLSearchParams();
  Object.entries(search).forEach(([key, value]) => {
    if (value !== undefined && value !== null) {
      params.set(key, String(value));
    }
  });

  const queryString = params.toString();
  return queryString ? `?${queryString}` : '';
}

// 完整URL生成器
export function generateUrl<T extends RoutePath>(
  path: T,
  options?: {
    params?: RouteParams[T];
    search?: RouteSearch[T];
  }
): string {
  const pathname = options?.params
    ? generatePath(path, options.params as any)
    : path;

  const searchString = options?.search
    ? generateSearch(path, options.search)
    : '';

  return pathname + searchString;
}

// 使用示例
const urls = {
  home: generateUrl('/'),
  // 结果: "/"

  user: generateUrl('/users/:userId', {
    params: { userId: '123' }
  }),
  // 结果: "/users/123"

  userPosts: generateUrl('/users/:userId/posts', {
    params: { userId: '123' },
    search: { page: 2 }
  }),
  // 结果: "/users/123/posts?page=2"

  products: generateUrl('/products', {
    search: {
      category: 'electronics',
      minPrice: 100,
      maxPrice: 500,
      page: 1
    }
  }),
  // 结果: "/products?category=electronics&minPrice=100&maxPrice=500&page=1"

  search: generateUrl('/search', {
    search: {
      q: 'react router',
      page: 1
    }
  })
  // 结果: "/search?q=react+router&page=1"
};

路由测试工具

typescript
// utils/testing.tsx
// 类型安全的路由测试工具

import { render } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { RoutePath, RouteParams, RouteSearch } from '../routes/types';

interface RenderRouteOptions<T extends RoutePath> {
  route: T;
  params?: RouteParams[T];
  search?: RouteSearch[T];
  initialEntries?: string[];
}

export function renderRoute<T extends RoutePath>(
  component: React.ReactElement,
  options: RenderRouteOptions<T>
) {
  const { route, params, search, initialEntries } = options;

  let path = route as string;

  // 替换参数
  if (params) {
    Object.entries(params).forEach(([key, value]) => {
      path = path.replace(`:${key}`, String(value));
    });
  }

  // 添加查询参数
  if (search) {
    const searchParams = new URLSearchParams();
    Object.entries(search).forEach(([key, value]) => {
      if (value !== undefined) {
        searchParams.set(key, String(value));
      }
    });
    const searchString = searchParams.toString();
    if (searchString) {
      path += `?${searchString}`;
    }
  }

  const router = createMemoryRouter(
    [
      {
        path: route,
        element: component
      }
    ],
    {
      initialEntries: initialEntries || [path]
    }
  );

  return render(<RouterProvider router={router} />);
}

// 测试示例
describe('UserDetail', () => {
  it('renders user information', () => {
    const { getByText } = renderRoute(<UserDetail />, {
      route: '/users/:userId',
      params: { userId: '123' }
    });

    expect(getByText(/User ID: 123/)).toBeInTheDocument();
  });
});

describe('ProductsList', () => {
  it('applies filters from search params', () => {
    const { getByDisplayValue } = renderRoute(<ProductsList />, {
      route: '/products',
      search: {
        category: 'electronics',
        minPrice: 100,
        page: 1
      }
    });

    expect(getByDisplayValue('electronics')).toBeInTheDocument();
    expect(getByDisplayValue('100')).toBeInTheDocument();
  });
});

最佳实践

1. 类型定义组织

typescript
// types/routes/
// ├── index.ts        // 导出所有类型
// ├── paths.ts        // 路径定义
// ├── params.ts       // 参数类型
// ├── search.ts       // 查询参数类型
// └── permissions.ts  // 权限类型

// types/routes/index.ts
export * from './paths';
export * from './params';
export * from './search';
export * from './permissions';

// types/routes/paths.ts
export type RoutePath =
  | '/'
  | '/users'
  | '/users/:userId'
  // ... 更多路径

// types/routes/params.ts
export interface RouteParams {
  '/': never;
  '/users': never;
  '/users/:userId': { userId: string };
  // ... 更多参数
}

// types/routes/search.ts
export interface RouteSearch {
  '/': never;
  '/users': UserListSearch;
  '/products': ProductsSearch;
  // ... 更多查询参数
}

interface UserListSearch {
  page?: number;
  limit?: number;
  sortBy?: 'name' | 'date';
}

interface ProductsSearch {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}

2. 渐进式采用

typescript
// 步骤1:首先为关键路由添加类型
const criticalRoutes = [
  '/users/:userId',
  '/products/:productId',
  '/checkout'
] as const;

// 步骤2:逐步扩展到所有路由
// 步骤3:添加查询参数类型
// 步骤4:添加权限类型
// 步骤5:完善错误处理

3. 性能优化

typescript
// 使用类型常量避免重复计算
const ROUTE_PATHS = {
  HOME: '/' as const,
  USERS: '/users' as const,
  USER_DETAIL: '/users/:userId' as const,
  // ...
} as const;

type RoutePathValue = typeof ROUTE_PATHS[keyof typeof ROUTE_PATHS];

// 缓存生成的URL
const urlCache = new Map<string, string>();

function cachedGenerateUrl<T extends RoutePath>(
  path: T,
  options?: any
): string {
  const cacheKey = JSON.stringify({ path, options });
  
  if (urlCache.has(cacheKey)) {
    return urlCache.get(cacheKey)!;
  }
  
  const url = generateUrl(path, options);
  urlCache.set(cacheKey, url);
  
  return url;
}

4. 文档和维护

typescript
// routes/README.md
/**
 * 路由系统文档
 * 
 * ## 添加新路由
 * 
 * 1. 在 types/routes/paths.ts 中添加路径
 * 2. 在 types/routes/params.ts 中定义参数类型
 * 3. 在 types/routes/search.ts 中定义查询参数类型
 * 4. 在 routes/index.ts 中添加路由配置
 * 5. 确保所有类型测试通过
 * 
 * ## 类型安全检查清单
 * 
 * - [ ] 路径拼写正确
 * - [ ] 参数类型完整
 * - [ ] 查询参数类型完整
 * - [ ] 权限配置正确
 * - [ ] 测试覆盖完整
 */

总结

类型安全路由提供了强大的保障:

  1. 编译时错误检测:在编译阶段发现路由错误
  2. IDE智能提示:完整的自动补全和类型提示
  3. 重构安全:重命名和修改时自动更新引用
  4. 类型文档:类型定义即文档
  5. 更好的开发体验:减少运行时错误,提高开发效率

类型安全路由特别适合大型TypeScript项目,能够显著提升代码质量和维护性。虽然初期需要一些额外的类型定义工作,但长期来看能够大幅减少bug和提高开发效率。