类型安全的路由
00:00
构建类型安全的前端路由系统
概述
前端路由是单页应用的核心机制,负责管理页面间的导航和参数传递。传统的路由方案使用字符串路径,缺乏类型安全,容易出现路径拼写错误、参数类型不匹配和参数遗漏等问题。通过 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>)();
}
}