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,没有就重定向走。
机制 :守卫的本质是"渲染前先问一句有没有证"。它没有后端参与,纯粹读前端存的 token。这带来一个事实------守卫拦的是"没登录",不是"token 假的",真正的真伪由后端 /api/repo 那一关验(见第六节、第八节)。
三、一次登录的完整旅程(端到端,含数据形态)
下面用**一次真实尝试(输入 admin / 123456)**串起所有文件,每一跳都标出"此时数据长什么样"。先上全链路图:
数据形态速查表(跟着表走一遍,逻辑就通了):
| 阶段 | 位置 | 此时数据长什么样 |
|---|---|---|
| ① 表单输入 | 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' | |
| ⑥ 签发 | 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 创建的实例做"两层变形"。这是真实项目必用模式,务必吃透:
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);
变形一(请求拦截器)------发之前 :从 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 扮演后端:
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 由三部分用 . 连接:
scss
header(头部) . payload(负载) . signature(签名)
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.js 的 setAuth 做了双写:
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 }) 跳到 /pay,RequireAuth 读到 token 非空 → 放行渲染 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
}
机制(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 它------它是已定义、未串联的演示代码,不要误以为登录流程依赖它。
九、两种入口路径对比
同一套机制,按"用户先去哪"分两条路径,逻辑完全一致,只是触发顺序不同:
- 先登录 :表单 → 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为何不参与流程。