前置知识: TypeScript

类型安全的路由

00:00
3 min Advanced 2026/6/14

构建类型安全的前端路由系统

概述

前端路由是单页应用的核心机制,负责管理页面间的导航和参数传递。传统的路由方案使用字符串路径,缺乏类型安全,容易出现路径拼写错误、参数类型不匹配和参数遗漏等问题。通过 TypeScript 的模板字面量类型、条件类型和泛型约束,可以构建类型安全的路由系统,确保路径、参数和查询字符串都有精确的类型约束。

基础概念

路由定义:将所有路由路径定义为一个类型映射,每个路径对应其参数和查询字符串的类型。

路径参数:路由路径中的动态片段(如 /users/:id),使用模板字面量类型提取参数名和类型。

查询参数:URL 中 ? 后面的键值对,使用接口定义每个路由支持的查询参数。

导航函数类型安全的导航函数,目标路由自动推断所需的参数查询字符串

路由守卫:在导航执行类型安全检查函数,用于权限验证和数据预加载

快速上手

路由类型定义

// 定义路由及其参数类型
interface Routes {
  '/': {};
  '/users': { query?: { page?: number; search?: string } };
  '/users/:id': { params: { id: string } };
  '/users/:id/posts': { params: { id: string }; query?: { tab?: 'all' | 'published' | 'draft' } };
  '/posts/new': {};
  '/posts/:id/edit': { params: { id: string } };
  '/settings': { query?: { section?: 'profile' | 'security' | 'notifications' } };
}

// 提取路径参数名
type ExtractParams<Path> = Path extends `${string}:${infer Param}/${infer Rest}`
  ? Param | ExtractParams<Rest>
  : Path extends `${string}:${infer Param}`
    ? Param
    : never;

// 使用
type UserParams = ExtractParams<'/users/:id'>; // 'id'
type PostParams = ExtractParams<'/posts/:id/edit'>; // 'id'

类型安全的导航

// 导航函数类型
type NavigateOptions<Path extends keyof Routes> = Routes[Path] extends { params: infer P }
  ? { params: P; query?: Routes[Path] extends { query: infer Q } ? Q : never }
  : { query?: Routes[Path] extends { query: infer Q } ? Q : never };

function navigate<Path extends keyof Routes>(path: Path, options: NavigateOptions<Path>): void {
  let url: string = path;

  // 替换路径参数
  if ('params' in options && options.params) {
    for (const [key, value] of Object.entries(options.params)) {
      url = url.replace(`:${key}`, value);
    }
  }

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

  // 执行导航
  window.history.pushState({}, '', url);
}

// 使用
navigate('/', {}); // 首页
navigate('/users', { query: { page: 1 } }); // 用户列表
navigate('/users/:id', { params: { id: '123' } }); // 用户详情
// navigate('/users/:id', {});                         // 错误:缺少 id 参数
// navigate('/users/:id', { params: { id: 123 } });    // 错误:id 应为 string

详细用法

路由链接组件

// 类型安全的链接组件 Props
type LinkProps<Path extends keyof Routes> = {
  to: Path;
} & NavigateOptions<Path>;

function Link<Path extends keyof Routes>(
  props: LinkProps<Path> & { children: React.ReactNode }
): React.ReactElement {
  const { to, children, ...options } = props;
  const href = buildUrl(to, options as any);

  return React.createElement(
    'a',
    {
      href,
      onClick: (e: React.MouseEvent) => {
        e.preventDefault();
        navigate(to, options as any);
      },
    },
    children
  );
}

function buildUrl(path: string, options: any): string {
  let url = path;
  if (options.params) {
    for (const [key, value] of Object.entries(options.params)) {
      url = url.replace(`:${key}`, value);
    }
  }
  if (options.query) {
    const params = new URLSearchParams(options.query);
    url += `?${params.toString()}`;
  }
  return url;
}

// 使用
React.createElement(Link, { to: '/users/:id', params: { id: '1' } }, '查看用户');
React.createElement(Link, { to: '/users', query: { page: 2 } }, '下一页');

路由匹配与参数解析

// 将路由模式转换为正则表达式
function routeToRegex(path: string): RegExp {
  const pattern = path
    .replace(/:[^/]+/g, '([^/]+)') // 替换参数为捕获组
    .replace(/\//g, '\\/'); // 转义斜杠
  return new RegExp(`^${pattern}$`);
}

// 从路径中提取参数
function extractParams<T extends keyof Routes>(
  route: T,
  path: string
): Routes[T] extends { params: infer P } ? P : {} {
  const regex = routeToRegex(route as string);
  const match = path.match(regex);
  if (!match) return {} as any;

  const paramNames = (route as string).match(/:[^/]+/g)?.map((p) => p.slice(1)) ?? [];
  const params: Record<string, string> = {};
  paramNames.forEach((name, index) => {
    params[name] = match[index + 1];
  });

  return params as any;
}

// 解析查询参数
function parseQuery<T extends keyof Routes>(
  route: T,
  search: string
): Routes[T] extends { query: infer Q } ? Q : {} {
  const params = new URLSearchParams(search);
  const result: Record<string, any> = {};

  // 根据路由定义的类型解析查询参数
  params.forEach((value, key) => {
    const numValue = Number(value);
    result[key] = isNaN(numValue) ? value : numValue;
  });

  return result as any;
}

路由守卫

// 路由守卫类型
type RouteGuard<Path extends keyof Routes> = (
  from: keyof Routes,
  to: Path,
  options: NavigateOptions<Path>
) => boolean | Promise<boolean>;

// 需要认证的路由
type ProtectedRoutes = '/settings' | '/posts/new' | '/posts/:id/edit';

// 认证守卫
function authGuard<Path extends ProtectedRoutes>(
  from: keyof Routes,
  to: Path,
  options: NavigateOptions<Path>
): boolean {
  const isAuthenticated = checkAuth();
  if (!isAuthenticated) {
    navigate('/users', { query: { search: '' } }); // 重定向到登录页
    return false;
  }
  return true;
}

function checkAuth(): boolean {
  // 检查用户是否已认证
  return !!localStorage.getItem('token');
}

常见场景

React Router 类型安全

import { useParams, useSearchParams, useNavigate } from 'react-router-dom';

// 类型安全的 useParams Hook
function useTypedParams<T extends keyof Routes>(): Routes[T] extends { params: infer P } ? P : {} {
  return useParams() as any;
}

// 使用
function UserProfile() {
  const { id } = useTypedParams<'/users/:id'>();
  // id: string
  return React.createElement('div', null, `用户 ID: ${id}`);
}

// 类型安全的 useNavigate Hook
function useTypedNavigate() {
  const navigate = useNavigate();

  return <Path extends keyof Routes>(path: Path, options: NavigateOptions<Path>) => {
    const url = buildUrl(path as string, options as any);
    navigate(url);
  };
}

面包屑导航

// 定义路由层级关系
interface RouteMeta {
  title: string;
  parent?: keyof Routes;
}

const routeMeta: Record<keyof Routes, RouteMeta> = {
  '/': { title: '首页' },
  '/users': { title: '用户列表', parent: '/' },
  '/users/:id': { title: '用户详情', parent: '/users' },
  '/users/:id/posts': { title: '用户文章', parent: '/users/:id' },
  '/posts/new': { title: '新建文章', parent: '/' },
  '/posts/:id/edit': { title: '编辑文章', parent: '/' },
  '/settings': { title: '设置', parent: '/' },
};

// 生成面包屑
function getBreadcrumbs(path: keyof Routes): Array<{ title: string; path: keyof Routes }> {
  const breadcrumbs: Array<{ title: string; path: keyof Routes }> = [];
  let current: keyof Routes | undefined = path;

  while (current) {
    const meta = routeMeta[current];
    breadcrumbs.unshift({ title: meta.title, path: current });
    current = meta.parent;
  }

  return breadcrumbs;
}

注意事项

  • 路径参数类型URL 中的路径参数始终是字符串类型。即使期望数,也需要在组件中手动转换
  • 查询参数解析URL 查询参数也是字符串解析时需要据期望类型转换,注意处理 NaN 的情况。
  • 路由定义维护路由类型定义需要与实际路由配置保持同步建议路由配置文件自动生成类型定义
  • 可选参数路由路径中的可选参数(如 /users/:id?)在 TypeScript 中较难精确表达建议使用联合类型条件类型处理。

进阶用法

路由类型自动生成

// 从路由配置对象自动生成类型
const routeConfig = {
  '/': { title: '首页' },
  '/users': { title: '用户列表', query: { page: 'number', search: 'string' } },
  '/users/:id': { title: '用户详情', params: { id: 'string' } },
} as const;

// 从配置生成路由类型
type RouteConfig = typeof routeConfig;
type RoutePaths = keyof RouteConfig;

// 从配置提取参数类型
type RouteParams<Path extends RoutePaths> = RouteConfig[Path] extends { params: infer P }
  ? { [K in keyof P]: P[K] extends 'string' ? string : P[K] extends 'number' ? number : never }
  : {};

// 使用
type UserDetailParams = RouteParams<'/users/:id'>;
// { id: string }

路由预加载

// 类型安全的路由预加载
type RouteLoader<Path extends keyof Routes> = Routes[Path] extends { params: infer P }
  ? (params: P) => Promise<void>
  : () => Promise<void>;

const routeLoaders: Partial<Record<keyof Routes, RouteLoader<keyof Routes>>> = {
  '/users/:id': async (params) => {
    // 预加载用户数据
    await fetch(`/api/users/${(params as { id: string }).id}`);
  },
  '/settings': async () => {
    // 预加载设置数据
    await fetch('/api/settings');
  },
};

async function preloadRoute<Path extends keyof Routes>(
  path: Path,
  options: NavigateOptions<Path>
): Promise<void> {
  const loader = routeLoaders[path];
  if (loader && 'params' in options) {
    await loader(options.params as any);
  } else if (loader) {
    await (loader as () => Promise<void>)();
  }
}

知识检测

学习进度

-- 已学文档
--% 知识覆盖率

学习推荐

专注模式