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

1 阅读12分钟

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.6UI 渲染
react-router-dom^7.18.2前端路由 + 路由守卫
zustand^5.0.15全局状态(token / user),替代 Context/Redux
axios^1.19.0HTTP 请求 + 拦截器
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' || body.password!=='123456'false,通过
⑥ 签发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 + zustandtoken 字符串 + 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 创建的实例做"两层变形"。这是真实项目必用模式,务必吃透:

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 扮演后端:

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 由三部分用 . 连接:

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

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

store/user.jssetAuth 做了双写

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 这样取:

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 去访问受保护接口":

useEffect(() => {
  (async () => { const res = await getRepo(); console.log(res); })()
}, []);

api/repo.js

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

链路回到 mock 的 /api/repo

// /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.jszustand 存 token/user + localStorage 持久化
store/todos.js另一个 zustand 子仓(示范分模块),本 demo 未接入流程
api/config.jsaxios 实例 + 请求/响应两个拦截器
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.jslocalStorage 持久化 + set 驱动 UI,缺一不可
选择器精准订阅Nav.jsx只取要的那块,避免多余重渲染
路由守卫RequireAuth.jsx!token<Navigate> 重定向
from 回跳Login.jsxlocation.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 为何不参与流程。