前言
这是一套最小可运行的前端 JWT 权限系统 Demo,完整覆盖「登录 → Token 持久化 → 请求自动带鉴权头 → 路由守卫拦截 → 后端 Token 校验」全链路。本文以复习为目的,逐文件逐行拆解逻辑,对所有陌生 API 做单独解析,并整理实战中所有踩坑点。
技术栈:React 18 + React Router v6 + Zustand 状态管理 + Axios 封装 + jsonwebtoken + vite-plugin-mock 模拟后端
核心流程总览:
- 用户访问受保护页面
/pay - 路由守卫
RequireAuth检测无 Token,跳转登录页并携带「原目标地址」 - 用户提交账号密码,Mock 后端签发 JWT
- 前端将 Token + 用户信息存入 Zustand + localStorage 持久化
- 跳转回用户原本想访问的页面
- 后续所有请求由 Axios 拦截器自动带上
Authorization: Bearer Token - Mock 后端校验 Token 合法性,合法则返回业务数据
一、项目结构与文件职责
arduino
src/
├── api/
│ ├── config.js // Axios 实例创建 + 请求/响应拦截器
│ ├── repo.js // 受保护业务接口
│ └── user.js // 登录接口
├── component/
│ ├── Nav.jsx // 顶部导航栏
│ └── RequireAuth.jsx // 路由守卫组件
├── pages/
│ ├── Home.jsx // 公开首页
│ ├── Login.jsx // 登录页(表单验证 + 登录逻辑)
│ └── Pay.jsx // 受保护页面
├── store/
│ └── user.js // Zustand 全局状态 + localStorage 持久化
├── App.jsx // 路由配置 + 路由懒加载
mock/
└── user.js // 模拟后端:JWT 签发 + 校验
二、核心模块逐行拆解
1. 路由入口:App.jsx
作用:配置路由表,实现路由懒加载,对受保护路由包裹路由守卫。
javascript
import { useAuthStore } from './store/user'
import React, { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, BrowserRouter as Router } from 'react-router-dom'
import Nav from './component/Nav'
import { getRepo } from './api/repo';
import RequireAuth from './component/RequireAuth'
// 路由懒加载
const Home = lazy(() => import('./pages/Home'))
const Login = lazy(() => import('./pages/Login'))
const Pay = lazy(() => import('./pages/Pay'));
function App(){
const token = useAuthStore(state => state.token);
return (
<>
<Router>
<Nav/>
{/* 懒加载降级:组件加载完成前显示 loading */}
<Suspense fallback={<div>loading...</div>}>
<Routes>
<Route path='/' element={<Home/>}></Route>
<Route path='/Login' element={<Login/>}></Route>
{/* 受保护路由:用 RequireAuth 包裹 */}
<Route path='/pay' element={<RequireAuth><Pay/></RequireAuth>}></Route>
</Routes>
</Suspense>
</Router>
</>
)
}
export default App;
关键 API 解析
React.lazy(() => import('./pages/xxx'))路由懒加载,只有访问到该路由时才会加载对应组件的代码,减少首屏包体积。 必须配合<Suspense>使用,否则会报错。<Suspense fallback={<div>loading...</div>}>懒加载组件加载过程中,显示 fallback 里的降级内容,避免白屏。<RequireAuth><Pay/></RequireAuth>高阶组件包裹模式:把受保护页面当作 children 传入守卫组件,由守卫决定是否渲染页面。
2. 路由守卫:RequireAuth.jsx
作用:拦截未登录用户,强制跳转登录页,同时记录用户原本想访问的地址。
javascript
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '../store/user'
export default function RequireAuth({ children }) {
const token = useAuthStore(state => state.token)
// 没有 token → 跳转登录页
if (!token) {
return <Navigate to='./login' replace />
}
// 有 token → 渲染目标页面
return <>{children}</>
}
关键 API 解析
<Navigate to='./login' replace />编程式导航的组件写法,replace: true表示替换浏览器历史记录。 不写 replace 的话,登录页会留在历史栈里,用户点返回会又回到登录页;用 replace 后,登录页被替换掉,返回直接回到上上个页面。
3. 登录页:Login.jsx
作用:表单校验、发起登录请求、存储登录状态、跳转回原目标页面。
ini
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { login } from '../api/user';
import { useAuthStore } from '../store/user';
import styles from './Login.module.css';
function Login() {
const navigate = useNavigate();
const location = useLocation();
// 取出路由跳转时携带的「原目标地址」,没有就默认首页
const from = location.state?.from || '/';
const setAuth = useAuthStore(state => state.setAuth);
const [formData, setFormData] = useState({ username: '', password: '' });
const [errors, setErrors] = useState({ username: '', password: '' });
const [isValid, setIsValid] = useState(false);
// 实时表单验证
useEffect(() => {
const newErrors = { username: '', password: '' };
if (!formData.username.trim()) {
newErrors.username = '用户名不能为空';
} else if (formData.username.length < 3) {
newErrors.username = '用户名至少3位';
}
if (!formData.password.trim()) {
newErrors.password = '密码不能为空';
} else if (formData.password.length < 6) {
newErrors.password = '密码至少6位';
}
setErrors(newErrors);
// 两个输入都合法 → 按钮可点击
setIsValid(!newErrors.username && !newErrors.password);
}, [formData]);
// 输入框双向绑定
const handleChange = e => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// 提交登录
const handleLogin = async e => {
e.preventDefault();
try {
const res = await login(formData);
if (res.code === 0) {
// 登录成功:更新全局状态 + 本地持久化
setAuth({ token: res.token, user: res.user });
// 跳回原本想去的页面,替换历史记录
navigate(from, { replace: true });
} else {
alert(res.message || '登录失败');
}
} catch (err) {
console.error(err);
alert('登录失败');
}
};
return (
<div className={styles.container}>
<h2>登录</h2>
<form onSubmit={handleLogin}>
<div className={styles.formGroup}>
<label htmlFor="username">用户名</label>
<input
id="username"
type="text"
name="username"
value={formData.username}
onChange={handleChange}
required
/>
{errors.username && <div className={styles.error}>{errors.username}</div>}
</div>
<div className={styles.formGroup}>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
{errors.password && <div className={styles.error}>{errors.password}</div>}
</div>
{/* 校验不通过则禁用按钮 */}
<button type="submit" disabled={!isValid}>登录</button>
</form>
</div>
);
}
export default Login;
关键 API 解析
-
location.state?.fromlocation.state:React Router 路由跳转时携带的额外数据对象?.可选链操作符:如果 state 不存在,不会报错,直接返回 undefined- 业务含义:用户被路由守卫拦下来时,守卫会把「原本想去的地址」放在 state.from 里传过来
-
navigate(from, { replace: true })登录成功后跳回原目标页面;replace: true把登录页从历史记录中抹掉,避免用户点返回又回到登录页。 -
表单验证思路 把校验逻辑放在 useEffect 里,依赖 formData,输入变化时实时校验,计算出错误信息和按钮可用状态。
4. 全局状态:Zustand + localStorage 持久化
作用:管理登录态(token + 用户信息),同时同步到 localStorage 实现刷新页面不丢失登录。
javascript
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
// 初始化:从本地存储读取,没有就给默认值
token: localStorage.getItem("token") || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// 登录:同时更新内存 + 本地存储
setAuth: ({ token, user }) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user });
},
// 退出登录:清空内存 + 本地存储
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}))
核心逻辑解析
-
为什么 localStorage 存取 user 要转 JSON? localStorage 只能存字符串,不能直接存 JS 对象。
-
存的时候:
JSON.stringify(user)→ JS 对象转 JSON 字符串 -
读的时候:
JSON.parse(字符串)→ JSON 字符串转回 JS 对象
token 本身就是字符串,所以直接存取,不需要转 JSON。
-
-
Zustand
create函数 传入一个函数,函数接收set方法用来更新状态,返回的对象就是全局状态。 组件中使用:const token = useAuthStore(state => state.token),按需订阅状态。 -
可优化点 如果 localStorage 里的 user 是非法 JSON,
JSON.parse会直接报错导致页面白屏。生产环境建议加 try-catch 兜底:javascriptuser: (() => { try { const str = localStorage.getItem('user'); return str ? JSON.parse(str) : null; } catch { return null; } })()
5. Axios 封装:请求 / 响应拦截器
作用:统一给所有请求加鉴权头,统一处理响应数据格式。
javascript
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000
});
// 请求拦截器:请求发出去之前执行
instance.interceptors.request.use(config => {
const token = localStorage.getItem("token");
// 只有 token 存在才加请求头!避免 null 导致非法字符报错
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
})
// 响应拦截器:拿到响应后统一剥掉外层 data
instance.interceptors.response.use(res => {
return res.data
})
export default instance;
关键逻辑解析
- 请求拦截器为什么要先判断 token? 如果 token 为 null,直接赋值会变成
Authorization: Bearer null,后端会报「请求头非法字符」错误。 所以没有 token 就不要设置这个请求头。 Bearer ${token}格式 这是 HTTP 鉴权的标准格式,Bearer后面必须跟一个空格,再拼接 token 字符串。- 响应拦截器的作用 axios 响应默认包在
res.data里,业务接口真正需要的数据在第二层。 统一在拦截器里return res.data,组件里拿到的就是直接的业务数据,不用每次都写.data。
6. API 层封装
6.1 受保护接口:repo.js
javascript
import axios from "./config"; // 用封装好的实例,走拦截器
export const getRepo = async () => {
const res = await axios.get('/repo');
return res;
}
因为用了封装的 axios 实例,会自动走请求拦截器加 Authorization 头,也自动走响应拦截器剥 data。
6.2 登录接口:user.js
javascript
import axios from "axios"; // 登录不需要 token,直接用原生 axios
export const login = async (data) => {
const res = await axios.post('/api/login', data);
return res.data;
}
登录接口本身不需要鉴权,所以不用走拦截器;同时因为没走响应拦截器,所以要手动
.data取数据。
7. Mock 后端:JWT 签发与校验
作用:模拟后端接口,实现 JWT 的签发(登录)和校验(业务接口)。
javascript
import jwt from "jsonwebtoken";
const secret = 'secret819!$' // 自定义密钥,签发和校验必须一致
export default [
// 受保护接口:校验 token
{
url: '/api/repo',
method: 'get',
response: req => {
const auth = req.headers['authorization'];
// 先判断请求头是否存在,不存在直接返回 401
if (!auth) {
return { code: 401, msg: '缺少 Authorization 请求头' };
}
const token = auth.split(' ')[1];
try {
// 校验 token 合法性、是否过期
let decoded = jwt.verify(token, secret);
return {
code: 0,
data: decoded.user
};
} catch (error) {
return {
code: 401,
msg: 'Invalid token'
};
}
}
},
// 登录接口:签发 token
{
url: '/api/login',
method: 'post',
response: (req, res) => {
const body = req.body;
// 签发 JWT
const token = jwt.sign(
{
user: body.username,
role: 'admin'
},
secret,
{ expiresIn: 86400 } // 有效期:秒
);
return {
code: 0,
user: { username: body.username },
token: token
}
}
}
]
核心概念解析
-
secret密钥 不是库内置的固定值,是自定义的字符串,相当于一把密码锁。-
jwt.sign(数据, secret):用密钥加密生成 token -
jwt.verify(token, secret):用同一个密钥解密校验
铁律:签发和校验的 secret 必须完全一模一样,一个字符都不能差,否则直接校验失败。
-
-
jwt.sign(payload, secret, options)- payload:要存在 token 里的数据(用户名、角色等)
- expiresIn:token 有效期,单位秒
-
为什么校验要包 try-catch? token 篡改、过期、格式错误,
jwt.verify都会直接抛出异常。 不包 try-catch 会导致 mock 服务崩溃,前端收到 Network Error。 -
经典踩坑 直接写
req.headers['authorization'].split(' ')[1],如果请求没带 Authorization 头,undefined.split()直接报错。必须先判断 auth 是否存在,再执行 split。
8. 导航组件:Nav.jsx
作用:根据登录态显示不同按钮,实现登录 / 退出入口。
javascript
import { Link } from 'react-router-dom'
import { useAuthStore } from '../store/user'
export default function Nav(){
const token = useAuthStore(state => state.token)
const logout = useAuthStore(state => state.logout)
const handleLogout = () => {
logout();
}
return (
<nav style={{padding: 0, borderBottom: '1px solid #ccc'}}>
<Link to="/">Home</Link>
<br />
<Link to="/pay">Pay</Link>
<br />
{/* 未登录显示登录按钮,已登录显示退出按钮 */}
{!token && <Link to="/login">Login</Link>}
{token && <button onClick={handleLogout}>Logout</button>}
</nav>
)
}
三、全链路时序走一遍
- 访问受保护页面 :用户点
/pay→RequireAuth读取 Zustand 中的 token → 无 token →<Navigate to="/login" replace />,同时携带state: { from: '/pay' } - 进入登录页 :
Login组件读取location.state?.from→ 记录目标地址/pay - 提交登录 :输入账号密码 → 调用
login(formData)→ 请求/api/login - 后端签发 Token :mock 接收请求 →
jwt.sign()生成 token → 返回{code:0, token, user} - 前端存储登录态 :
setAuth()→ 同时写入 Zustand 内存 + localStorage 持久化 - 跳转原页面 :
navigate('/pay', { replace: true }) - 访问业务接口 :页面调用
getRepo()→ 请求拦截器读取 localStorage 的 token → 自动加上Authorization: Bearer xxx - 后端校验 Token :mock 读取请求头 →
jwt.verify()校验通过 → 返回业务数据 - 前端渲染数据:响应拦截器剥掉外层 data → 组件拿到数据渲染
四、踩坑复盘与避坑清单
1. JS 语法坑
- ❌
undefined.split()直接报错:访问对象属性前一定要先判断是否存在 - ❌ localStorage 直接存对象:会变成
[object Object],必须JSON.stringify - ❌
JSON.parse(null)脏数据崩溃:本地存储数据不可信,建议加 try-catch
2. Axios 拦截器坑
- ❌ token 为 null 也设置 Authorization 头:会生成
Bearer null,触发非法字符报错 - ❌ 忘记
return config:请求会直接卡住发不出去 - ❌ 登录接口用封装实例:登录不需要 token,用原生 axios 更清晰
3. React Router 坑
- ❌ 跳转登录页不用 replace:用户点返回会又回到登录页
- ❌ 不记录 from 地址:登录成功只能跳首页,体验差
4. Mock 后端坑
- ❌ jwt.verify 不包 try-catch:token 无效直接服务崩溃,前端 Network Error
- ❌ secret 签发和校验不一致:永远 401 校验失败
- ❌ 不判空直接 split 请求头:不带 token 的请求直接打挂服务
5. 开发工具坑
- ❌ CSS Module 文件不存在:import 了不存在的
.module.css会直接编译失败 - ❌ Vite HMR 热更新错乱:改文件后出现匿名
<anonymous>报错,Ctrl+F5 硬刷新或重启 vite 即可
五、总结
这套权限系统的核心可以浓缩为三句话:
- 登录拿 token,存本地
- 请求拦截器统一加 Authorization 头
- 路由守卫 + 后端校验双重拦截未登录请求
整个链路中最容易出问题的地方集中在「边界判断」:token 有没有、请求头在不在、数据格式对不对。把所有边界情况都做了兜底,整个权限系统就会非常稳定。
适合作为 React 权限系统的入门模板,在此基础上可以扩展角色权限、动态路由、刷新 token 等更复杂的能力