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 /> 挂到 #root;App.jsx 定义路由表,三个页面都用了 lazy 懒加载:
/→Home(公开页)/login→Login(登录页)/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.jsx 的 formData | { 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 + zustand | token 字符串 + user 对象各存一份 |
| ⑩ 之后请求 | Authorization 请求头 | Bearer xxx.yyy.zzz |
逐文件走一遍:
Login.jsx的formData由受控输入框维护;点"登录"触发handleLogin,e.preventDefault()阻止表单默认提交,然后const res = await login(formData)。api/user.js的login(data)里data就是上面的formData,axios.post('/login', data)把它作为请求体发出。- 这个
axios是api/config.js的实例(见下一节),自动加/api前缀 → 实际POST /api/login,并被mock拦截。 mock/user.js取出req.body,对比写死的'admin'/'123456',一致就用jwt.sign签发,返回{ code:0, user, token }。- 响应经拦截器解包,
Login.jsx里res已是数据体,res.code === 0成立 →setAuth(...)+navigate(...)。
你问的
data从哪来,在这里闭环了:data是login函数的形参占位符,实参来自第①步Login.jsx的login(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.js 里 viteMockServe 拦截 /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.body 的 username/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.js 的 setAuth 做了双写:
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 }) 跳到 /pay,RequireAuth 读到 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/repo 在 try/catch 之后还有一行 return { code: 0, token }(约第 31-34 行),但 try 和 catch 都已 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 → 受保护接口放行。 - 先戳受保护页:访问
/pay→RequireAuth读不到 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 自验身份 |
易错点
- JWT 是签名不是加密:payload 只是 base64 编码,谁都能解码看到
user/role,所以别把密码塞进 payload。 data形参来源:login(data)的data是占位符,实参来自 login 函数的调用方Login.jsx的login(formData);别以为它是 mock 或 axios 凭空造的。- 响应拦截器已经解包:业务里
res就是后端数据体,res.code === 0直接判断;不要再写res.data.code。 baseURL: '/api'自动前缀:axios.post('/login')实际是/api/login,mock 也必须拦截/api/login,两边路径要对齐。RequireAuth没传from:从Pay被拦去登录后,登录完回的是首页'/',不是原来的/pay——回跳原页这个能力本 demo 未真正打通。todos.js是孤立的:定义了useTodosStore但没被任何组件使用,不是登录流程的一部分。- mock 的死代码:
/api/repo末尾return { code: 0, token }不可达,不影响功能,阅读时忽略即可。 - 账号密码只在 mock 里校验:
Login.jsx的isValid只是前端兜底(长度),真正的"对不对"由 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为何不参与流程。