文章目录
- 为什么要重视路由设计
- [SPA 路由基础与术语](#SPA 路由基础与术语)
-
- [- **路由模式**](#- 路由模式)
-
- - Hash 路由:`/#/path`,无需后端配合,适合静态托管。
- [- History 路由:`/path`,需服务端进行回退到 `index.html` 的兜底配置,URL 更优雅。](#- History 路由:
/path,需服务端进行回退到index.html的兜底配置,URL 更优雅。)
- [- **路径类型**](#- 路径类型)
-
- [- 静态:`/about`](#- 静态:
/about) - [- 动态:`/users/:userId`](#- 动态:
/users/:userId) - [- 可选参数:`/search/:tab?`](#- 可选参数:
/search/:tab?) - [- 通配符:`/*` 用于 404 或微前端子应用承载](#- 通配符:
/*用于 404 或微前端子应用承载)
- [- 静态:`/about`](#- 静态:
- [- **导航方式**](#- 导航方式)
- [- **状态与副作用**](#- 状态与副作用)
- [URL 设计原则](#URL 设计原则)
-
- [- **语义化**:资源化的路径命名,如 `/projects/:id/issues`](#- 语义化:资源化的路径命名,如
/projects/:id/issues) - [- **稳定性**:避免业务词汇频繁变动导致的 URL 变更;必要时做 [301/302](https://dontla.blog.csdn.net/article/details/123267303) 重定向](#- 稳定性:避免业务词汇频繁变动导致的 URL 变更;必要时做 301/302 重定向)
- [- **最小必要信息**:优先用路径参数表达主实体,用查询参数表达筛选/分页](#- 最小必要信息:优先用路径参数表达主实体,用查询参数表达筛选/分页)
- [- **可分享性**:URL 应能完整复现页面状态(分页、排序、筛选)](#- 可分享性:URL 应能完整复现页面状态(分页、排序、筛选))
- [- **国际化**:路径尽量英文,展示层面做本地化;或将语言做为前缀 `/zh-CN/...`](#- 国际化:路径尽量英文,展示层面做本地化;或将语言做为前缀
/zh-CN/...)
- [- **语义化**:资源化的路径命名,如 `/projects/:id/issues`](#- 语义化:资源化的路径命名,如
- [React 路由库选择](#React 路由库选择)
-
- [- **React Router v6+(CSR)**:主流 SPA 路由,支持嵌套、懒加载、数据路由(指在路由级别获取数据的能力(通过loader))。](#- React Router v6+(CSR):主流 SPA 路由,支持嵌套、懒加载、数据路由(指在路由级别获取数据的能力(通过loader))。)
- [- **Next.js App Router(SSR/混合渲染)**:基于文件系统的路由,更适合 SEO/性能要求高的站点。](#- Next.js App Router(SSR/混合渲染):基于文件系统的路由,更适合 SEO/性能要求高的站点。)
- [- **TanStack Router**:类型推断更强,适合大规模 TS 项目。](#- TanStack Router:类型推断更强,适合大规模 TS 项目。)
- [- 纯 SPA 且静态托管优先用 React Router;需要服务端数据首屏和 SEO 考虑 Next.js。](#- 纯 SPA 且静态托管优先用 React Router;需要服务端数据首屏和 SEO 考虑 Next.js。)
- [典型路由结构(React Router v6)](#典型路由结构(React Router v6))
- [典型路由结构(Next.js (App Router, v13+) )](#典型路由结构(Next.js (App Router, v13+) ))
- 嵌套路由与布局
-
- [- 使用"布局路由"(Layout + `<Outlet />`)承载共享 UI:导航、页脚、侧边栏。](#- 使用“布局路由”(Layout +
<Outlet />)承载共享 UI:导航、页脚、侧边栏。) - [- 子路由只渲染差异部分,提升可维护性与性能。](#- 子路由只渲染差异部分,提升可维护性与性能。)
- [- 代码示例](#- 代码示例)
- [- 使用"布局路由"(Layout + `<Outlet />`)承载共享 UI:导航、页脚、侧边栏。](#- 使用“布局路由”(Layout +
- 动态路由与参数管理
-
- [- 使用 `useParams()` 读取路径参数,`useSearchParams()` 管理查询参数。](#- 使用
useParams()读取路径参数,useSearchParams()管理查询参数。) - [- 将筛选器/分页同步到 URL,保证可分享与可回放。](#- 将筛选器/分页同步到 URL,保证可分享与可回放。)
- [- 代码示例](#- 代码示例)
- [- 使用 `useParams()` 读取路径参数,`useSearchParams()` 管理查询参数。](#- 使用
- 受保护路由与权限
-
- [- 粗粒度:登录态守卫(如上 `withAuth`)。](#- 粗粒度:登录态守卫(如上
withAuth)。) - [- 细粒度:在页面内部做权限点控制(按钮禁用/隐藏、接口 403 处理)。](#- 细粒度:在页面内部做权限点控制(按钮禁用/隐藏、接口 403 处理)。)
- [- 鉴权跳转保留 `next` 参数,实现登录后回跳。](#- 鉴权跳转保留
next参数,实现登录后回跳。) - [- 代码示例](#- 代码示例)
- [- 粗粒度:登录态守卫(如上 `withAuth`)。](#- 粗粒度:登录态守卫(如上
- 代码分割与性能
-
- [- 路由级懒加载 `lazy()` + `Suspense`,将页面切分为独立包。](#- 路由级懒加载
lazy()+Suspense,将页面切分为独立包。) - [- 以"路由段"为边界做代码与数据解耦;把"首屏必要"与"次屏可延后"拆开。](#- 以“路由段”为边界做代码与数据解耦;把“首屏必要”与“次屏可延后”拆开。)
- [- 预取:用户悬停导航项后预加载对应路由模块(配合自定义逻辑或框架支持)。](#- 预取:用户悬停导航项后预加载对应路由模块(配合自定义逻辑或框架支持)。)
- [- 路由级懒加载 `lazy()` + `Suspense`,将页面切分为独立包。](#- 路由级懒加载
- [错误处理与 NotFound](#错误处理与 NotFound)
-
- [- 使用 `errorElement` 或边界组件捕获渲染/加载错误。](#- 使用
errorElement或边界组件捕获渲染/加载错误。) - [- 对未知路由提供友好的 404,并引导回可达页面。](#- 对未知路由提供友好的 404,并引导回可达页面。)
- [- 使用 `errorElement` 或边界组件捕获渲染/加载错误。](#- 使用
- 历史记录与可用性
-
- [- 使用浏览器 History API(React Router 已封装)。](#- 使用浏览器 History API(React Router 已封装)。)
- [- 确保返回键语义明确;避免在 `useEffect` 中无条件 `navigate` 造成回退黑洞。](#- 确保返回键语义明确;避免在
useEffect中无条件navigate造成回退黑洞。) - [- 模态/抽屉路由可用 `background location` 技巧保留返回栈。](#- 模态/抽屉路由可用
background location技巧保留返回栈。)
- [SEO 与可访问性(纯 SPA 的权衡)](#SEO 与可访问性(纯 SPA 的权衡))
-
- [- 纯 CSR SEO 能力有限;如需 SEO,考虑 SSR/SSG(Next.js)或预渲染(Prerender/SPA Prerender)。](#- 纯 CSR SEO 能力有限;如需 SEO,考虑 SSR/SSG(Next.js)或预渲染(Prerender/SPA Prerender)。)
- [- 保持标题与描述同步:在路由切换时更新 `document.title` 与 meta。](#- 保持标题与描述同步:在路由切换时更新
document.title与 meta。) - [- 使用语义化标签与可访问性属性,确保读屏与键盘导航。](#- 使用语义化标签与可访问性属性,确保读屏与键盘导航。)
- 国际化与多租户
-
- [- 多语言:`/:locale/...` 或域名/子域区分;路由表按 locale 切换展示文案。](#- 多语言:
/:locale/...或域名/子域区分;路由表按 locale 切换展示文案。) - [- 多租户:`/:tenantId/...` 作为路径前缀;确保权限与数据域的隔离。](#- 多租户:
/:tenantId/...作为路径前缀;确保权限与数据域的隔离。)
- [- 多语言:`/:locale/...` 或域名/子域区分;路由表按 locale 切换展示文案。](#- 多语言:
- 微前端与子应用承载
-
- [- 预留 `/*` 通配路由给子应用挂载点(如 qiankun / Module Federation)。](#- 预留
/*通配路由给子应用挂载点(如 qiankun / Module Federation)。) - [- 跨应用导航建议统一网关与约定,避免循环嵌套与历史栈混乱。](#- 跨应用导航建议统一网关与约定,避免循环嵌套与历史栈混乱。)
- [- 预留 `/*` 通配路由给子应用挂载点(如 qiankun / Module Federation)。](#- 预留
- 测试策略
-
- [- 单元测试:对包含 `useParams`/`useSearchParams` 的组件,用 `MemoryRouter` 注入初始条目。](#- 单元测试:对包含
useParams/useSearchParams的组件,用MemoryRouter注入初始条目。) - [- 端到端测试:使用 Playwright/Cypress,覆盖关键流转与回退行为。](#- 端到端测试:使用 Playwright/Cypress,覆盖关键流转与回退行为。)
- [- 代码示例](#- 代码示例)
- [- 单元测试:对包含 `useParams`/`useSearchParams` 的组件,用 `MemoryRouter` 注入初始条目。](#- 单元测试:对包含
- 常见陷阱与规避
-
- [- **服务端未配置 History 回退**:刷新 404。需将未知路径回退到 `index.html`。](#- 服务端未配置 History 回退:刷新 404。需将未知路径回退到
index.html。) - [- **把 UI 状态塞进全局 store**:优先用 URL 表达可分享状态,减少隐式耦合。](#- 把 UI 状态塞进全局 store:优先用 URL 表达可分享状态,减少隐式耦合。)
- [- **在副作用中循环导航**:注意条件与依赖,避免导航抖动与回退黑洞。](#- 在副作用中循环导航:注意条件与依赖,避免导航抖动与回退黑洞。)
- [- **路径大小写/尾斜杠不一致**:统一规范与重定向策略。](#- 路径大小写/尾斜杠不一致:统一规范与重定向策略。)
- [- **过度嵌套路由**:保持两层为主,更多使用组合组件而非深层路由。](#- 过度嵌套路由:保持两层为主,更多使用组合组件而非深层路由。)
- [- **服务端未配置 History 回退**:刷新 404。需将未知路径回退到 `index.html`。](#- 服务端未配置 History 回退:刷新 404。需将未知路径回退到
- 最佳实践清单
-
- [- 路由为"信息架构",URL 语义化并稳定。](#- 路由为“信息架构”,URL 语义化并稳定。)
- [- 以"路由段"为边界做懒加载与数据获取。](#- 以“路由段”为边界做懒加载与数据获取。)
- [- 参数与筛选同步到 URL,确保可分享与可回放。](#- 参数与筛选同步到 URL,确保可分享与可回放。)
- [- 登录态用守卫拦截,权限点在页面内细粒度控制。](#- 登录态用守卫拦截,权限点在页面内细粒度控制。)
- [- 合理的 404/错误边界与回退行为。](#- 合理的 404/错误边界与回退行为。)
- [- 为迁移/扩展(国际化、微前端)预留结构与前缀。](#- 为迁移/扩展(国际化、微前端)预留结构与前缀。)
- 简化的页面示例
- 结语
为什么要重视路由设计
- 用户体验:路由关乎首屏加载、页面切换、历史回退的顺滑程度。
- 可维护性:清晰的路由模型决定了模块边界、权限边界与代码分割边界。
- 可观测性与增长:良好的路由结构让埋点、A/B、SEO(SSR/预渲染场景下)更可控。
SPA 路由基础与术语
- 路由模式
- Hash 路由:/#/path,无需后端配合,适合静态托管。
- History 路由:/path,需服务端进行回退到 index.html 的兜底配置,URL 更优雅。
- 路径类型
- 静态:/about
- 动态:/users/:userId
- 可选参数:/search/:tab?
- 通配符:/* 用于 404 或微前端子应用承载
- 导航方式
- 声明式
<Link to="/..."> - 命令式
navigate('/...')
- 状态与副作用
- URL 是"外部状态",应尽量可序列化(查询参数、路径参数)。
- 与 UI 状态解耦(例如模态、筛选器同步到 URL)。
URL 设计原则
- 语义化 :资源化的路径命名,如 /projects/:id/issues
- 稳定性 :避免业务词汇频繁变动导致的 URL 变更;必要时做 301/302 重定向
- 最小必要信息:优先用路径参数表达主实体,用查询参数表达筛选/分页
- 可分享性:URL 应能完整复现页面状态(分页、排序、筛选)
- 国际化 :路径尽量英文,展示层面做本地化;或将语言做为前缀 /zh-CN/...
React 路由库选择
- React Router v6+(CSR):主流 SPA 路由,支持嵌套、懒加载、数据路由(指在路由级别获取数据的能力(通过loader))。
- Next.js App Router(SSR/混合渲染):基于文件系统的路由,更适合 SEO/性能要求高的站点。
- TanStack Router:类型推断更强,适合大规模 TS 项目。
- 纯 SPA 且静态托管优先用 React Router;需要服务端数据首屏和 SEO 考虑 Next.js。
典型路由结构(React Router v6)
ts
// routes.tsx
import { createBrowserRouter, redirect } from 'react-router-dom'; // 1. 导入路由核心函数
import { lazy, Suspense } from 'react'; // 2. 导入懒加载和加载状态组件
const Layout = lazy(() => import('./layouts/Layout')); // 3. 懒加载布局组件(按需加载)
const Home = lazy(() => import('./pages/Home')); // 4. 懒加载首页
const Projects = lazy(() => import('./pages/Projects')); // 5. 懒加载项目列表页
const ProjectDetail = lazy(() => import('./pages/ProjectDetail')); // 6. 懒加载项目详情页
const Settings = lazy(() => import('./pages/Settings')); // 7. 懒加载设置页
const NotFound = lazy(() => import('./pages/NotFound')); // 8. 懒加载404页
const SignIn = lazy(() => import('./pages/SignIn')); // 9. 懒加载登录页
const withAuth = // 10. 高阶组件:创建权限保护函数
(element: JSX.Element) => // 11. 接收组件作为参数
({ request }: { request: Request }) => { // 12. 接收路由请求对象
const isAuthed = Boolean(localStorage.getItem('token')); // 13. 检查本地存储是否有token
if (!isAuthed) { // 14. 未登录时
throw redirect(`/signin?next=${encodeURIComponent(new URL(request.url).pathname)}`); // 15. 重定向到登录页并携带目标路径
}
return element; // 16. 已登录则返回原组件
};
export const router = createBrowserRouter([ // 17. 创建路由配置
{
path: '/', // 18. 根路径
element: ( // 19. 根路由渲染的组件
<Suspense fallback={<div>Loading...</div>}> // 20. 懒加载的加载状态
<Layout /> // 21. 渲染布局组件
</Suspense>
),
errorElement: <NotFound />, // 22. 错误时渲染404页
children: [ // 23. 子路由配置
{ index: true, element: <Home /> }, // 24. 默认子路由(首页)
{
path: 'projects', // 25. 项目路径
children: [ // 26. 项目子路由
{ index: true, element: <Projects /> }, // 27. 项目列表页(默认)
{ path: ':projectId', element: <ProjectDetail /> }, // 28. 项目详情页(动态参数)
],
},
{ path: 'settings/*', element: withAuth(<Settings />) }, // 29. 设置页(受保护+通配符)
],
},
{ path: '/signin', element: <SignIn /> }, // 30. 登录页
{ path: '*', element: <NotFound /> }, // 31. 404通配符
]);
ts
// main.tsx
import React from 'react'; // 32. 导入React核心库
import ReactDOM from 'react-dom/client'; // 33. 导入ReactDOM
import { RouterProvider } from 'react-router-dom'; // 34. 导入路由提供者组件
import { router } from './routes'; // 35. 导入路由配置
ReactDOM.createRoot(document.getElementById('root')!).render( // 36. 挂载根节点
<React.StrictMode> // 37. 开启严格模式(检测潜在问题)
<RouterProvider router={router} /> // 38. 使用路由提供者渲染路由
</React.StrictMode>
);
典型路由结构(Next.js (App Router, v13+) )
参考文章:Web典型路由结构之Next.js (App Router, v13+) )(文件系统驱动的路由:File-based Routing)
嵌套路由与布局
- 使用"布局路由"(Layout + <Outlet />)承载共享 UI:导航、页脚、侧边栏。
- 子路由只渲染差异部分,提升可维护性与性能。
- 代码示例
ts
// layouts/Layout.tsx
import { Outlet, NavLink } from 'react-router-dom'; // 39. 导入Outlet(路由出口)和NavLink(导航链接)
export default function Layout() { // 40. 布局组件
return (
<div className="app"> // 41. 应用容器
<nav> // 42. 导航栏
<NavLink to="/">首页</NavLink> // 43. 首页链接
<NavLink to="/projects">项目</NavLink> // 44. 项目列表链接
<NavLink to="/settings/profile">设置</NavLink> // 45. 设置链接
</nav>
<main> // 46. 主内容区域
<Outlet /> // 47. 路由内容将在此处渲染(子路由内容)
</main>
</div>
);
}
动态路由与参数管理
- 使用 useParams() 读取路径参数,useSearchParams() 管理查询参数。
- 将筛选器/分页同步到 URL,保证可分享与可回放。
- 代码示例
ts
// pages/Projects.tsx
import { useSearchParams, Link } from 'react-router-dom'; // 48. 导入查询参数管理钩子和链接组件
export default function Projects() { // 49. 项目列表页
const [searchParams, setSearchParams] = useSearchParams(); // 50. 解构查询参数和更新函数
const page = Number(searchParams.get('page') ?? 1); // 51. 获取分页参数(默认1)
const q = searchParams.get('q') ?? ''; // 52. 获取搜索关键词
return (
<div> // 53. 项目列表容器
<input // 54. 搜索输入框
value={q} // 55. 绑定当前搜索词
onChange={(e) => setSearchParams({ q: e.target.value, page: '1' })} // 56. 输入时更新URL参数(重置分页)
placeholder="搜索项目" // 57. 占位符
/>
<ul> // 58. 项目列表
<li><Link to="/projects/alpha">Alpha</Link></li> // 59. 项目链接
<li><Link to="/projects/beta">Beta</Link></li> // 60. 项目链接
</ul>
<button // 61. 下一页按钮
onClick={() => setSearchParams({ q, page: String(page + 1) })} // 62. 点击时更新分页参数
>
下一页
</button>
</div>
);
}
受保护路由与权限
- 粗粒度:登录态守卫(如上 withAuth)。
- 细粒度:在页面内部做权限点控制(按钮禁用/隐藏、接口 403 处理)。
- 鉴权跳转保留 next 参数,实现登录后回跳。
- 代码示例
ts
// pages/SignIn.tsx
import { useLocation, useNavigate } from 'react-router-dom'; // 63. 导入位置和导航钩子
export default function SignIn() { // 64. 登录页
const navigate = useNavigate(); // 65. 获取导航函数
const { search } = useLocation(); // 66. 获取当前URL的查询参数
const next = new URLSearchParams(search).get('next') ?? '/'; // 67. 解析next参数(默认跳转首页)
const onSubmit = async () => { // 68. 提交处理函数
localStorage.setItem('token', 'mock'); // 69. 本地存储模拟token
navigate(next, { replace: true }); // 70. 跳转到目标路径(替换历史记录)
};
return <button onClick={onSubmit}>登录</button>; // 71. 登录按钮
}
代码分割与性能
- 路由级懒加载 lazy() + Suspense,将页面切分为独立包。
- 以"路由段"为边界做代码与数据解耦;把"首屏必要"与"次屏可延后"拆开。
- 预取:用户悬停导航项后预加载对应路由模块(配合自定义逻辑或框架支持)。
错误处理与 NotFound
- 使用 errorElement 或边界组件捕获渲染/加载错误。
- 对未知路由提供友好的 404,并引导回可达页面。
历史记录与可用性
- 使用浏览器 History API(React Router 已封装)。
- 确保返回键语义明确;避免在 useEffect 中无条件 navigate 造成回退黑洞。
- 模态/抽屉路由可用 background location 技巧保留返回栈。
SEO 与可访问性(纯 SPA 的权衡)
- 纯 CSR SEO 能力有限;如需 SEO,考虑 SSR/SSG(Next.js)或预渲染(Prerender/SPA Prerender)。
- 保持标题与描述同步:在路由切换时更新 document.title 与 meta。
- 使用语义化标签与可访问性属性,确保读屏与键盘导航。
国际化与多租户
- 多语言:/:locale/... 或域名/子域区分;路由表按 locale 切换展示文案。
- 多租户:/:tenantId/... 作为路径前缀;确保权限与数据域的隔离。
微前端与子应用承载
- 预留 /* 通配路由给子应用挂载点(如 qiankun / Module Federation)。
- 跨应用导航建议统一网关与约定,避免循环嵌套与历史栈混乱。
测试策略
- 单元测试:对包含 useParams/useSearchParams 的组件,用 MemoryRouter 注入初始条目。
- 端到端测试:使用 Playwright/Cypress,覆盖关键流转与回退行为。
- 代码示例
ts
// 示例:组件测试
import { render, screen } from '@testing-library/react'; // 72. 导入测试工具
import { MemoryRouter, Route, Routes } from 'react-router-dom'; // 73. 导入内存路由模拟器
import ProjectDetail from './ProjectDetail'; // 74. 导入待测组件
test('展示项目 ID', () => { // 75. 测试用例
render( // 76. 渲染测试组件
<MemoryRouter initialEntries={['/projects/42']}> // 77. 模拟初始URL为/projects/42
<Routes> // 78. 路由配置
<Route path="/projects/:projectId" element={<ProjectDetail />} /> // 79. 配置测试路由
</Routes>
</MemoryRouter>
);
expect(screen.getByText(/Project: 42/)).toBeInTheDocument(); // 80. 验证文本存在
});
常见陷阱与规避
- 服务端未配置 History 回退 :刷新 404。需将未知路径回退到 index.html。
- 把 UI 状态塞进全局 store:优先用 URL 表达可分享状态,减少隐式耦合。
- 在副作用中循环导航:注意条件与依赖,避免导航抖动与回退黑洞。
- 路径大小写/尾斜杠不一致:统一规范与重定向策略。
- 过度嵌套路由:保持两层为主,更多使用组合组件而非深层路由。
最佳实践清单
- 路由为"信息架构",URL 语义化并稳定。
- 以"路由段"为边界做懒加载与数据获取。
- 参数与筛选同步到 URL,确保可分享与可回放。
- 登录态用守卫拦截,权限点在页面内细粒度控制。
- 合理的 404/错误边界与回退行为。
- 为迁移/扩展(国际化、微前端)预留结构与前缀。
简化的页面示例
ts
// pages/ProjectDetail.tsx
import { useParams } from 'react-router-dom'; // 81. 导入路径参数钩子
export default function ProjectDetail() { // 82. 项目详情页
const { projectId } = useParams<{ projectId: string }>(); // 83. 获取路径参数(类型安全)
return <div>Project: {projectId}</div>; // 84. 渲染项目ID
}
ts
// pages/Settings/index.tsx
import { Outlet, NavLink } from 'react-router-dom'; // 85. 导入Outlet和NavLink
export default function Settings() { // 86. 设置页
return (
<div> // 87. 设置容器
<h1>设置</h1> // 88. 标题
<nav> // 89. 设置导航
<NavLink to="profile">个人资料</NavLink> // 90. 个人资料链接(相对路径)
<NavLink to="billing">账单</NavLink> // 91. 账单链接
</nav>
<Outlet /> // 92. 子路由内容在此渲染
</div>
);
}
结语
良好的 SPA 路由设计是"信息架构 + 工程实践"的结合。以 React 为例,围绕 URL 语义、嵌套路由、代码分割与鉴权构建清晰的路由树,可以在保证用户体验的同时显著提升项目的可维护性与演进空间。需要 SEO/首屏性能时,再升级到 SSR/SSG 架构(如 Next.js),延续相同的路由与信息架构思想即可。