JWT 登录鉴权全链路:从 Zustand 状态管理到 Axios 拦截器,彻底搞懂前端鉴权工程
HTTP 是无状态的------服务器不知道你是谁。每次请求都是"陌生人"。JWT(JSON Web Token) 就是解决这个问题的工业标准:登录时签发 token,后续请求自动携带 token,服务器解码验证身份。本文从零搭建一个完整的 JWT 鉴权全栈项目------Zustand 管理全局登录状态、Axios 拦截器自动注入 token、React Router 路由守卫保护受保护页面、Vite Mock 模拟后端签发与验证 token。16 个文件,一条链路打通前端鉴权工程。建议收藏后动手实践。
一、为什么需要登录鉴权?
1.1 HTTP 的无状态问题
bash
HTTP 是无状态的(Stateless):
请求 1:POST /login { username: 'admin', password: '123456' }
→ 服务器验证通过,返回登录成功
请求 2:GET /pay
→ 服务器:你是谁?我不认识你
→ HTTP 协议设计如此:每个请求都是独立的
→ 请求 1 和请求 2 之间没有关联
问题:用户登录后,后续请求怎么证明"我就是刚才登录的那个人"?
1.2 登录鉴权的演进
css
┌──────────────────────────────────────────────────────────┐
│ 登录鉴权方案演进 │
│ │
│ ① Cookie + Session(传统方案) │
│ → 服务器创建 Session,返回 SessionID(写在 Cookie 中) │
│ → 浏览器每次请求自动带上 Cookie │
│ → 服务器根据 SessionID 找到 Session 对象 │
│ → 问题:Session 存在服务器内存,不适合分布式 │
│ │
│ ② JWT(现代方案) │
│ → 服务器签发 token(包含用户信息的加密字符串) │
│ → 客户端存储 token(localStorage) │
│ → 每次请求在 Header 中带上 token │
│ → 服务器解码 token,验证身份 │
│ → 优势:无状态、分布式友好、跨域支持好 │
└──────────────────────────────────────────────────────────┘
1.3 JWT 的核心概念
javascript
JWT = JSON Web Token
┌──────────────────────────────────────────────────┐
│ JWT 的三个部分 │
│ │
│ Header.Payload.Signature │
│ │ │ │ │
│ │ │ └── 签名:用密钥加密的验证码 │
│ │ └── 载荷:用户身份信息(JSON 对象) │
│ └── 头部:算法类型 │
│ │
│ 两个核心动作: │
│ ├── sign:签名,把 JSON 对象 → 加密 → token │
│ └── verify:验证,把 token → 解密 → JSON 对象 │
└──────────────────────────────────────────────────┘
css
JWT 的完整流程:
① 用户登录
POST /login { username: 'admin', password: '123456' }
② 服务器验证通过,签发 token
jwt.sign({ user: 'admin', role: 'admin' }, secret, { expiresIn: 86400 })
→ 生成 token 字符串
③ 服务器返回 token
{ code: 0, user: { username: 'admin' }, token: 'eyJhbG...' }
④ 客户端存储 token
localStorage.setItem('token', token)
⑤ 后续请求自动携带 token
GET /repo
Headers: { Authorization: 'Bearer eyJhbG...' }
⑥ 服务器验证 token
jwt.verify(token, secret)
→ 解码出 { user: 'admin', role: 'admin' }
→ 身份确认,返回数据
⑦ token 过期
→ verify 抛异常
→ 返回 401,要求重新登录
二、项目架构全景
2.1 技术栈
arduino
React 全家桶 + 鉴权工程:
├── React → UI 框架
├── React Router DOM → 路由管理(BrowserRouter, lazy, Suspense)
├── Zustand → 轻量级状态管理(替代 Redux)
├── Axios → HTTP 客户端(拦截器自动注入 token)
├── Vite Mock Server → 模拟后端 API(JWT 签发与验证)
└── CSS Modules → 样式隔离
2.2 项目结构
ruby
src/
├── App.jsx # 应用入口,路由配置,路由守卫
├── main.jsx # React 挂载入口
├── pages/
│ ├── Home.jsx # 首页(公开页面)
│ ├── Login.jsx # 登录页(表单校验 + 登录逻辑)
│ ├── Pay.jsx # 支付页(受保护页面)
│ └── Login.module.css # 登录页样式(CSS Module)
├── components/
│ ├── Nav.jsx # 导航栏(条件渲染登录/登出)
│ └── RequireAuth.jsx # 路由守卫(无 token 跳转登录)
├── api/
│ ├── config.js # Axios 实例 + 拦截器
│ ├── user.js # 登录接口
│ └── repo.js # 受保护接口
├── store/
│ ├── user.js # Zustand 鉴权 Store(token + user)
│ └── todos.js # Zustand Todos Store(子仓库)
└── mock/
└── user.js # Vite Mock 后端(JWT 签发 + 验证)
2.3 架构全景图
bash
┌──────────────────────────────────────────────────────────────────┐
│ JWT 鉴权全链路架构 │
│ │
│ 浏览器 │
│ │ │
│ │ ① 访问 /pay(受保护页面) │
│ ▼ │
│ React Router │
│ │ │
│ │ ② RequireAuth 检查 token │
│ │ → 无 token:Navigate to /login │
│ │ → 有 token:渲染 Pay 组件 │
│ ▼ │
│ 组件(Pay / Login / Home) │
│ │ │
│ │ ③ 调用 API │
│ │ await login(formData) │
│ │ await getRepo() │
│ ▼ │
│ api/user.js / api/repo.js │
│ │ │
│ │ ④ 发起 Axios 请求 │
│ ▼ │
│ api/config.js(Axios 实例 + 拦截器) │
│ │ │
│ │ ⑤ 请求拦截器:自动注入 token │
│ │ config.headers['Authorization'] = `Bearer ${token}` │
│ │ │
│ │ ⑥ 响应拦截器:提取 res.data │
│ │ return res.data │
│ ▼ │
│ Vite Mock Server(模拟后端) │
│ │ │
│ │ ⑦ /api/login:验证账密 → jwt.sign → 返回 token │
│ │ ⑦ /api/repo:jwt.verify → 返回用户数据或 401 │
│ ▼ │
│ Zustand Store(全局状态) │
│ │ │
│ │ ⑧ setAuth({ token, user }) │
│ │ → 存入 localStorage │
│ │ → 更新 store 状态 │
│ │ → 组件自动重新渲染 │
│ ▼ │
│ Nav 组件响应状态变化 │
│ → 显示 Logout 按钮 │
│ → 显示 Hello, admin! │
└──────────────────────────────────────────────────────────────────┘
三、Zustand:轻量级全局状态管理
3.1 为什么用 Zustand?
bash
React 状态管理方案对比:
Props 传递 → 简单但层数多时痛苦(Props Drilling)
Context + useContext → 跨层通信,但 Provider 嵌套地狱
Redux → 功能强大但样板代码太多
Zustand → 轻量、无 Provider、hooks 风格
Zustand 优势:
├── 不需要 Provider 包裹
├── 不需要 reducer/action/type
├── 直接在组件中使用
├── 跨路由全局共享
└── API 极简,create(set => ({ ... }))
3.2 鉴权 Store:store/user.js
javascript
import { create } from 'zustand';
export const useAuthStore = create(set => ({
// ======== 状态 ========
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// ======== 动作(Actions) ========
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 });
},
}));
逐段解析:
javascript
create(set => ({ ... }))
// create 是高阶函数:
// → 接收一个函数,函数参数是 set(修改状态的方法)
// → 返回一个自定义 Hook:useAuthStore
// → 组件调用 useAuthStore 获取状态
// 状态初始化时从 localStorage 读取
token: localStorage.getItem('token') || '',
// → 页面刷新后 state 不会丢失
// → localStorage 持久化存储
// → token 为 null 时默认为空字符串
user: JSON.parse(localStorage.getItem('user')) || null,
// → localStorage 存的是 JSON 字符串
// → JSON.parse 还原为对象
// → 没有存储时返回 null
bash
Zustand Store 的设计理念:
React App = UI Component + Store
┌──────────────────────────────────────────────┐
│ Store(状态仓库) │
│ ├── state:token, user │
│ └── actions:setAuth, logout │
│ │
│ 职责: │
│ ├── 管理全局共享的登录状态 │
│ ├── 持久化到 localStorage │
│ └── 提供修改状态的方法 │
└──────────────────────────────────────────────┘
│
│ 组件通过 useAuthStore 获取
▼
┌──────────────────────────────────────────────┐
│ Components │
│ ├── Nav:读取 token/user,条件渲染 │
│ ├── RequireAuth:检查 token,守卫路由 │
│ └── Login:调用 setAuth,更新登录状态 │
└──────────────────────────────────────────────┘
setAuth 做了三件事:
javascript
setAuth: ({ token, user }) => {
localStorage.setItem('token', token); // ① 持久化 token
localStorage.setItem('user', JSON.stringify(user)); // ② 持久化 user
set({ token, user }); // ③ 更新 store 状态
}
// 三步缺一不可:
// localStorage → 页面刷新后状态不丢
// set → 触发所有使用该状态的组件重新渲染
logout 做了三件事:
javascript
logout: () => {
localStorage.removeItem('token'); // ① 清除持久化
localStorage.removeItem('user'); // ② 清除持久化
set({ token: '', user: null }); // ③ 重置状态
}
// 登出后所有依赖 token 的组件自动更新
// Nav 隐藏 Logout 按钮,显示 Login 链接
// RequireAuth 失去 token,路由被拦截
3.3 子仓库拆分:store/todos.js
javascript
import { create } from 'zustand';
export const useTodosStore = create(set => ({
todos: [],
setTodos: ({ todos }) => set({ todos }),
}));
bash
大型项目的 Store 拆分:
小型项目:
├── 一个 store 管理所有状态
└── 简单够用
中大型项目:
├── store/user.js → 鉴权子仓库
├── store/todos.js → Todos 子仓库
├── store/products.js → 产品子仓库
└── 按业务领域拆分,各自管理
Zustand 天然支持拆分:
→ 每个 create() 返回独立的 Hook
→ 组件按需引入,互不干扰
→ 不需要像 Redux 那样 combineReducers
四、Axios 拦截器:自动注入 Token
4.1 api/config.js
javascript
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000,
});
// 请求拦截器:每个请求自动带上 token
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:自动提取 res.data
instance.interceptors.response.use(res => {
return res.data;
});
export default instance;
4.2 axios.create:创建实例
javascript
const instance = axios.create({
baseURL: '/api',
timeout: 5000,
});
csharp
axios.create vs 直接用 axios:
直接用 axios:
axios.get('http://localhost:3000/api/login')
→ 每次都要写完整 URL
→ 没有统一配置
axios.create 创建实例:
const instance = axios.create({ baseURL: '/api' })
instance.get('/login')
→ 自动拼接为 /api/login
→ baseURL 统一配置
→ timeout 统一设置
→ 拦截器统一挂载
→ 一个项目可以有多个实例(不同后端服务)
4.3 请求拦截器
javascript
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
arduino
请求拦截器的执行流程:
组件调用 axios.get('/repo')
│
▼
请求拦截器拦截
│
│ config = {
│ url: '/repo',
│ method: 'get',
│ headers: {}
│ }
│
│ 从 localStorage 读取 token
│ config.headers['Authorization'] = 'Bearer eyJhbG...'
│
│ return config ← 必须返回,否则请求发不出去
│
▼
真正发起 HTTP 请求
GET /api/repo
Headers: { Authorization: 'Bearer eyJhbG...' }
价值:
├── 不用在每个 API 函数里手动加 token
├── 统一管理,改一处全局生效
├── 自动从 localStorage 读取,不依赖组件状态
└── token 不存在时不加 Header(登录请求不需要 token)
bash
Bearer Token 格式:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
→ Bearer 是固定的前缀关键字
→ 后面跟一个空格
→ 然后是 token 字符串
→ 服务器按空格分割:split(' ')[1] 取到 token
Bearer 的含义:"持有者"
→ 谁持有这个 token,谁就是合法用户
→ token 本身就是身份凭证
4.4 响应拦截器
javascript
instance.interceptors.response.use(res => {
return res.data;
});
css
响应拦截器的执行流程:
服务器返回响应
│
│ res = {
│ data: { code: 0, user: {...}, token: '...' },
│ status: 200,
│ headers: { ... },
│ config: { ... }
│ }
│
▼
响应拦截器拦截
│
│ return res.data
│ → 只返回业务数据
│ → 剥掉 HTTP 元信息(status, headers, config)
│
▼
组件拿到的就是:
{ code: 0, user: {...}, token: '...' }
价值:
├── 组件代码更简洁,不用 res.data.data
├── 统一处理响应格式
└── 可以在这里做全局错误处理(401 跳登录等)
4.5 API 函数
javascript
// api/user.js ------ 登录接口
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res;
};
javascript
// api/repo.js ------ 受保护接口
import axios from './config';
export const getRepo = async () => {
const res = await axios.get('/repo');
return res;
};
scss
API 层的设计原则:
config.js(Axios 实例 + 拦截器)
│
├── user.js(用户相关接口)
│ └── login(data) → POST /api/login
│
└── repo.js(仓库相关接口)
└── getRepo() → GET /api/repo
每个接口函数:
├── 只管调用 axios 实例
├── 不关心 token 怎么来的(拦截器自动处理)
├── 不关心响应怎么提取的(拦截器自动处理)
└── 只负责定义 URL 和 HTTP 方法
五、Vite Mock Server:模拟后端 JWT
5.1 vite.config.js
javascript
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, // 开发环境启用
}),
],
});
bash
Vite Mock 的工作原理:
浏览器请求 /api/login
│
▼
Vite Dev Server 拦截
│
│ 匹配 mock/user.js 中定义的规则
│ { url: '/api/login', method: 'post', response: ... }
│
│ 执行 response 函数
│ 返回模拟数据
│
▼
Axios 收到响应
→ 响应拦截器提取 res.data
→ 组件拿到 { code, user, token }
价值:
├── 前端独立开发,不依赖后端
├── baseURL: '/api' → Mock 拦截 /api/* 请求
├── 可以模拟 JWT 签发和验证
└── 切换到真实后端只需改 baseURL
5.2 Mock 后端:mock/user.js
javascript
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';
export default [
// ======== 受保护接口:验证 token ========
{
url: '/api/repo',
method: 'get',
response: req => {
// 提取 Bearer Token
const token = req.headers['authorization'].split(' ')[1];
try {
const decoded = jwt.verify(token, secret);
return { code: 0, data: decoded.user };
} catch (err) {
return { code: 401, msg: 'Invalid token' };
}
},
},
// ======== 登录接口:签发 token ========
{
url: '/api/login',
method: 'post',
timeout: 2000,
response: (req, res) => {
const body = req.body;
if (body.username !== 'admin' || body.password !== '123456') {
return { code: -1, message: 'username or password 错误' };
}
// 签发 JWT
const token = jwt.sign(
{ user: body.username, role: 'admin' },
secret,
{ expiresIn: 86400 }
);
return {
code: 0,
user: { username: body.username },
token: token,
};
},
},
];
5.3 登录接口:jwt.sign
javascript
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // 载荷:用户身份信息
secret, // 密钥:加盐加密
{ expiresIn: 86400 } // 选项:过期时间 24 小时
);
javascript
jwt.sign 的三个参数:
┌──────────────────────────────────────────────────┐
│ payload(载荷) │
│ { user: 'admin', role: 'admin' } │
│ → JSON 对象,包含用户身份信息 │
│ → 可以放任何非敏感信息 │
│ → 不要放密码!token 可以被解码 │
│ │
│ secret(密钥) │
│ 'secret819!$' │
│ → 服务器私有的加密密钥 │
│ → 只有知道 secret 的服务器才能签发和验证 │
│ → 相当于"加盐",防止 token 被伪造 │
│ │
│ options(选项) │
│ { expiresIn: 86400 } │
│ → 86400 秒 = 24 小时 │
│ → 过期后 token 失效,需要重新登录 │
│ → 防止 token 被盗后永久有效 │
└──────────────────────────────────────────────────┘
5.4 受保护接口:jwt.verify
javascript
const token = req.headers['authorization'].split(' ')[1];
try {
const decoded = jwt.verify(token, secret);
return { code: 0, data: decoded.user };
} catch (err) {
return { code: 401, msg: 'Invalid token' };
}
css
jwt.verify 的流程:
① 提取 token
req.headers['authorization'] → 'Bearer eyJhbG...'
.split(' ')[1] → 'eyJhbG...'
→ 按空格分割,取第二部分
② 验证 token
jwt.verify(token, secret)
→ 用相同的 secret 解密
→ 验证签名是否正确
→ 检查是否过期
→ 验证通过:返回 decoded = { user: 'admin', role: 'admin' }
→ 验证失败:抛出异常 TokenExpiredError / JsonWebTokenError
③ 根据结果返回
成功 → { code: 0, data: decoded.user }
失败 → { code: 401, msg: 'Invalid token' }
JWT 的分布式优势:
→ 任何一台服务器只要有 secret 就能验证 token
→ 不需要共享 Session
→ 天然适合微服务、分布式部署
六、路由守卫:RequireAuth 组件
6.1 components/RequireAuth.jsx
jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
const location = useLocation();
if (!token) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
return children;
}
export default RequireAuth;
6.2 路由守卫的工作原理
ini
┌──────────────────────────────────────────────────────────┐
│ 路由守卫工作流程 │
│ │
│ 用户访问 /pay │
│ │ │
│ ▼ │
│ React Router 匹配路由 │
│ <Route path="/pay" element={ │
│ <RequireAuth> │
│ <Pay /> │
│ </RequireAuth> │
│ } /> │
│ │ │
│ ▼ │
│ RequireAuth 组件渲染 │
│ │ │
│ │ ① 从 store 读取 token │
│ │ const token = useAuthStore(state => state.token) │
│ │ │
│ │ ② 判断 token 是否存在 │
│ │ │
│ ├── 有 token → return children → 渲染 Pay 组件 │
│ │ │
│ └── 无 token → return <Navigate to="/login" /> │
│ → 自动跳转到登录页 │
│ → state={{ from: location }} │
│ → 记住用户想去的页面 │
│ │
│ children 模式: │
│ → RequireAuth 包裹在路由外部 │
│ → children 就是 Pay 组件 │
│ → 有权限才渲染 children │
│ → 无权限跳转登录 │
└──────────────────────────────────────────────────────────┘
6.3 登录后跳回原页面
jsx
// RequireAuth 跳转时记录来源页面
<Navigate to="/login" replace state={{ from: location }} />
// └── 记住用户想访问的路径
// Login 页面登录成功后跳回原页面
const from = location.state?.from || '/';
navigate(from, { replace: true });
// → 从 state 中取出 from
// → 如果有 from,跳回原页面
// → 如果没有(直接访问登录页),跳到首页
bash
场景演示:
① 用户访问 /pay(未登录)
→ RequireAuth 无 token → 跳转 /login
→ state = { from: { pathname: '/pay' } }
② 用户在登录页输入账密,登录成功
→ navigate(from) → 跳回 /pay
→ 用户无感知,体验丝滑
③ 用户直接访问 /login(已登录或未登录)
→ state 为空
→ from = '/'
→ 登录成功后跳首页
七、App.jsx:路由配置与集成
7.1 完整代码
jsx
import React, { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import RequireAuth from './components/RequireAuth';
import Nav from './components/Nav';
import { getRepo } from './api/repo';
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
function App() {
useEffect(() => {
(async () => {
const res = await getRepo();
console.log(res);
})();
}, []);
return (
<Router>
<Nav />
<Suspense fallback={<div>loading...</div>}>
<Routes>
{/* 公开页面 */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* 受保护页面:用 RequireAuth 包裹 */}
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
</Routes>
</Suspense>
</Router>
);
}
export default App;
7.2 路由配置分析
javascript
路由表:
路径 组件 是否需要鉴权 懒加载
─────────────────────────────────────────────
/ Home 否 是
/login Login 否 是
/pay Pay 是(RequireAuth)是
懒加载(lazy + Suspense):
→ const Home = lazy(() => import('./pages/Home'))
→ 首页只加载当前路由的代码
→ 提升首屏加载速度
→ Suspense 包裹,加载中显示 fallback
受保护路由:
→ <RequireAuth><Pay /></RequireAuth>
→ Pay 组件被 RequireAuth 包裹
→ 渲染前先检查 token
→ 无 token 不渲染,跳转登录
7.3 Zustand 的选择器模式
javascript
// 组件中按需获取状态
const token = useAuthStore(state => state.token);
const user = useAuthStore(state => state.user);
const logout = useAuthStore(state => state.logout);
ini
选择器模式 vs 全量获取:
全量获取(不推荐):
const store = useAuthStore();
→ store 的任何状态变化都会触发组件重新渲染
→ 性能浪费
选择器获取(推荐):
const token = useAuthStore(state => state.token);
→ 只订阅 token 的变化
→ 只有 token 变了才重新渲染
→ user 变化不影响只用到 token 的组件
这和 Redux 的 useSelector 思想一致:
→ 精确订阅,按需渲染
八、Login 页面:表单校验与登录流程
8.1 Login.jsx 完整代码
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) {
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;
8.2 登录流程拆解
ini
┌──────────────────────────────────────────────────────────┐
│ 完整登录流程 │
│ │
│ ① 用户输入用户名和密码 │
│ handleChange → setFormData 更新表单数据 │
│ │
│ ② useEffect 实时校验 │
│ → 用户名不能为空,至少 3 位 │
│ → 密码不能为空,至少 6 位 │
│ → 校验通过:isValid = true,按钮可点击 │
│ → 校验失败:isValid = false,按钮禁用 │
│ │
│ ③ 点击登录按钮 │
│ handleLogin → e.preventDefault() 阻止默认提交 │
│ │
│ ④ 调用 API │
│ const res = await login(formData) │
│ → axios.post('/login', { username, password }) │
│ → 请求拦截器:不需要 token(登录请求) │
│ → Mock 服务器验证账密 │
│ → jwt.sign 签发 token │
│ → 响应拦截器:提取 res.data │
│ │
│ ⑤ 处理响应 │
│ res.code === 0 │
│ → setAuth({ token: res.token, user: res.user }) │
│ → 存入 localStorage │
│ → 更新 Zustand store │
│ → Nav 组件重新渲染(显示 Logout) │
│ → navigate(from, { replace: true }) │
│ → 跳回用户之前想访问的页面 │
│ │
│ ⑥ 登录失败 │
│ res.code !== 0 │
│ → alert(res.message) │
│ → 留在登录页 │
└──────────────────────────────────────────────────────────┘
8.3 CSS Module:样式隔离
css
/* Login.module.css */
.container {
max-width: 400px;
margin: 50px auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fafafa;
}
.error {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
vbnet
CSS Module 的价值:
普通 CSS:
→ .container { ... }
→ 全局作用域,可能和其他组件冲突
CSS Module:
→ Login.module.css
→ 编译后类名变成 Login_container_xxx
→ 天然隔离,不会冲突
→ import styles from './Login.module.css'
→ className={styles.container}
九、Nav 组件:条件渲染与登出
9.1 Nav.jsx
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, borderBottom: '1px solid #ccc' }}>
<Link to="/">Home</Link>
<Link to="/pay">Pay</Link>
{!token && <Link to="/login">Login</Link>}
{user && <span>Hello, {user.username}!</span>}
{token && <button onClick={handleLogout}>Logout</button>}
</nav>
);
}
export default Nav;
9.2 条件渲染逻辑
yaml
Nav 组件根据登录状态显示不同内容:
未登录状态(token 为空字符串):
├── Home 链接
├── Pay 链接
├── Login 链接 ← !token 为 true
└── 不显示用户名和登出按钮
已登录状态(token 不为空):
├── Home 链接
├── Pay 链接
├── 不显示 Login 链接 ← !token 为 false
├── Hello, admin! ← user 存在
└── Logout 按钮 ← token 存在
Zustand 的响应式更新:
→ setAuth 更新 store
→ Nav 组件自动重新渲染
→ 不需要手动管理 DOM
十、完整鉴权链路总结
10.1 端到端流程
scss
┌──────────────────────────────────────────────────────────────────┐
│ JWT 鉴权完整链路 │
│ │
│ 【未登录访问受保护页面】 │
│ │
│ ① 用户访问 /pay │
│ → RequireAuth 检查 token │
│ → token 为空 → Navigate to /login │
│ → state = { from: '/pay' } │
│ │
│ ② 登录页展示 │
│ → 用户输入 admin / 123456 │
│ → 实时校验通过 │
│ → 点击登录 │
│ │
│ 【登录流程】 │
│ │
│ ③ login(formData) │
│ → axios.post('/api/login', { username, password }) │
│ → 请求拦截器:无 token 不加 Header │
│ → Mock 服务器验证账密 │
│ → jwt.sign({ user, role }, secret, { expiresIn }) │
│ → 返回 { code: 0, user: { username }, token: 'eyJ...' } │
│ → 响应拦截器:提取 res.data │
│ │
│ ④ setAuth({ token, user }) │
│ → localStorage.setItem('token', token) │
│ → localStorage.setItem('user', JSON.stringify(user)) │
│ → set({ token, user }) → Zustand 更新 │
│ → Nav 组件重新渲染:显示 Logout + Hello, admin! │
│ │
│ ⑤ navigate(from) │
│ → 跳回 /pay │
│ → RequireAuth 检查 token → 通过 → 渲染 Pay │
│ │
│ 【后续请求自动携带 token】 │
│ │
│ ⑥ getRepo() │
│ → axios.get('/api/repo') │
│ → 请求拦截器:config.headers['Authorization'] = Bearer xxx │
│ → Mock 服务器 jwt.verify(token, secret) │
│ → 验证通过:返回 { code: 0, data: 'admin' } │
│ → 验证失败:返回 { code: 401, msg: 'Invalid token' } │
│ │
│ 【登出流程】 │
│ │
│ ⑦ handleLogout() │
│ → logout() │
│ → localStorage.removeItem('token') │
│ → localStorage.removeItem('user') │
│ → set({ token: '', user: null }) │
│ → Nav 组件重新渲染:显示 Login 链接 │
│ → 访问 /pay → RequireAuth 拦截 → 跳转 /login │
└──────────────────────────────────────────────────────────────────┘
10.2 各层职责
scss
┌──────────────────────────────────────────────────────────┐
│ 各层职责边界 │
│ │
│ 组件层(pages/ + components/) │
│ ├── Login:表单校验 + 调用 API + 更新 Store │
│ ├── RequireAuth:检查 token + 路由守卫 │
│ ├── Nav:条件渲染 + 登出操作 │
│ └── App:路由配置 + 懒加载 │
│ │
│ API 层(api/) │
│ ├── config.js:Axios 实例 + 请求/响应拦截器 │
│ ├── user.js:login(data) │
│ └── repo.js:getRepo() │
│ │
│ 状态层(store/) │
│ ├── user.js:token + user + setAuth + logout │
│ └── todos.js:todos + setTodos │
│ │
│ Mock 层(mock/) │
│ └── user.js:jwt.sign + jwt.verify │
│ │
│ 原则: │
│ 组件不直接操作 localStorage → 通过 Store │
│ 组件不直接拼接 token Header → 通过 Axios 拦截器 │
│ 组件不直接验证 token → 通过后端 API │
│ 每层只做自己的事,通过约定的接口协作 │
└──────────────────────────────────────────────────────────┘
十一、总结
11.1 知识体系图
bash
JWT 登录鉴权全链路
│
├── JWT 理论
│ ├── HTTP 无状态 → 需要身份凭证
│ ├── JWT = JSON Web Token
│ ├── sign:JSON 对象 → 加密 → token
│ ├── verify:token → 解密 → JSON 对象
│ ├── Bearer Token 格式:Authorization: Bearer xxx
│ └── 优势:无状态、分布式友好、跨域支持
│
├── Zustand 状态管理
│ ├── create(set => ({ state, actions }))
│ ├── 选择器模式:useAuthStore(s => s.token)
│ ├── auth Store:token + user + setAuth + logout
│ ├── localStorage 持久化
│ └── 子仓库拆分(auth / todos / ...)
│
├── Axios 拦截器
│ ├── axios.create({ baseURL, timeout })
│ ├── 请求拦截器:自动注入 Bearer Token
│ ├── 响应拦截器:自动提取 res.data
│ └── 组件不需要手动处理 token
│
├── 路由守卫
│ ├── RequireAuth 组件包裹受保护路由
│ ├── 无 token → Navigate to /login
│ ├── state={{ from }} 记住来源页面
│ └── 登录成功后跳回原页面
│
├── Vite Mock Server
│ ├── viteMockServe 插件配置
│ ├── mock/user.js 模拟后端
│ ├── /api/login:验证账密 → jwt.sign → 返回 token
│ └── /api/repo:jwt.verify → 返回数据或 401
│
├── Login 页面
│ ├── useEffect 实时表单校验
│ ├── 受控组件 + 状态管理
│ ├── 调用 login API → setAuth → navigate
│ └── CSS Module 样式隔离
│
└── Nav 组件
├── 条件渲染(!token / token / user)
├── Link 路由导航
└── logout 清除状态
11.2 核心概念速查
| 概念 | 要点 |
|---|---|
| JWT | JSON Web Token,sign 签发 / verify 验证 |
| Bearer Token | Authorization: Bearer xxx,服务器 split(' ')1 取 token |
| zustand | 轻量状态管理,create(set => ({ state, actions })) |
| 选择器模式 | useAuthStore(s => s.token),精确订阅,按需渲染 |
| 请求拦截器 | 每个请求自动注入 Authorization: Bearer ${token} |
| 响应拦截器 | 自动提取 res.data,组件拿到的就是业务数据 |
| RequireAuth | 路由守卫,无 token 跳登录页,记住来源页面 |
| localStorage | 持久化 token 和 user,页面刷新不丢失 |
| jwt.sign | { payload, secret, { expiresIn } } → token 字符串 |
| jwt.verify | (token, secret) → decoded 对象,失败抛异常 |
| Navigate | React Router 声明式跳转,replace 替换历史记录 |
| CSS Module | .module.css 后缀,编译后类名带 hash,样式隔离 |
11.3 一句话总结
前端鉴权工程 = JWT(身份凭证)+ Zustand(全局状态)+ Axios 拦截器(自动注入)+ 路由守卫(访问控制)。登录时
jwt.sign签发 token,ZustandsetAuth存储 token 到 localStorage,Axios 请求拦截器自动注入Bearer Token,RequireAuth 路由守卫拦截未授权访问------四个角色各司其职,组成完整的鉴权闭环。
如果这篇文章对你有帮助,欢迎点赞 和收藏!