Skip to main content

React Router

一、概述

单页面应用(SPA)的核心是路由:在不刷新整个页面的前提下,根据 URL 切换视图。React Router 是 React 生态里事实标准(v6 已成主流),核心思路:

  1. 监听 URL 变化——通过 popstate(History 模式)或 hashchange(Hash 模式)事件
  2. 匹配路由表——根据 URL 找到对应的组件
  3. 触发重新渲染——更新 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/userexample.com/#/user
部署要求需要服务端把所有路径 fallback 到 index.html✅ 静态文件即可(不需后端配合)
SEO✅ 友好❌ 搜索引擎通常不抓 # 后内容
原理History API + popstatelocation.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-routerRouterreact-router-dom 只是多了 DOM 绑定)。它们会维护一个路由 state,URL 变化时触发 state 更新,触发 Router 重渲染,重新执行路由匹配。

3.1 三种 Router 对比

Router底层state 来源适用场景
BrowserRouterHistory APIwindow.location主流 Web 应用
HashRouterlocation.hashlocation.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 内部做的事

  1. 遍历所有 Route,拿到 path
  2. 用 URL 路径和 path 做匹配(支持动态参数、嵌套、通配)
  3. 只渲染第一个匹配到的 Route(v5 是顺序匹配,可能多个匹配)
  4. 把匹配信息(params、search 等)通过 Context 传给匹配到的组件

4.2 path 匹配规则

语法含义示例
/精确匹配根路径/
*通配,匹配任意路径<Route path="*" element={<NotFound />} />
:id动态参数/user/:id/user/123id = 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

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 内部做的事

  1. 阻止 <a> 默认跳转
  2. 调用 history.push(to)
  3. history 触发 setLocation(被 Router 拦截过)
  4. 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>
</>
);
}

navigate vs <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>
);
}

原理

  1. webpack / vite 看到 import() 语法,把目标模块打成独立 chunk(不会进入主 bundle)
  2. 路由被匹配时,触发 Home 的渲染
  3. React 调用 lazy(() => import(...))异步加载 chunk
  4. 加载期间渲染 Suspensefallback(Loading 状态)
  5. chunk 加载完,组件渲染

进阶优化

  • 预加载:用 route.lazy + 路由表的 loader 函数(v6.4+ Data Router)
  • 按组件粒度懒加载:不要整个页面都打包,只懒加载大组件(如富文本编辑器、ECharts 图表)
  • 避免瀑布流:父组件 lazy 后,子组件再 lazy 会形成 Promise 链。用 preload API 或工具库(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 简化:不再需要 exactstrict 这些严格匹配的修饰词
  • 用户点击触发 → 用 <Link>(可访问性、SEO、prefetch)
  • 程序逻辑触发(如"提交成功跳转") → 用 useNavigate

Q4: 路由懒加载为什么要包 Suspense

lazy(() => import(...)) 加载期间返回的是一个 Promise,渲染时会抛"未加载完"的异常。Suspense 捕获这个异常并渲染 fallback UI。

如果不包 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 自己的代码分割机制。