React Router
一、概述
单页面应用(SPA)的核心是路由:在不刷新整个页面的前提下,根据 URL 切换视图。React Router 是 React 生态里事实标准(v6 已成主流),核心思路:
- 监听 URL 变化——通过
popstate(History 模式)或hashchange(Hash 模式)事件 - 匹配路由表——根据 URL 找到对应的组件
- 触发重新渲染——更新 Router 内部的 state,重新执行匹配逻辑,切换页面
整个过程都在不重新请求服务器的前提下完成,给用户"原生 App 般的体验"。
二、底层原理:两种路由实现
React Router v6 抹平了 History 和 Hash 两种模式的差异,但理解底层有助于排查奇怪 bug(比如刷新 404、Hash 模式路由难看)。
2.1 History API —— BrowserRouter 的底座
浏览器原生提供 3 个 API 来操作历史记录:
// 1. 压栈:产生新历史记录(可后退)
history.pushState(state, title, url);
// 2. 替换:替换当前历史记录(不可后退)
history.replaceState(state, title, url);
// 3. 后退 / 前进
history.back();
history.forward();
history.go(-1);
关键特性:pushState / replaceState 不会触发 popstate 事件,只有浏览器的前进/后退按钮才会触发。所以 Router 需要重写这两个方法,主动触发自己的监听器。
// 简化的 Router 内部实现
function createBrowserHistory() {
const listeners = [];
const handlePop = () => listeners.forEach((fn) => fn(location));
// 重写 pushState / replaceState
const originalPush = history.pushState;
history.pushState = function (...args) {
originalPush.apply(this, args);
listeners.forEach((fn) => fn(location)); // 主动通知
};
window.addEventListener("popstate", handlePop);
return {
push: (path) => history.pushState({}, "", path),
replace: (path) => history.replaceState({}, "", path),
listen: (fn) => listeners.push(fn),
};
}
2.2 Hash 路由 —— HashRouter 的底座
利用 URL 的 hash 部分(# 后面的内容)变化触发 hashchange 事件:
// http://example.com/#/user/list
// location.hash === "#/user/list"
window.addEventListener("hashchange", () => {
console.log("URL 变了", location.hash);
});
// 修改 URL(会触发 hashchange)
window.location.hash = "/user/detail";
优劣对比:
| 维度 | History 模式 | Hash 模式 |
|---|---|---|
| URL 美观度 | ✅ example.com/user | ❌ example.com/#/user |
| 部署要求 | 需要服务端把所有路径 fallback 到 index.html | ✅ 静态文件即可(不需后端配合) |
| SEO | ✅ 友好 | ❌ 搜索引擎通常不抓 # 后内容 |
| 原理 | History API + popstate | location.hash + hashchange |
| 何时用 | 主流选择 | 老项目、静态托管、不想动 Nginx |
404 陷阱:用 History 模式部署时,必须让服务端把所有未匹配的资源请求都 fallback 到
index.html。否则刷新页面或直接访问子路由时(如example.com/user/1),服务器返回 404 而不是 SPA 入口。Nginx 配置示例:location / {
try_files $uri $uri/ /index.html;
}
三、Router 组件家族
所有 Router 组件都基于 react-router 的 Router(react-router-dom 只是多了 DOM 绑定)。它们会维护一个路由 state,URL 变化时触发 state 更新,触发 Router 重渲染,重新执行路由匹配。
3.1 三种 Router 对比
| Router | 底层 | state 来源 | 适用场景 |
|---|---|---|---|
BrowserRouter | History API | window.location | 主流 Web 应用 |
HashRouter | location.hash | location.hash | 静态托管、无后端配合 |
MemoryRouter | 内存数组 | React 内部 state | 测试环境、React Native |
import { BrowserRouter, HashRouter, MemoryRouter } from "react-router-dom";
<BrowserRouter>
<App />
</BrowserRouter>;
3.2 内部 state 管理原理
Router 内部用 React useState 维护一个 location 对象:
// 简化源码
function BrowserRouter({ children }) {
const [location, setLocation] = useState(window.location);
useEffect(() => {
const handlePop = () => setLocation(window.location);
window.addEventListener("popstate", handlePop);
// 拦截 pushState / replaceState
const wrap =
(type) =>
(original) =>
(...args) => {
original.apply(history, args);
setLocation(window.location);
};
history.pushState = wrap("push")(history.pushState);
history.replaceState = wrap("replace")(history.replaceState);
return () => window.removeEventListener("popstate", handlePop);
}, []);
return (
<RouterContext.Provider value={{ location, setLocation }}>
{children}
</RouterContext.Provider>
);
}
关键点:URL 变化 → setLocation → Router 重渲染 → Routes 重新匹配 → 切换组件。
四、Route 匹配机制
4.1 Routes 与 Route
v6 重写了路由配置,不再用 <Switch>,而是用 <Routes> 容器包多个 <Route>:
import { Routes, Route } from "react-router-dom";
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user" element={<User />} />
<Route path="/user/:id" element={<UserDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>;
Routes 内部做的事:
- 遍历所有
Route,拿到path - 用 URL 路径和
path做匹配(支持动态参数、嵌套、通配) - 只渲染第一个匹配到的 Route(v5 是顺序匹配,可能多个匹配)
- 把匹配信息(params、search 等)通过 Context 传给匹配到的组件
4.2 path 匹配规则
| 语法 | 含义 | 示例 |
|---|---|---|
/ | 精确匹配根路径 | / |
* | 通配,匹配任意路径 | <Route path="*" element={<NotFound />} /> |
:id | 动态参数 | /user/:id → /user/123 中 id = 123 |
* (在路径里) | 任意后缀 | /user/* 匹配 /user/123/edit |
| 嵌套 | 子 Route 继承父路径 | 见下节 |
4.3 嵌套路由与 Outlet
<Routes>
<Route path="/user" element={<UserLayout />}>
<Route index element={<UserList />} /> {/* /user */}
<Route path=":id" element={<UserDetail />} /> {/* /user/123 */}
<Route path="new" element={<UserForm />} /> {/* /user/new */}
</Route>
</Routes>;
function UserLayout() {
return (
<div>
<h1>用户中心</h1>
<Outlet /> {/* 关键:子 Route 渲染在这里 */}
</div>
);
}
设计点:
Outlet是嵌套路由的核心——父组件通过Outlet声明"子路由渲染的位置",这样既能保持共享布局(侧边栏、Header),又能切换内部页面。
五、导航 API
5.1 Link 与 NavLink
import { Link, NavLink } from "react-router-dom";
// Link:等价于 <a>,但阻止默认跳转,改用 history.pushState
<Link to="/user/123">用户详情</Link>
// NavLink:当前 URL 匹配 to 时自动添加 active class
<NavLink
to="/user"
className={({ isActive }) => isActive ? "active" : ""}
>
用户
</NavLink>
Link 内部做的事:
- 阻止
<a>默认跳转 - 调用
history.push(to) - history 触发
setLocation(被 Router 拦截过) - Router 重渲染,匹配到目标 Route,组件切换
5.2 编程式导航:useNavigate
import { useNavigate } from "react-router-dom";
function MyComponent() {
const navigate = useNavigate();
return (
<>
<button onClick={() => navigate("/user/123")}>跳详情</button>
<button onClick={() => navigate(-1)}>返回</button>
<button onClick={() => navigate("/login", { replace: true })}>
替换式跳转(不留历史记录)
</button>
</>
);
}
navigatevs<Link>:用户点击用<Link>(保证可访问性、SEO、prefetch);程序逻辑触发用navigate。
六、5 大常用 Hooks
import {
useLocation,
useParams,
useSearchParams,
useMatch,
useBlocker,
} from "react-router-dom";
| Hook | 用途 | 典型场景 |
|---|---|---|
useLocation | 获取当前 location 对象 | 面包屑、判断当前页 |
useParams | 获取动态路由参数 | 详情页拿 id |
useSearchParams | 获取 / 设置 URL query | 搜索、分页、过滤 |
useMatch | 判断当前 URL 是否匹配某个 path | 菜单高亮 |
useBlocker | 拦截路由跳转 | 编辑未保存提示 |
6.1 useLocation
function Page() {
const location = useLocation();
console.log(location.pathname); // "/user/123"
console.log(location.search); // "?tab=info"
console.log(location.state); // navigate 传的第 2 个参数
console.log(location.hash); // "#section"
}
6.2 useParams
// 路由:<Route path="/user/:id" element={<UserDetail />} />
function UserDetail() {
const { id } = useParams();
// URL: /user/123 → id = "123"
}
6.3 useSearchParams
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("q"); // 读取
const page = Number(searchParams.get("page") || 1);
return (
<input
defaultValue={keyword}
onChange={(e) => setSearchParams({ q: e.target.value })} // 设置
/>
);
}
6.4 useBlocker(v6.7+)
拦截路由跳转,常用于"编辑未保存"提示:
function EditForm() {
const [isDirty, setIsDirty] = useState(false);
// 当 isDirty 为 true 时,阻止任何导航操作
useBlocker(({ currentLocation, nextLocation }) => {
if (!isDirty) return false;
return !window.confirm("编辑未保存,确定离开吗?");
});
// ...
}
⚠️ 注意:v6.7+ 才稳定,Data Router 模式下才生效(需要用
createBrowserRouter)。如不支持可用usePrompt自定义。
七、路由守卫
常见场景:登录鉴权、权限拦截、角色判断。
思路:写一个 AuthRoute 组件,包裹需要鉴权的页面,未登录则重定向到登录页。
import { Navigate, useLocation } from "react-router-dom";
function AuthRoute({ children, requireAuth = true }) {
const isLoggedIn = useSelector((state) => state.user.isLoggedIn);
const location = useLocation();
if (requireAuth && !isLoggedIn) {
// 没登录 → 重定向到 /login,state 带原路径
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
return children;
}
// 用法
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/user"
element={
<AuthRoute>
<User />
</AuthRoute>
}
/>
</Routes>;
递归处理:如果守卫里又有子路由需要鉴权,可以把
AuthRoute嵌套到父 Route 的 element 里:<Route
path="/admin"
element={
<AuthRoute requireAdmin>
<AdminLayout />
</AuthRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="users" element={<UserManager />} />
</Route>
八、路由懒加载
利用 ES Module 的动态 import + React 的 lazy + Suspense,把每个路由组件切成独立 chunk,按需加载:
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
const Home = lazy(() => import("./pages/Home"));
const User = lazy(() => import("./pages/User"));
const NotFound = lazy(() => import("./pages/NotFound"));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user" element={<User />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
);
}
原理:
- webpack / vite 看到
import()语法,把目标模块打成独立 chunk(不会进入主 bundle) - 路由被匹配时,触发
Home的渲染 - React 调用
lazy(() => import(...)),异步加载 chunk - 加载期间渲染
Suspense的fallback(Loading 状态) - chunk 加载完,组件渲染
进阶优化:
- 预加载:用
route.lazy+ 路由表的loader函数(v6.4+ Data Router)- 按组件粒度懒加载:不要整个页面都打包,只懒加载大组件(如富文本编辑器、ECharts 图表)
- 避免瀑布流:父组件 lazy 后,子组件再 lazy 会形成 Promise 链。用
preloadAPI 或工具库(react-imported-component)预取
九、面试高频 Q&A
Q1: Hash 路由和 History 路由的区别?
见上文对比表。一句话:Hash 路由 URL 带
#、不需要服务端配合;History 路由 URL 干净、但必须服务端 fallback 否则刷新 404。
Q2: 为什么 React Router v6 用 <Routes> 替代了 v5 的 <Switch>?
- 匹配规则:v5
<Switch>是顺序匹配,多个匹配会渲染第一个;v6<Routes>是按最佳匹配,更智能- 语法:v6 直接用
element属性而不是render/component,嵌套更直观- API 简化:不再需要
exact、strict这些严格匹配的修饰词
Q3: useNavigate 和 <Link> 怎么选?
- 用户点击触发 → 用
<Link>(可访问性、SEO、prefetch)- 程序逻辑触发(如"提交成功跳转") → 用
useNavigate
Q4: 路由懒加载为什么要包 Suspense?
lazy(() => import(...))加载期间返回的是一个 Promise,渲染时会抛"未加载完"的异常。Suspense捕获这个异常并渲染fallbackUI。如果不包
Suspense,React 会强制刷新整个组件树才能显示 fallback,UX 不好。
Q5: Outlet 是干嘛的?
嵌套路由里子路由的渲染出口。父组件用
<Outlet />标记"子路由渲染到哪",保留共享布局(Header/Sidebar)的同时切换内部页面。
Q6: 路由守卫里如何拿到用户原本想访问的路径?
用
useLocation()拿当前路径,作为state传给<Navigate>:<Navigate to="/login" state={{ from: location.pathname }} replace />登录成功后从
location.state.from取出来跳转回去。
Q7: 动态 import 在 SSR 里能用吗?
Next.js 用
dynamic()包了一层 lazy,但原始的React.lazy在 SSR 阶段不支持(服务端没有异步加载概念)。如需 SSR 路由懒加载,用 Next.js 自己的代码分割机制。