JWT 登录认证完整流程(React + Vite 实战复习)

JWT 登录认证完整流程(React + Vite 实战复习)

摘要

这是一个用 React + Vite 搭的 JWT 登录认证完整演示项目 ,不需要真实后端------vite-plugin-mock 在本地扮演后端,签发并校验 token。整条链路只有一句话:登录拿证,带证访问

本文目标是让你把逻辑链条彻底理顺 ,所以采用"先看全局结构 → 再追踪一次完整登录的每一跳(含数据形态)→ 逐层放大难点(axios 变形、JWT 原理、token 存与驱动、无状态闭环)→ 对比两种入口路径"的讲法。你之前提的 8 个澄清点(mock 是假后端、JWT 是签名不是加密、data 形参来源、zustand 选择器、api 分层、repo 验票等)都已揉进对应章节,并补了"篡改 token 会怎样""刷新页面为什么不掉登录"这类帮你建立直觉的内容。

一、技术栈(已验证版本)

技术 版本 在本项目的作用
Vite ^8.0.12 构建 + 开发服务器
React / react-dom ^19.2.6 UI 渲染
react-router-dom ^7.18.2 前端路由 + 路由守卫
zustand ^5.0.15 全局状态(token / user),替代 Context/Redux
axios ^1.19.0 HTTP 请求 + 拦截器
vite-plugin-mock ^3.0.2 本地模拟后端接口,免真实服务器
jsonwebtoken ^9.0.3 服务端签发 / 校验 JWT

先建立一个全局心智模型:这个项目里没有真实后端进程 。所有"后端行为"都是 mock/user.js 在浏览器发请求时被 vite-plugin-mock 拦截后就地返回的。所以你跑 npm run dev 时只起了一个前端,token 的签发和校验都在前端进程里用 jsonwebtoken 完成(真实项目里这部分会放到真正的 Node 后端)。

二、先把"全局长什么样"画出来

入口 main.jsx<App /> 挂到 #rootApp.jsx 定义路由表,三个页面都用了 lazy 懒加载:

  • /Home(公开页)
  • /loginLogin(登录页)
  • /pay<RequireAuth> 包裹的 Pay(受保护页)

Nav 导航栏在路由外一直渲染,根据登录状态切换显示内容。RequireAuth 是路由守卫:读 zustand 里的 token没有就重定向走

flowchart TD A[main.jsx 挂载 App] --> B[App.jsx 路由表] B --> C[Home 公开页] B --> D[Login 登录页] B --> E[RequireAuth 包裹 Pay] E --> F[token 判断] F -->|无 token| G[Navigate 重定向到 Login] F -->|有 token| H[渲染 Pay 受保护页]

机制 :守卫的本质是"渲染前先问一句有没有证"。它没有后端参与,纯粹读前端存的 token。这带来一个事实------守卫拦的是"没登录",不是"token 假的",真正的真伪由后端 /api/repo 那一关验(见第六节、第八节)。

三、一次登录的完整旅程(端到端,含数据形态)

下面用**一次真实尝试(输入 admin / 123456)**串起所有文件,每一跳都标出"此时数据长什么样"。先上全链路图:

flowchart TD A[用户在 Login 表单输入] --> B[formData 累积成对象] B --> C[handleLogin 调 login formData] C --> D[api user.js 发 POST login] D --> E[axios 实例加前缀 注入头] E --> F[mock 拦截 校验账号密码] F -->|正确| G[jwt.sign 签发 token] F -->|错误| Z[返回 code 错误 提示] G --> H[响应体 code0 user token] H --> I[setAuth 双写] I --> J[navigate 跳转] J --> K[RequireAuth 读到 token 放行]

数据形态速查表(跟着表走一遍,逻辑就通了):

阶段 位置 此时数据长什么样
① 表单输入 Login.jsxformData { username: 'admin', password: '123456' }
② 提交 login(formData)data 同一个对象(引用传递,不拷贝)
③ 请求体 axios.post('/login', data) { username:'admin', password:'123456' }
④ mock 收到 req.body { username:'admin', password:'123456' }
⑤ 校验对比 `body.username!=='admin'
⑥ 签发 jwt.sign({ user: body.username, role:'admin' }, secret, {expiresIn:86400}) payload 为 { user:'admin', role:'admin' }
⑦ 返回 响应体 { code:0, user:{username:'admin'}, token:'xxx.yyy.zzz' }
⑧ 业务收到 res(响应拦截器已解包成 res.data { code:0, user:{username:'admin'}, token:'xxx.yyy.zzz' }
⑨ 存储 localStorage + zustand token 字符串 + user 对象各存一份
⑩ 之后请求 Authorization 请求头 Bearer xxx.yyy.zzz

逐文件走一遍

  1. Login.jsxformData 由受控输入框维护;点"登录"触发 handleLogine.preventDefault() 阻止表单默认提交,然后 const res = await login(formData)
  2. api/user.jslogin(data)data 就是上面的 formDataaxios.post('/login', data) 把它作为请求体发出。
  3. 这个 axiosapi/config.js 的实例(见下一节),自动加 /api 前缀 → 实际 POST /api/login,并被 mock 拦截。
  4. mock/user.js 取出 req.body,对比写死的 'admin'/'123456',一致就用 jwt.sign 签发,返回 { code:0, user, token }
  5. 响应经拦截器解包,Login.jsxres 已是数据体,res.code === 0 成立 → setAuth(...) + navigate(...)

你问的 data 从哪来,在这里闭环了datalogin 函数的形参占位符,实参来自第①步 Login.jsxlogin(formData)。它一路透传成请求体,再被 mock 当作 req.body 收到。名字叫 formData/body/payload 都行,关键是调用点在 Login.jsx 第 46 行

四、请求在 axios 实例里经历了什么(两层变形)

业务代码只写 login(formData),但请求真正发出前,会经过 api/config.js 创建的实例做"两层变形"。这是真实项目必用模式,务必吃透:

js 复制代码
const instance = axios.create({ baseURL: '/api', timeout: 5000 });

instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) config.headers['Authorization'] = `Bearer ${token}`;
  return config;
});

instance.interceptors.response.use(res => res.data);
flowchart TD A[业务调用 login formData] --> B[进入 axios 实例] B --> C[请求拦截器 读 localStorage token] C -->|有 token| D[加 Authorization Bearer 头] C -->|无 token| E[不加头 直接发] D --> F[实际请求 api login] E --> F F --> G[收到 HTTP 响应] G --> H[响应拦截器 返回 res.data] H --> I[业务拿到数据体]

变形一(请求拦截器)------发之前 :从 localStorage 取 token,有就给请求配置加 Authorization: Bearer xxx 头。登录时通常还没有 token,这一层此时跳过(所以登录请求不带头,mock 的 /api/login 也不校验头,只看账号密码)。

变形二(响应拦截器)------收之后 :直接 return res.data,把"HTTP 响应对象"剥成"后端数据体"。所以第③步里业务拿到的 res 其实已经是 res.data------这也是为什么 Login.jsx 直接写 res.code === 0,而不需要 再写 res.data.code

为什么 api 要分层(你问的"api 文件夹是干什么"):

  • config.js 是地基,只建一次 axios 实例 + 拦截器;
  • user.js / repo.js 是一个个业务接口,都 import axios from './config',复用同一套 baseURL、token 注入、响应解包。
  • 组件只调 login(formData) / getRepo(),不碰 axios、不关心 HTTP 细节。哪天把 axios 换成 fetch,只改 api/ 一处,组件零改动。一句话------接口层负责"调什么",config 层负责"怎么调",组件只关心业务

五、mock 假后端怎么"发证"

vite.config.jsviteMockServe 拦截 /api/*mock/user.js 扮演后端:

js 复制代码
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';

// /api/login
response: (req) => {
  const body = req.body;                       // 就是 Login 传来的 formData
  if (body.username !== 'admin' || body.password !== '123456') {
    return { code: -1, msg: '用户名或密码错误' };
  }
  const token = jwt.sign(
    { user: body.username, role: 'admin' },    // payload 放用户信息
    secret,
    { expiresIn: 86400 }                       // 有效期 24 小时
  );
  return { code: 0, user: { username: body.username }, token };
}

机制(mock 是"假后端",不是"等访问后才生成")vite-plugin-mock 在浏览器发请求时把它拦下来,直接返回响应------它扮演的就是后端角色。浏览器(axios)根本不关心响应是真实服务器还是 mock 给的。mock 里已写了 console.log(body),跑 npm run dev 的终端能看到你输入的账号密码。

数据来源要分清req.bodyusername/password用户输入 ;用来对比的 'admin'/'123456' 是 mock 里写死的"正确答案" (扮演数据库里的合法账号);签发时 user: body.username 又把用户输入塞回 token。所以你输入 admin/123456 能拿到 token,输别的返回 code: -1、前端 alert('用户名或密码错误')

六、核心机制:JWT 是「签名」不是「加密」

这是最容易说错的一点,也是你重点澄清过的。JWT 用的是 signature(签名) ,不是 encryption(加密)

加密 encrypt 签名 sign
内容 不可见,需密钥解密 可见,只是防篡改
目的 保密 验证身份 / 完整性

JWT 由三部分用 . 连接:

scss 复制代码
header(头部) . payload(负载) . signature(签名)
flowchart TD A[header 头部 算法声明] --> D[用点连接成 token] B[payload 负载 用户信息 仅 base64 编码可见] --> D C[signature 签名 由 secret 生成 防篡改] --> D D --> E[Bearer 之后带在请求头] F[secret 密钥 仅用于签名] --> C
  • payload 只是 base64 编码,不是加密 。base64 是"编码"(能直接解码还原),不是"加密"(需要密钥才能还原)。任何人拿到 token 都能把中间那段 base64 解出来看到 {"user":"admin","role":"admin"}jwt.sign({ user:'admin', role:'admin' }, secret, ...) 里的 user 是"看得见"的。
  • secret(本例 'secret819!$')不负责"锁起来不让看" ,而是用来生成签名。签名 = 用 secret 对"头部+负载"算出的一个校验值。服务端之后用 jwt.verify(token, secret) 校验:重算一遍,和 token 里带的签名比对,对不上就说明内容被改过。

篡改示例(帮你建立直觉) :假设攻击者拿到一个合法 token,把中间 payload 段的 admin 改成 hacker,试图伪装成另一个用户。但签名是用原始 payload 算的,篡改后的 payload 重算出来的签名和原签名不一致 → jwt.verify 抛错 → 后端 catch 返回 401 token无效。所以 JWT 能保证"这 token 是我发的、内容没被改过",但保证不了内容保密 ------这正是"别把密码塞进 payload"的底层原因。另外 token 设了 expiresIn: 86400(24 小时),过期后 verify 同样抛错 → 401。

一句话总结:JWT = 明文信息(base64 编码)+ 一个防篡改的签名。它能"验明正身",但不保密。

七、拿到 token 后:怎么存、怎么驱动界面

登录成功回到 handleLogin

jsx 复制代码
if (res.code === 0) {
  setAuth({ token: res.token, user: res.user });  // 核心一步
  navigate(from, { replace: true });              // 跳回之前想去的页
}

store/user.jssetAuth 做了双写

js 复制代码
export const useAuthStore = create(set => ({
  token: localStorage.getItem('token') || '',     // 初始值从 localStorage 恢复
  user: JSON.parse(localStorage.getItem('user')) || null,
  setAuth: ({ token, user }) => {
    localStorage.setItem('token', token);          // ① 持久化:刷新不丢
    localStorage.setItem('user', JSON.stringify(user));
    set({ token, user });                          // ② 更新内存:驱动 UI
  },
  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    set({ token: '', user: null });
  }
}))

机制(双存储为什么配合)

  • localStorage.setItem 负责"刷新页面后 token 还在"(持久化)。store 的初始值 token: localStorage.getItem('token') || '' 就是靠它,刷新后自动恢复登录态------所以刷新页面不会掉登录。
  • set({ token, user }) 负责"改了状态 → 订阅它的组件立刻重渲染"(驱动 UI)。
  • 只写 localStorage 界面不变;只写 set 刷新就丢。两者必须一起用。

机制(zustand 选择器为什么"挑着取")Nav.jsx 这样取:

jsx 复制代码
const token = useAuthStore((state) => state.token);
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);

每次 useAuthStore(selector) 只订阅 store 里那一块。只取 token,那 user 变化时这个组件不会重渲染------性能更好,也是官方推荐写法。对比 const { token, user } = useAuthStore() 一次拿全部,store 里任何字段变化都会让 Nav 重渲染。取到的 token/user 决定显示 Login 入口还是用户名 + Logout 按钮;logout 在点击时执行退出(清空 localStorage + set 空值 → Nav 重新订阅到变化 → 回到未登录界面)。

存证后两件事同步发生:Nav 作为订阅者立刻重渲染(隐藏 Login、显示用户名和 Logout);navigate(from, { replace: true }) 跳到 /payRequireAuth 读到 token 非空 → 放行渲染 Pay

flowchart TD A[登录成功 res.code 0] --> B[setAuth 写入] B --> C[localStorage 持久化 刷新不丢] B --> D[zustand set 更新内存] D --> E[Nav 订阅到 token 重渲染] D --> F[RequireAuth 读到 token 放行] E --> G[显示用户名 和 Logout] F --> H[渲染 Pay 页]

事实层(回跳的真相)from 来自 location.state?.from || '/'。但本项目里 RequireAuth 重定向用的是 <Navigate to="/login" replace />没把 from 作为 state 传过去Nav<Link to="/pay"> 也没带 state。所以实际从 Pay 被拦去登录后,from 默认是 '/',登录完毕跳回的是首页而不是原来的 /pay。代码确有其逻辑,但本 demo 未真正串起"回跳原页"------这是已实现的写法里的一个待完善点。

八、带 token 访问受保护接口(无状态闭环)

App.jsx 一挂载就调 getRepo(),演示"登录后拿 token 去访问受保护接口":

jsx 复制代码
useEffect(() => {
  (async () => { const res = await getRepo(); console.log(res); })()
}, []);

api/repo.js

js 复制代码
export const getRepo = async () => {
  const res = await axios.get('/repo');  // 实际请求 /api/repo
  return res;
};

链路回到 mock 的 /api/repo

js 复制代码
// /api/repo
const auth = req.headers['authorization'];
if (!auth) return { code: 401, msg: 'token无效' };
const token = auth.split(' ')[1];          // 取到 Bearer 后面的 token
try {
  let decoded = jwt.verify(token, secret);  // 验签名 + 验过期
  return { code: 0, data: decoded.user };   // 通过 → 返回解码出的用户
} catch (err) {
  return { code: 401, msg: 'token无效' };    // 篡改/过期/缺失 → 401
}
flowchart TD A[App 启动 调 getRepo] --> B[axios get 请求 repo] B --> C[请求拦截器 自动加 Bearer token] C --> D[mock 后端 收请求] D --> E[jwt.verify 校验 token] E -->|合法| F[返回 解码用户数据] E -->|缺失或篡改| G[返回 401 token无效]

机制(repo 是"验票",和 login"发证"配对)

接口 角色 依赖
/api/login 颁发 token(发证) 用户名密码
/api/repo 校验 token(验票) 必须带有效 token

login 只证明"你输对了账号密码";真实项目里登录后访问的每一个数据接口都要证明"你确实是那个登录过的人",靠的就是请求时带上 token、后端 jwt.verify 校验。这就是 无状态认证 ------服务端不存会话,靠 token 自己验明身份。你可以这样观察:不登录直接打开页面 → 没 token → /api/repo 返回 401;登录后刷新 → 拦截器自动带 token → 校验通过返回用户名。

事实层(一处死代码) :mock 的 /api/repotry/catch 之后还有一行 return { code: 0, token }(约第 31-34 行),但 trycatch 都已 return,函数不可能走到那里------属于不可达的死代码,阅读时忽略即可,不影响功能。

事实层(todos store)store/todos.js 新建了 useTodosStore(注释说是"大型项目分模块 store/子仓"示范),但整个登录流程里没有任何文件 import 它------它是已定义、未串联的演示代码,不要误以为登录流程依赖它。

九、两种入口路径对比

同一套机制,按"用户先去哪"分两条路径,逻辑完全一致,只是触发顺序不同:

flowchart TD A[用户打开页面] --> B{先去哪} B -->|直接访问 Pay| C[RequireAuth 读 token] C -->|无 token| D[重定向 Login 登录后回首页] B -->|先去 Login| E[填表登录成功] E --> F[setAuth 拿到 token] F --> G[之后任何请求带 token]
  • 先登录 :表单 → mock 发证 → setAuth 存证 → 之后所有请求(含 getRepo)自动带 token → 受保护接口放行。
  • 先戳受保护页 :访问 /payRequireAuth 读不到 token → 重定向到 /login(本 demo 未带 from 状态)→ 登录成功后跳回首页 /,此时已有 token,再去 /pay 就能进。

无论哪条路,核心都是那 8 个字:登录拿证,带证访问

十、文件职责清单

文件 职责
main.jsx 入口,挂载 <App />
App.jsx 路由表 + 懒加载页面 + 挂载即调 getRepo 演示
components/RequireAuth.jsx 路由守卫:没 token 重定向到 /login
components/Nav.jsx 导航栏,用选择器按登录状态显示/隐藏
pages/Login.jsx 登录表单 + 实时校验 + 提交
store/user.js zustand 存 token/user + localStorage 持久化
store/todos.js 另一个 zustand 子仓(示范分模块),本 demo 未接入流程
api/config.js axios 实例 + 请求/响应两个拦截器
api/user.js 登录接口封装
api/repo.js 受保护接口封装(演示验 token)
mock/user.js 模拟后端:签发 + 校验 JWT
vite.config.js 启用 viteMockServe 加载 mock

小结(知识覆盖表)

知识点 落在哪 一句话
JWT 全流程 mock 签发 + 拦截器加头 + repo 校验 签发 jwt.sign → 存储 → 拦截器加 Authorization → 校验 jwt.verify
签名 ≠ 加密 第六节对比表 + 篡改示例 payload base64 可见,secret 只用于防篡改签名
axios 双拦截器 api/config.js 请求统一加 token,响应统一解包 res.data
zustand 双存储 store/user.js localStorage 持久化 + set 驱动 UI,缺一不可
选择器精准订阅 Nav.jsx 只取要的那块,避免多余重渲染
路由守卫 RequireAuth.jsx !token<Navigate> 重定向
from 回跳 Login.jsx location.state?.from,但本 demo 未真正串起
无状态认证闭环 App.jsx + /api/repo 服务端不存会话,靠 token 自验身份

易错点

  1. JWT 是签名不是加密 :payload 只是 base64 编码,谁都能解码看到 user/role,所以别把密码塞进 payload
  2. data 形参来源login(data)data 是占位符,实参来自 login 函数的调用方 Login.jsxlogin(formData);别以为它是 mock 或 axios 凭空造的。
  3. 响应拦截器已经解包 :业务里 res 就是后端数据体,res.code === 0 直接判断;不要再写 res.data.code
  4. baseURL: '/api' 自动前缀axios.post('/login') 实际是 /api/login,mock 也必须拦截 /api/login,两边路径要对齐。
  5. RequireAuth 没传 from :从 Pay 被拦去登录后,登录完回的是首页 '/',不是原来的 /pay------回跳原页这个能力本 demo 未真正打通。
  6. todos.js 是孤立的 :定义了 useTodosStore 但没被任何组件使用,不是登录流程的一部分。
  7. mock 的死代码/api/repo 末尾 return { code: 0, token } 不可达,不影响功能,阅读时忽略即可。
  8. 账号密码只在 mock 里校验Login.jsxisValid 只是前端兜底(长度),真正的"对不对"由 mock 的 'admin'/'123456' 决定。

自测清单

  • 能口述从"输入账号密码"到"拿到 token"的完整请求链路(组件 → api → axios 实例 → mock 拦截),并说出每一跳数据长什么样。
  • 能说清 axios 两个拦截器各自做了什么、在请求的哪一刻发生,以及为什么业务代码拿到的直接是数据体。
  • 能解释 jwt.sign 签发了什么、jwt.verify 校验了什么,能区分"签名"和"加密",并讲出"篡改 payload 会被 401"的原因。
  • 能说明 setAuth 为什么既要 localStorage.setItem 又要 set,缺一个会怎样;并解释"刷新页面为什么不掉登录"。
  • 能讲出 zustand 选择器"挑着取"相比"一次拿全部"的好处,以及 Nav 为何能在登录后自动切换显示。
  • 能描述 RequireAuth 的拦截套路,以及 Nav 如何靠订阅 store 自动切换显示。
  • 能对比 /api/login(发证)与 /api/repo(验票)的角色,并说明什么是无状态认证。
  • 能指出本 demo 里 from 回跳为何实际回到首页、以及 todos.js 为何不参与流程。
相关推荐
Java陈序员3 小时前
轻量运维面板!一款现代化的服务器控制面板工具!
运维·服务器·python·react.js·github
光影少年6 小时前
RN首屏加载速度优化全方案
react native·react.js·掘金·金石计划
前端精髓8 小时前
React 更新 state 中的对象
javascript·react.js·ecmascript
10share14 小时前
我为什么用 React 重写了一个 VitePress
前端·react.js
天道kabuto14 小时前
前端每日知识点:React 与 Vue Diff 算法对比 · 完整总结
vue.js·react.js·前端框架
moMo14 小时前
React 全栈实战:从组件到鉴权的完整指南
react.js
D_jing2016 小时前
【踩坑解决】pnpm+Vite引入@koi/core报错spark-md5找不到模块(幽灵依赖深度解析)
pnpm·vite·幽灵依赖·前端踩坑
Flynt1 天前
pnpm 12 换上了 Rust 内核,我拿项目实测了一轮构建速度
rust·vite·前端工程化
光影少年1 天前
如何实现RN 多环境、多渠道打包
前端·react native·react.js