从零看懂 JWT 登录鉴权:一个 React Demo 的完整拆解
这篇文章基于一个 login-demo 项目,带你走通"登录 → 颁发 token → 携带 token → 鉴权 → 访问受保护页面"的完整链路。项目技术栈是 React + react-router-dom + zustand + axios + mockjs + jsonwebtoken。
一、整体思路:为什么需要 JWT
HTTP 是无状态的(Stateless),所以服务器不知道"你是谁"。
既然服务器记不住身份,就需要一种方式让用户自己"证明自己是谁"。JWT(JSON Web Token)的思路是:
- 登录时,后端校验账号密码(
admin/123456); - 校验通过后,把用户的身份信息做成一个 JSON 对象 (比如
{ id: 1, username: 'admin', role: 'admin' }); - 用
sign把这个 JSON 对象加密成一串 token(这个操作是单向的,只能签发不能反推); - 前端每次请求都把 token 放进请求头的
Authorization字段(格式Bearer <token>); - 后端拿到 token 后用
verify解码,还原出 JSON 对象,就知道"你是谁"了。
对比传统的 cookie/session 方案:session 会把会话对象存在服务器内存里,请求通过携带的 sessionId 去查。这种方案不太适合分布式 ------因为 A 服务器的内存里不一定有 B 服务器签发的 session。而 JWT 没有这个问题:任何一台服务器签发的 token,都可以在任何一台自己的其他服务器上解码出来,因为它不依赖服务器本地存储,信息都在 token 自己身上。
二、Mock 后端:签发和校验 token
项目用 vite-plugin-mock 模拟后端接口:
arduino
viteMockServe({
mockPath: 'mock', // mock 文件所在目录
localEnabled: true, // 本地开发时启用
})
所有 mock 接口定义在 mock/user.js 里,核心是两个动作:sign(签发) 和 verify(校验) 。
1. 登录接口 /api/login ------ 用 sign 颁发 token
arduino
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // 身份信息 JSON 对象
secret, // 密钥(加盐)
{ expiresIn: 86400 } // 有效期
)
三个参数分别对应:要写入 token 的身份信息、密钥(secret819!$)、过期时间。登录成功返回 { code: 0, user, token }。
注意这里的 sign 是单项操作------你能用密钥把 JSON 变成 token,但拿着 token 逆推出密钥很难,这正是"加密"的意义。
2. 数据接口 /api/repo ------ 用 verify 校验 token
ini
const authHeader = req.headers['authorization']; // "Bearer XXXXX"
if (!authHeader) return { code: 401, msg: 'No token' };
const token = authHeader.split(' ')[1]; // 拆出 Bearer 后面的 token
let decoded = jwt.verify(token, secret); // 解码还原 JSON
return { code: 0, data: decoded.user };
先用 split(' ') 把 "Bearer xxx" 拆成两段,取第二段真正的 token,再用 verify 解码。解不出来就返回 401 Invalid token。
这里
401就是"未授权"的 HTTP 状态码,代表"你没有凭证或凭证无效"。
三、axios 拦截器:让每个请求自动带上 token
如果每次请求都手动加 token,既繁琐又容易漏。拦截器就是干这个的------统一在发请求前、收响应后做处理 。见 api/config.js:
php
const instance = axios.create({
baseURL: '/api/', // 统一前缀
timeout: 5000,
})
axios.create 创建一个独立的 axios 实例,所有接口都带上 /api/ 前缀。
request 拦截器 ------ 发请求前
ini
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config; // 请求配置对象
})
- 拦截器把每个 axios 请求都拦下来 ,拿到
config(请求配置对象); - 从
localStorage取出 token,写进请求头的Authorization; return config是关键------必须把配置对象还回去,请求才能继续发出去。
response 拦截器 ------ 收到响应后
kotlin
instance.interceptors.response.use(res => {
return res.data; // 直接返回服务器数据,省得每次取 .data
})
这里统一把 res.data 解包返回。所以 api/repo.js 和 api/user.js 里就不需要再 res.data 双重解包 ,直接 return res 即可。
javascript
// api/user.js
export const login = async (data) => {
const res = await axios.post('/login', data);
return res;
}
四、zustand:全局状态管理
登录与否、用户信息 是全局状态,需要在跨路由、跨组件的地方共享(比如导航栏和登录页都要读写)。这个项目用 zustand 统一管理。
先理解一个问题:React 里组件通信、状态共享有几种方式?
- 父子传递:父组件通过 props 把状态传给子组件;
- createContext + useContext:跨层级共享状态;
- zustand store:把状态抽到一个"状态仓库"里,任何组件都能读写,尤其适合全局状态。
store/user.js 就是一个 store:
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 });
}
}))
关键点:
create是一个高阶函数 :它接受一个函数作为参数,返回的也是一个函数(最终就是一个自定义 hookuseAuthStore)。set是修改状态的方法,状态和动作(action)都定义在同一个对象里。- 状态持久化 :
token和user的初始值从localStorage里读,setAuth和logout时同步写/删localStorage。这样刷新页面后登录状态不丢失------这就是 readme 里说的"后端签发的 token 放在 localStorage"。
user 存的是对象,所以存的时候要 JSON.stringify,读的时候要 JSON.parse。
为什么说"组件状态几乎都不放在 component"
store/todos.js 展示了第二个 store:
javascript
export const useTodosStore = create(set => ({
todos: [],
setTodos: ({ todos }) => {
set({ todos })
}
}))
注释里也点明了使用场景:大型项目 用子仓(多个 store 拆分),中小型项目 还是用传统状态共享即可。store 的本质就是把"状态 + 修改状态的动作"集中管理,让 React App = UI Component + State 这个等式更清晰。
在组件里怎么用 store
nav.jsx 里读取:
ini
const token = useAuthStore(state => state.token);
const user = useAuthStore(state => state.user);
const logout = useAuthStore(state => state.logout);
注意这里的用法:useAuthStore(state => state.token) 是通过**选择器(selector)**精准取出需要的某一块状态,而不是把整个 store 都拿过来。
Login.jsx 里写入:
scss
const setAuth = useAuthStore(state => state.setAuth);
// 登录成功:
setAuth({ token: res.token, user: res.user });
五、React Router:路由、懒加载与路由守卫
1. 路由结构
App.jsx 用 BrowserRouter 包住整个应用,Routes + Route 定义路由:
xml
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<RequireAuth><Pay /></RequireAuth>
} />
三个页面:/(首页,公开)、/login(登录,公开)、/pay(受保护,需要登录)。
2. 懒加载
javascript
const Home = lazy(() => import('./pages/Home'))
React.lazy 让组件按需加载 (而不是一开始全部加载),配合 <Suspense fallback={<div>Loading...</div>}>,在组件还没加载完时显示 fallback 占位内容。
3. 路由守卫 RequireAuth
RequireAuth.jsx 是保护 /pay 的关键:
javascript
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
if (!token) {
return <Navigate to="/login" replace />
}
return children;
}
逻辑非常直接:如果没有 token,就 <Navigate> 重定向到登录页;有 token 才渲染 children(也就是 Pay 页面) 。replace 表示用替换而不是压栈的方式跳转。
4. 导航栏 Nav
Nav.jsx 根据状态动态渲染:
javascript
{!token && <Link to="/login">Login</Link>} // 未登录才显示 Login
{user && <a>{user.username}</a>} // 登录后显示用户名
{token && <button onClick={handleLogout}>Logout</button>} // 登录后显示退出
点击 Logout 调用 logout(),清空 store 和 localStorage,导航栏随之切换回未登录状态。
六、登录页:表单 + 验证 + 跳转
Login.jsx 完整串联了前面的所有知识点:
1. 受控表单
ini
const [formData, setFormData] = useState({ username: '', password: '' });
const handleChange = e => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
输入框的值由 state 控制(value + onChange),用计算属性名 [name] 让两个输入框共用一个处理函数。
2. 表单校验
useEffect 依赖 formData,每当输入变化就重新校验:
- 用户名:不能为空、至少 3 位;
- 密码:不能为空、至少 6 位;
- 通过
isValid控制按钮disabled={!isValid},校验不通过按钮不可点。
3. 登录流程与跳转
ini
const from = location.state?.from || '/';
const handleLogin = async e => {
e.preventDefault();
const res = await login(formData);
if (res.code === 0) {
setAuth({ token: res.token, user: res.user }); // 1. 存入全局状态
navigate(from, { replace: true }); // 2. 跳转
} else {
alert(res.message || '登录失败');
}
};
code === 0 表示"没有错误"(对应 mock 里返回的 code: 0)。成功就 setAuth 保存身份 + navigate 跳转回原页面。
4. CSS Modules
Login.module.css 以 .module.css 结尾,用 import styles from './Login.module.css' 导入,通过 styles.container、styles.error 这种对象方式引用类名。这样样式是局部作用域的,不会污染全局。
七、知识点总结
- JWT 解决了什么:HTTP 无状态,服务器记不住"你是谁",于是用 token 让客户端自己携带身份凭证,每次请求带上,后端解码还原身份。
- 两个核心动作 :
sign(用密钥 + 身份 JSON 签发 token,单向加密)和verify(解码还原 JSON)。对比 session,JWT 不依赖服务器内存,适合分布式。 - token 传递约定 :请求头
Authorization: Bearer <token>,后端用split(' ')拆出 token;无效或缺失返回401。 - axios 拦截器 :
axios.create建实例(配 baseURL、timeout);request 拦截器 统一从 localStorage 取 token 塞进请求头(必须return config);response 拦截器 统一解包res.data,业务代码不再双重解包。 - zustand :
create是高阶函数,返回自定义 hook;状态和 action 同在一个对象里,用set修改;通过 selector(state => state.xxx)精准读取;配合localStorage实现登录状态持久化(JSON.stringify/JSON.parse)。 - React Router :
BrowserRouter+Routes+Route定义路由;React.lazy+Suspense实现按需加载;RequireAuth守卫组件用Navigate把无 token 用户重定向到登录页;useNavigate/useLocation实现登录后跳转回原页面。 - 受控表单 :输入值由 state 驱动,
[name]计算属性名复用处理函数,useEffect依赖变化做校验,disabled控制提交按钮。 - mock 开发 :
viteMockServe插件 +mock目录,无需真实后端即可联调登录鉴权全流程。 - CSS Modules :
.module.css文件 +styles.xxx引用,实现样式局部作用域,避免全局污染。
一句话串起来 :登录页提交表单 → axios(带 request 拦截器)发 /api/login → mock 后端 sign 签发 token 返回 → setAuth 存入 zustand + localStorage → 之后每个请求由 request 拦截器自动带 Authorization → 访问 /pay 时 RequireAuth 检查 token,没有就跳登录 → 有 token 时 /api/repo 用 verify 解码校验通过,返回用户数据。整条链路闭环。