
刚开始学 React Router 时,我把它理解成"点击链接,换一个组件"。这个说法不能算错,但它解释不了几个很快就会遇到的问题:
- 为什么地址栏变了,页面却没有整页刷新?
/user/123里的123怎么交给组件?- 为什么父路由里还需要一个
<Outlet />? - 未登录访问支付页,登录后怎样回到原来的页面?
- 本地开发一切正常,部署后刷新
/products/42为什么变成服务器 404?
继续拆解这个示例后,我更愿意把 React Router 理解成一套 URL 状态与 React 组件树之间的映射规则。页面跳转只是表象,路由匹配、代码分割、历史记录、导航状态和权限边界才是它真正需要处理的事情。
本文基于项目中的 React 19.2.6、React Router DOM 7.18.2 和 Vite 8.2.0 示例展开,使用的是 React Router 的 Declarative Mode。
一、前端路由到底解决了什么?
传统多页应用点击链接时,浏览器通常会向服务器请求另一份 HTML 文档。旧页面卸载、新页面重新加载,中间可能出现明显的白屏,JavaScript 状态也会随页面重载丢失。
SPA(Single Page Application,单页应用)通常只在首次访问时加载主 HTML。之后的应用内导航大致经历三步:
- 修改浏览器当前 URL;
- 路由器根据 URL 匹配路由规则;
- React 渲染匹配到的组件树。
这三步不需要为每一次内部导航重新请求整份 HTML,因此页面切换更连贯,应用状态也更容易保留。
但"前端路由"与 RESTful API 不是同一个概念。前者决定浏览器端显示哪个页面,后者更多是在描述服务端资源接口。它们都使用 URL,却负责不同层次的问题。
二、这个项目用的是 BrowserRouter,不是 HashRouter
原学习笔记多次提到 Hash Router,但代码实际导入的是:
jsx
import { BrowserRouter as Router } from "react-router-dom";
这意味着当前项目使用的是 BrowserRouter。它通过浏览器 History API 管理导航,典型 URL 是:
text
https://example.com/products/42
HashRouter 才会把路由放在 # 后面:
text
https://example.com/#/products/42
两者的关键区别可以这样记:
| 对比项 | BrowserRouter | HashRouter |
|---|---|---|
| URL 形式 | /products/42 |
/#/products/42 |
| 底层依据 | History API | location.hash |
| 服务端是否收到路由路径 | 会 | # 后内容不会发送给服务器 |
| 部署要求 | 服务器需要 SPA fallback | 静态托管更省配置 |
| URL 外观 | 更自然 | 带 # |
所以,代码注释中的"当前 location.hash"不适用于这里的 BrowserRouter。路由器匹配的是当前 location 中的 pathname。
三、先搭起最小路由骨架
这个项目的核心结构可以缩成下面几行:
jsx
import {
BrowserRouter,
Link,
Navigate,
Route,
Routes,
} from "react-router-dom";
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/home">首页</Link>
<Link to="/about">关于</Link>
</nav>
<Routes>
<Route path="/" element={<Navigate to="/home" replace />} />
<Route path="/home" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}
这些组件各自只负责一件事:
| API | 职责 |
|---|---|
BrowserRouter |
监听浏览器地址变化,为后代组件提供路由上下文 |
Routes |
在子路由中挑选当前最佳匹配 |
Route |
声明路径与页面元素之间的映射 |
Link |
进行应用内声明式导航,不重新请求整份 HTML |
Navigate |
在渲染阶段执行声明式重定向 |
<Navigate to="/home" replace /> 中的 replace 会替换当前历史记录,而不是再压入一条记录。这样用户从 / 被重定向到 /home 后,点击浏览器后退不会又回到那个只负责跳转的入口页。
应用内部链接通常使用 Link;真正离开当前站点的外部链接仍然应该使用普通 <a href="...">。

四、路由懒加载:先下载当前真正需要的页面
如果所有页面都在入口文件里静态导入,首页首次打开时就要下载和解析所有页面代码,即使用户根本不会进入其中大部分页面。
项目使用 React.lazy 配合动态 import():
jsx
import { lazy, Suspense } from "react";
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
const ProductDetail = lazy(() => import("./pages/Productx/Detail"));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/home" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products/:productId" element={<ProductDetail />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
这里必须同时理解三个环节:
- 动态
import()告诉打包器这里可以拆分代码; lazy把异步模块包装成 React 组件;Suspense在模块尚未加载时显示 fallback。
我用当前项目执行 npm run build 后,Vite 确实生成了独立的 Home、About、Detail、Login、Pay、ProtectRoute 等 chunk。这说明路由级代码分割已经发生,而不只是代码写法看起来像"懒加载"。
不过懒加载不是越细越好。页面拆得过碎会增加请求和加载状态切换,首次进入某个页面也可能出现短暂等待。适合按页面边界或明显较重的功能拆分,而不是把每个小组件都改成动态导入。
五、动态路由:把 URL 的一段交给组件
用户详情页的路由写成:
jsx
<Route path="/user/:id" element={<UserProfile />} />
冒号开头的 :id 是动态参数。访问 /user/123 时,可以在组件中通过 useParams 读取:
jsx
import { useParams } from "react-router-dom";
function UserProfile() {
const { id } = useParams();
return <h2>User Profile: {id}</h2>;
}
路由参数来自 URL,是字符串,也是不可信输入。真实业务中如果要用它请求接口,还应该校验格式并处理加载、空数据和请求失败状态。
六、嵌套路由:父页面提供布局,Outlet 决定子页面位置
产品模块包含一个父路由和两个子路由:
jsx
<Route path="/products" element={<Products />}>
<Route path="new" element={<NewProduct />} />
<Route path=":productId" element={<ProductDetail />} />
</Route>
子路由没有以 / 开头,因此它们相对于父路径匹配:
new对应/products/new;:productId对应/products/42。
父组件必须提供子路由出口:
jsx
import { Outlet } from "react-router-dom";
function Products() {
return (
<section>
<h1>产品列表</h1>
<Outlet />
</section>
);
}
可以把 Outlet 理解成父页面预留的插槽。父级导航、标题或侧栏保持不变,匹配到的子页面在这个位置渲染。
还有一个容易担心的问题:new 会不会被动态参数 :productId 抢先匹配?在当前 React Router 的路由排名中,静态段比动态段更具体,所以 /products/new 会优先匹配 new。这种优先级来自路由的具体程度,而不是 JSX 中谁写在前面。
七、权限守卫:记住用户从哪里来
示例中的 /pay 需要登录后才能访问:
jsx
<Route
path="/pay"
element={
<ProtectedRoute>
<Pay />
</ProtectedRoute>
}
/>
守卫需要完成两件事:判断当前是否允许进入,以及未登录时保存来源位置。教学示例可以写得更稳健一些:
jsx
import { Navigate, useLocation } from "react-router-dom";
function readDemoLogin() {
try {
return localStorage.getItem("demo-auth:v1") === "true";
} catch {
return false;
}
}
function ProtectedRoute({ children }) {
const location = useLocation();
if (!readDemoLogin()) {
return (
<Navigate
to="/login"
replace
state={{ from: location }}
/>
);
}
return children;
}
这里把整个 location 放进 state.from,可以同时保留 pathname、search 和 hash。replace 则避免用户后退时再次落回被守卫拦截的记录。

登录成功后读取来源并回跳:
jsx
const location = useLocation();
const navigate = useNavigate();
const from = location.state?.from ?? "/";
function saveDemoLogin() {
try {
localStorage.setItem("demo-auth:v1", "true");
return true;
} catch {
return false;
}
}
function handleLoginSuccess() {
if (!saveDemoLogin()) {
alert("浏览器无法保存登录状态");
return;
}
navigate(from, { replace: true });
}
为什么对 localStorage 加 try...catch?隐私模式、存储被禁用或浏览器配额异常时,读取和写入都可能抛错。键名加上 v1 也给以后调整存储结构留下了迁移空间。
但必须强调:这个布尔值只能用于演示前端导航,不能承担真实鉴权。用户可以直接修改 localStorage,也可以绕过页面调用 API。真正的身份认证和资源授权必须由服务器执行,前端守卫只是避免用户进入不合适的界面。
八、404 兜底与编程式导航
最后的通配路由负责接住所有未匹配地址:
jsx
<Route path="*" element={<NotFound />} />
如果 404 页面希望 3 秒后自动返回首页,可以使用 useNavigate,但要清理定时器:
jsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
function NotFound() {
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => {
navigate("/", { replace: true });
}, 3000);
return () => clearTimeout(timer);
}, [navigate]);
return <h1>Not Found</h1>;
}
如果组件提前卸载,cleanup 会取消尚未执行的回调,避免一个已经离开的页面突然触发导航。
Navigate 和 useNavigate 的区别也可以在这里串起来:
- 条件渲染时需要声明式重定向,用
<Navigate />; - 表单提交、按钮点击、定时器回调等事件中需要跳转,用
useNavigate()。
九、为什么部署后刷新深层路由会 404?
在应用内点击 Link 时,React Router 已经运行在浏览器里,可以接管导航。但用户直接打开 /products/42 或在这个地址按刷新,浏览器会真的向服务器请求 /products/42。
如果服务器只认识实际文件,就会返回 404,React 应用甚至没有机会启动。
使用 BrowserRouter 时,服务器需要把找不到的前端路径回退到 index.html。例如 Nginx 常见配置是:
nginx
location / {
try_files $uri $uri/ /index.html;
}
之后浏览器先拿到 SPA 入口,React Router 再根据 /products/42 渲染对应页面。API、静态资源等路径应配置独立规则,不能全部无条件回退。
如果应用部署在子目录,还要同时处理 Vite 的资源 base 与 Router 的 basename,否则资源地址和路由地址可能不一致。
十、我的 React Router 检查清单
以后再搭 React 路由,我会先检查这七件事:
- 先选路由策略:需要自然 URL 就用 BrowserRouter,并准备服务器 fallback;无法配置服务器时再评估 HashRouter。
- 区分导航方式 :应用内链接用
Link,渲染时重定向用Navigate,事件回调中跳转用useNavigate。 - 按页面划分懒加载边界:用构建产物确认真的生成了独立 chunk。
- 正确设计路由层级 :嵌套路由的父组件必须提供
Outlet。 - 把 URL 当作外部输入 :校验
useParams得到的值,并处理请求异常。 - 保留登录来源但不迷信前端守卫:回跳体验交给 navigation state,真实权限交给服务端。
- 同时测试点击与刷新:既测应用内跳转,也直接访问深层 URL,提前发现部署配置问题。
总结
React Router 做的不是简单"隐藏一个组件、显示另一个组件",而是在浏览器地址、历史记录和 React 组件树之间建立稳定的映射。
从这个示例里,可以串起一条完整学习路线:
BrowserRouter + Routes + Route建立基本映射;Link、Navigate、useNavigate处理不同导航场景;lazy + Suspense形成路由级代码分割;useParams接收动态路径;Outlet承载嵌套页面;useLocation + navigation state完成登录回跳;path="*"与服务器 fallback 分别处理前端和部署层的 404。
当这些概念不再是零散 API,而是一条从 URL 到组件树、再回到用户操作的完整链路时,React Router 才真正从"会配置"变成"能设计"。