React Router 进阶:路由守卫、登录鉴权与状态传递
前言
上一篇文章我们搭建了 React Router v6 的基本骨架------路由匹配、懒加载、动态参数、嵌套路由和 404 兜底。但那还是一个"敞开大门"的应用:任何人随便敲个 URL 就能访问所有页面。
真实项目里,有些页面需要登录才能看 (支付页、个人中心、后台管理)。这就引出了今天的主角:路由守卫(Route Guard) 、登录鉴权 以及在这个过程中 React Router 如何在不同页面之间传递状态。
一、HashRouter vs BrowserRouter:彻底搞清楚两种路由
javascript
// App.jsx ------ 今天从 HashRouter 换到了 BrowserRouter
import {
BrowserRouter as Router, // ← 换了!
Routes,
Route,
Navigate,
} from 'react-router-dom';
HashRouter 是怎么工作的
URL 里 # 后面的内容叫 fragment(片段标识符) ,它的核心特性是:改变 hash 不会触发浏览器向服务器发请求。
bash
http://localhost:5173/#/pay
└────────── hash 部分,浏览器不管它
当你从 #/home 切到 #/pay:
- 浏览器不发起 HTTP 请求
- 但触发
hashchange事件 - React Router 监听这个事件,根据 hash 值切换组件
优点 :零配置,服务器不需要任何配合。扔到任何静态服务器上都能跑。 缺点 :URL 里有个 #,不够"干净",看起来不像传统网页。
BrowserRouter 是怎么工作的
BrowserRouter 依赖的是 HTML5 引入的 History API:
javascript
// 底层原理
window.history.pushState(null, '', '/pay'); // URL 变了,页面不刷新
它可以直接操作完整的 URL 路径,不需要 #:
bash
http://localhost:5173/pay ← 干净的 URL,没有 #
但有一个关键前提 :当用户直接访问 /pay 或刷新页面 时,浏览器会向服务器发起 GET 请求。服务器必须配置一条规则------所有路径都返回 index.html,让前端 React Router 来接管路由匹配。
bash
用户刷新 /pay → 服务器请求 GET /pay
├─ 没配置 fallback → 服务器返回 404
└─ 配置了 fallback → 服务器返回 index.html → React Router 接管 → 匹配 /pay 渲染组件
Vite 开发模式下默认已配置好,生产环境需在 Nginx 中加:
bash
location / {
try_files $uri /index.html;
}
| HashRouter | BrowserRouter | |
|---|---|---|
| URL 样子 | /#/pay |
/pay |
| 原理 | hashchange 事件 |
History API(pushState) |
| 服务器配置 | 不需要 | 需要 fallback 到 index.html |
| 适用场景 | Demo、GitHub Pages、静态部署 | 生产环境、SEO 友好 |
Demo 阶段用 HashRouter 省心,上线后切 BrowserRouter 更专业。
二、Restful 设计理念:URL 就是资源
在深入路由之前,先明确一个设计思想:一切皆资源。
bash
/products → 商品列表资源
/products/123 → id=123 的商品资源
/products/new → 创建新商品的入口资源
/user/123 → id=123 的用户资源
/pay → 支付页面资源
/login → 登录页面资源
每个 URL 路径对应一个逻辑上的资源,路由切换 = 访问不同资源,而不是"跳转到一个新页面"。
这也解释了什么前端路由要接管 URL------URL 是用户与应用交互的入口,它应该准确表达"当前在看什么"。
三、路由对象:前端路由的三个核心概念
浏览器暴露了三个核心对象,React Router 正是对它们做了 React 风格的封装:
| 原生概念 | React Router Hook | 作用 |
|---|---|---|
window.navigator |
(无直接 Hook,Link/Navigate 内部封装) | 导航能力 |
window.location |
useLocation() |
当前在哪个 URL |
window.history |
useNavigate() |
历史记录栈的操作 |
bash
┌─────────────────────────────┐
│ 浏览器 │
│ │
│ navigator → "你能去哪" │
│ location → "你现在在哪" │
│ history → "你之前去过哪" │
│ │
│ React Router 把它们包装成 │
│ 组件和 Hooks,让你在 JSX 中 │
│ 自然地操作路由 │
└─────────────────────────────┘
理解这三个概念,就能理解为什么 Link 不刷新页面------它底层调用的是 history.pushState,只改 URL 不发请求;为什么 useLocation().state 能跨页面传数据------因为 pushState 本身就支持携带 state 对象。
四、路由守卫:用 {children} 做"门禁"
javascript
// ProtectRoute.jsx
import { Navigate } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
// 从 localStorage 读取登录状态
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// 没登录 → 跳转到 /login,同时捎带上"从哪来的"
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
// 已登录 → 放行,渲染子组件
return <>{children}</>;
};
设计模式:children 作为"插槽"
ProtectRoute 不关心自己被包裹的是什么组件,它只做一件事:检查权限 → 决定放行还是拦截 。具体渲染什么,完全由使用方通过 children 传入:
javascript
// App.jsx
<Route path="/pay" element={
<ProtectRoute>
<Pay /> {/* ← children,ProtectRoute 不管它是什么 */}
</ProtectRoute>
} />
这就是 React 组件化的精髓------关注点分离 。ProtectRoute 只关心"是否有权限",Pay 只关心"支付页长什么样",两者通过 children 松耦合地组合在一起。
children 本质上就是组件调用时写在 JSX 标签内部的所有内容:
xml
<ProtectRoute>
<Pay /> ← 这个就是 children
</ProtectRoute>
// 等价于
ProtectRoute({ children: <Pay /> })
五、Navigate 的 state 对象:跨页面传递"隐藏数据"
csharp
<Navigate to="/login" replace state={{ from: location.pathname }} />
// └────────────────────────────────┘
// state 对象:URL 里看不见,目标页面能拿到
它解决了什么问题
用户在地址栏输入 #/pay,ProtectRoute 发现没登录,重定向到 /login。登录成功后,该跳回哪里?必须有人告诉 Login 页面"用户是从 /pay 过来的" 。这个信息不能放在 URL 里(会暴露内部路径,而且破坏 Login 页面的 URL 结构),但又需要在页面间传递------state 就是干这个的。
数据如何流转
ini
用户访问 /pay(未登录)
│
▼
ProtectRoute:
<Navigate to="/login" state={{ from: '/pay' }} />
│
│ state 被写入 history.state
│
▼
地址栏变成 /login,但 state 藏在 history 栈里
│
▼
Login 组件:
const location = useLocation();
const from = location.state?.from || '/'; // → '/pay'
│
▼
登录成功 → navigate(from) → 送回 /pay
为什么 state 刷新不丢
因为 React Router 底层调用的是 history.pushState(state, '', url),state 对象被浏览器序列化存储在历史记录栈中,页面刷新后从 history.state 恢复,所以 Login 页面刷新也依然能拿到 from。
六、Login 页面:表单、状态存储、防回退
ini
// Login/index.jsx
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// 读取 ProtectRoute 传过来的"来源路径"
const from = location.state?.from || '/';
function handleSubmit(e) {
e.preventDefault(); // 阻止表单的默认提交行为(会刷新页面)
// FormData API:从 <form> 元素中提取用户输入
const formData = new FormData(e.currentTarget);
const username = formData.get('username');
const password = formData.get('password');
// 硬编码的鉴权(Demo 演示,生产环境需要后端验证)
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true');
navigate(from, { replace: true }); // ← 关键:replace 模式
}
}
return (
<form onSubmit={handleSubmit}>
<input name="username" placeholder="请输入用户名" required />
<input name="password" placeholder="请输入密码" required />
<button type="submit">登录</button>
</form>
);
};
FormData:不用受控组件也能拿表单数据
React 中处理表单通常用"受控组件"------每个 input 绑定一个 state:
scss
// 受控组件写法(常见但繁琐)
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
<input value={username} onChange={e => setUsername(e.target.value)} />
FormData 是浏览器原生 API,直接从 <form> DOM 元素读取 name 属性对应的输入值,不需要 state、不需要 onChange。对于登录表单这种"提交时才关心值"的场景,简洁得多。
注意:input 必须加
name属性,FormData 通过它来索引值。
navigate(from, { replace: true }):为什么用 replace?
bash
历史记录栈(push 模式):
┌─────────┐
│ / │ ← 初始页面
├─────────┤
│ /pay │ ← 用户尝试访问
├─────────┤
│ /login │ ← 被重定向到登录页
└─────────┘
用户点"返回" → 回到 /pay → 又被 ProtectRoute 拦截 → 又跳到 /login
↑
死循环!用户被"困"在登录页
bash
历史记录栈(replace 模式):
┌─────────┐
│ / │ ← 初始页面
├─────────┤
│ /pay │ ← /login 把这条记录替换掉了!
└─────────┘
用户点"返回" → 回到 / → 一切正常
replace: true 的作用:用新路径替换当前的历史记录 ,而不是追加一条新记录。登录成功后,/login 的历史记录就不该存在------用户不需要"回到登录页",回去只会被再次拦截。
七、localStorage:为什么用它存登录状态
javascript
localStorage.setItem('isLogin', 'true');
localStorage.getItem('isLogin') === 'true';
HTTP 是无状态的
协议层面,每次 HTTP 请求都是独立的,服务器不会"记住"你上一次请求做了什么。网页要"记住"用户已登录,必须自己想办法保存状态。
三种常见方案
| 方案 | 存储位置 | 生命周期 | 用途 |
|---|---|---|---|
localStorage |
浏览器磁盘 | 永久(除非手动清除) | 登录状态、用户偏好 |
sessionStorage |
浏览器内存 | 关闭标签页即清除 | 临时数据、表单草稿 |
| Cookie | 浏览器 + 每次请求自动发给服务器 | 可设置过期时间 | Session Token |
Demo 中用了最简单的 localStorage------它按域名 隔离存储(同一个域名的所有页面共享,不同域名的互相看不到),React Router 的 ProtectRoute 和 Login 在同一个域名下,所以 Login 写入的 isLogin,ProtectRoute 能读到。
makefile
localhost:5173 的 localStorage:
isLogin: "true" ← Login 页面写入
ProtectRoute 读取 ← 同一个域名沙盒,共享
八、完整鉴权流程总结
把今天新增的所有零件串起来:
php
用户点击"支付" → <Link to="/pay">
│
▼
Routes 匹配 path="/pay"
│
▼
<ProtectRoute> 被渲染
localStorage.getItem('isLogin') === 'true' ?
│
┌─────┴─────┐
│ false │ true
▼ ▼
重定向 渲染 <Pay />
/login
│
state={{ from: '/pay' }}
│
▼
Login 组件渲染
useLocation().state?.from → '/pay'
│
▼
用户输入 admin / 123456 → handleSubmit
localStorage.setItem('isLogin', 'true')
navigate('/pay', { replace: true })
│
▼
ProtectRoute 再次检查
isLogin === true → 放行
│
▼
<Pay /> 正常渲染,URL 是 /pay
关键要点速查
| 概念 | 一句话解释 |
|---|---|
| BrowserRouter | 用 History API 操作完整 URL 路径,需要服务器 fallback 支持 |
| HashRouter | 用 URL # 部分做路由,零配置,URL 里有个 # |
children 模式 |
组件标签内部的内容作为 prop 传入,实现松耦合的"插槽" |
| 路由守卫 | 包裹敏感页面,检查权限后决定放行还是重定向 |
Navigate 的 state |
跨页面传递"隐藏数据"(如来源路径),不进 URL,刷新不丢 |
useLocation() |
读取浏览器当前 URL 的信息,包括 state |
FormData |
浏览器原生 API,从 <form> 提取带 name 属性的 input 值 |
localStorage |
浏览器本地持久存储,按域名隔离 |
navigate(path, {replace}) |
替换当前历史记录,不让用户回退到不该回的页面 |
| Restful | URL 路径对应资源,/products/123 就是 id=123 的商品 |
React Router 的本质能力可以归纳为三点:匹配 (URL → 组件)、导航 (组件 → URL)、传参(路由参数 + state)。鉴权路由则是这些基础能力的组合应用------检查状态(localStorage)、条件导航(Navigate)、跨页传参(state)、防回退(replace)。把这些原子能力理解透彻,你会发现任何复杂的前端路由场景,拆开来看都离不开这三个维度。