告别繁琐的 Context + useReducer,用 Zustand 优雅管理全局状态,配合 JWT 实现完整的登录鉴权流程。
前言
在前后端分离的开发模式中,用户鉴权 是每个应用都绕不开的核心功能。传统的 session-cookie 方案在分布式环境下略显力不从心,而 JWT(JSON Web Token) 凭借其无状态、易扩展的特性,成为了现代 Web 应用的首选。
本文将带你从零开始,使用 React + React Router + Zustand + axios + Mock.js 搭建一套完整的登录鉴权系统。你将学到:
- JWT 的核心原理与使用方式
- Zustand 的极简状态管理
- 路由守卫实现页面访问控制
- axios 拦截器的优雅封装
- Mock.js 模拟后端接口
一、JWT 登录鉴权核心原理
1.1 为什么需要 JWT?
HTTP 协议本身是 无状态(Stateless) 的,每次请求都是独立的。服务器需要知道「你是谁」,于是就有了身份认证的需求。
| 方案 | 特点 | 适用场景 |
|---|---|---|
| Session-Cookie | 服务端存储 session,客户端存 sessionId | 单体应用 |
| JWT | 服务端不存状态,客户端存 token | 分布式、微服务 |
JWT 的核心思想是:服务器将用户身份信息(JSON 对象)通过加密算法签名,生成一串 token 颁发给客户端 。客户端后续请求只需在 Authorization 头中携带该 token,服务器即可解码还原用户信息,无需额外存储。
1.2 JWT 的两大动作
- sign(签发) :将用户 JSON 对象 + 密钥 → 生成 token
- verify(验证) :将 token + 密钥 → 还原用户 JSON 对象
javascript
php
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';
// 签发 token
const token = jwt.sign(
{ user: 'admin', role: 'admin' },
secret,
{ expiresIn: 86400 } // 24小时过期
);
// 验证 token
const decoded = jwt.verify(token, secret);
二、项目结构与技术选型
2.1 技术栈
| 技术 | 用途 |
|---|---|
| React 18 | UI 框架 |
| React Router v6 | 路由管理 |
| Zustand | 轻量级状态管理 |
| axios | HTTP 请求库 |
| vite-plugin-mock | 模拟后端接口 |
| jsonwebtoken | JWT 签名与验证 |
2.2 目录结构
text
ruby
login-demo/
├── mock/
│ └── user.js # Mock 接口定义
├── src/
│ ├── api/
│ │ ├── config.js # axios 实例 + 拦截器
│ │ ├── user.js # 用户相关 API
│ │ └── repo.js # 示例 API
│ ├── components/
│ │ ├── Nav.jsx # 导航组件
│ │ └── RequireAuth.jsx # 路由守卫
│ ├── pages/
│ │ ├── Home.jsx # 首页
│ │ ├── Login.jsx # 登录页
│ │ ├── Login.module.css # 登录页样式
│ │ └── Pay.jsx # 受保护页面
│ ├── store/
│ │ ├── user.js # 用户状态仓库
│ │ └── todos.js # 示例仓库
│ ├── App.jsx
│ ├── main.jsx
│ └── index.css
├── vite.config.js
└── package.json
三、Zustand:极简的全局状态管理
3.1 为什么选择 Zustand?
React 官方推荐 Context + useReducer 进行跨层级状态共享,但存在 渲染性能 和 模板代码过多 的问题。Zustand 是一个轻量级状态管理库,API 简洁、性能优异,非常适合中小型项目。
3.2 创建用户状态仓库
在 src/store/user.js 中定义用户状态:
javascript
javascript
// 全局配置:提供用户身份状态存储
import { create } from 'zustand';
// 从 localStorage 读取初始状态
const getInitialState = () => ({
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
});
export const useAuthStore = create((set) => ({
...getInitialState(),
// 设置认证信息(登录时调用)
setAuth: ({ token, user }) => {
// 持久化到 localStorage
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user });
},
// 清除认证信息(退出时调用)
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
},
}));
💡 关键点 :Zustand 的
create接收一个函数,返回包含状态和 action 的 store。set方法用于更新状态,同时我们手动同步到localStorage实现持久化。
3.3 在组件中使用
jsx
javascript
// 获取 token
const token = useAuthStore((state) => state.token);
// 获取用户信息
const user = useAuthStore((state) => state.user);
// 获取 action
const logout = useAuthStore((state) => state.logout);
四、Mock.js:模拟后端接口
开发阶段无需真实后端,使用 vite-plugin-mock 即可快速模拟 RESTful API。
4.1 配置 vite-plugin-mock
javascript
javascript
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteMockServe } from 'vite-plugin-mock';
export default defineConfig({
plugins: [
react(),
viteMockServe({
mockPath: 'mock', // mock 文件存放目录
localEnabled: true, // 开发环境启用
}),
],
});
4.2 编写 Mock 接口
在 mock/user.js 中定义 /api/login 和 /api/repo 两个接口:
javascript
javascript
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';
export default [
// ---------- 登录接口 ----------
{
url: '/api/login',
method: 'post',
timeout: 2000,
response: ({ body }) => {
const { username, password } = body;
// 模拟验证
if (username !== 'admin' || password !== '123456') {
return {
code: -1,
message: '用户名或密码错误',
};
}
// ✅ 服务器签发 JWT
const token = jwt.sign(
{
user: username,
role: 'admin',
},
secret,
{ expiresIn: 86400 }
);
return {
code: 0,
user: { username },
token,
};
},
},
// ---------- 获取仓库信息(需要鉴权) ----------
{
url: '/api/repo',
method: 'get',
response: (req) => {
// 从 Authorization 头中提取 token
const authHeader = req.headers.authorization || '';
const token = authHeader.split(' ')[1];
try {
// 验证 token
const decoded = jwt.verify(token, secret);
return {
code: 0,
data: decoded.user,
};
} catch (err) {
return {
code: 401,
message: 'Invalid token',
};
}
},
},
];
🔐 安全提示:实际生产环境中,密钥应存放在环境变量中,切勿硬编码。
五、axios 拦截器:统一处理请求与响应
5.1 创建 axios 实例
在 src/api/config.js 中封装 axios:
javascript
javascript
import axios from 'axios';
const instance = axios.create({
baseURL: '/api', // 所有请求以 /api 开头
timeout: 5000,
});
// ---------- 请求拦截器 ----------
// 在每个请求发出前,自动从 localStorage 获取 token 并添加到 header
instance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// ---------- 响应拦截器 ----------
// 统一处理响应数据,直接返回 data 部分
instance.interceptors.response.use((res) => {
return res.data;
});
export default instance;
5.2 封装 API 方法
javascript
javascript
// src/api/user.js
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res;
};
javascript
javascript
// src/api/repo.js
import axios from './config';
export const getRepo = async () => {
const res = await axios.get('/repo');
return res;
};
六、登录页面:表单验证 + 状态更新
6.1 表单验证
使用 useEffect 实时校验表单数据,控制登录按钮的可用状态:
jsx
ini
// src/pages/Login.jsx
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) {
// ✅ 存储认证信息到 Zustand + localStorage
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;
6.2 样式文件
css
css
/* Login.module.css */
.container {
max-width: 400px;
margin: 50px auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fafafa;
}
h2 {
text-align: center;
margin-bottom: 1.5rem;
}
.formGroup {
margin-bottom: 1rem;
}
label {
display: block;
font-weight: bold;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.5rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.error {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
button {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
七、路由守卫:保护私有页面
7.1 实现 RequireAuth 组件
RequireAuth 是一个高阶组件,检查用户是否已登录,未登录则重定向到登录页:
jsx
javascript
// src/components/RequireAuth.jsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore((state) => state.token);
// 未登录 → 跳转到登录页,并携带当前路径用于登录后回跳
if (!token) {
return <Navigate to="/login" replace state={{ from: window.location.pathname }} />;
}
return children;
}
export default RequireAuth;
7.2 在路由中配置守卫
使用 React.lazy + Suspense 实现代码分割,提升首屏加载速度:
jsx
javascript
// src/App.jsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import RequireAuth from './components/RequireAuth';
import Nav from './components/Nav';
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
function App() {
return (
<Router>
<Nav />
<Suspense fallback={<div>加载中...</div>}>
<Routes>
{/* 公开路由 */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* 受保护路由 */}
<Route
path="/pay"
element={
<RequireAuth>
<Pay />
</RequireAuth>
}
/>
</Routes>
</Suspense>
</Router>
);
}
export default App;
八、导航组件:动态展示登录状态
根据登录状态显示不同的导航项:
jsx
javascript
// src/components/Nav.jsx
import { Link } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function Nav() {
const token = useAuthStore((state) => state.token);
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
};
return (
<nav style={{ padding: '0 1rem', borderBottom: '1px solid #ccc' }}>
<Link to="/">🏠 Home</Link>
<Link to="/pay">💳 Pay</Link>
{!token ? (
<Link to="/login">🔐 登录</Link>
) : (
<>
<span style={{ margin: '0 1rem' }}>👤 {user?.username}</span>
<button onClick={handleLogout}>退出</button>
</>
)}
</nav>
);
}
export default Nav;
九、完整流程回顾
下面用一张流程图总结整个登录鉴权的完整链路:
text
javascript
┌────────────┐ 1. 输入用户名密码 ┌──────────────┐
│ Login │ ──────────────────────────▶ │ /api/login │
│ 页面 │ │ Mock 接口 │
└────────────┘ └──────────────┘
│ │
│ ▼
│ 2. JWT sign 生成 token
│ │
│ 3. 返回 { code:0, token }
│ │
▼ │
┌────────────┐ │
│ setAuth │ ◀────────────────────────────────────┘
│ Zustand │ 4. 存入 Zustand + localStorage
│ + localStorage
└────────────┘
│
▼
┌────────────┐ 5. 登录成功,跳转到目标页
│ navigate │
│ (from) │
└────────────┘
┌────────────────────────────────────────────────────────┐
│ 后续每次请求 │
├────────────────────────────────────────────────────────┤
│ axios 请求拦截器 → 从 localStorage 取 token │
│ → 添加到 Authorization: Bearer <token> │
│ → 发送请求 │
│ │
│ 服务器(Mock)→ 验证 token → 返回数据 │
│ token 无效 → 返回 401 │
└────────────────────────────────────────────────────────┘
十、总结与思考
通过本篇文章,我们完成了一套完整的 React 登录鉴权系统,核心收获包括:
| 知识点 | 核心价值 |
|---|---|
| JWT | 无状态认证,适合分布式系统 |
| Zustand | 极简状态管理,告别 Context 地狱 |
| 路由守卫 | 优雅保护私有路由 |
| axios 拦截器 | 统一处理 token,避免重复代码 |
| Mock.js | 前后端分离开发,不依赖后端即可联调 |
进一步优化方向
- token 刷新机制:使用 refresh_token 实现无感刷新
- 错误统一处理:响应拦截器中统一处理 401 跳转登录
- 权限细化:基于角色(role)控制页面/按钮访问
- TypeScript:为项目增加类型安全