登录之后,那个长长的 token 到底是怎么"活"起来的?这篇文章不背概念,带着你跟着 token 从出生到校验,把每一个文件、每一次跳转、每一次自动加在请求头里的
Authorization都走一遍。
前言
最近在做一个小项目,卡在"登录之后怎么让后端认识我"这个问题上折腾了很久。市面上讲 JWT 的文章很多,但大多数是各讲各的:这篇讲 jsonwebtoken 怎么 sign,那篇讲 axios 拦截器怎么写,还有讲 zustand 的、讲路由守卫的……单看都懂,合在一起就不知道这些文件是怎么串起来的了。
所以这篇文章我想换一种讲法:以 token 的一生为主线,把 React + react-router-dom + zustand + axios + vite-plugin-mock + jsonwebtoken 这堆东西,用"文件之间的联系"串成一条完整的线。
你将会收获:
- 🎯 搞清楚 JWT 到底解决了什么问题,和 cookie/session 有啥区别
- 🔑 理解
jsonwebtoken里sign(签发)和verify(校验)两个动作的本质 - 🚀 用
vite-plugin-mock在前端就把"后端服务器"假出来,不用等真后端 - 🕵️ 看懂 axios 拦截器怎么"隐身"地给每个请求自动加上 token
- 🏪 理解为什么登录状态要用 zustand 全局管,而不是在组件里传
- 🛡️ 学会写路由守卫,没登录就滚去登录页
- 📌 走一遍完整的登录 → 鉴权 → 登出流程,知道每个文件在哪个环节出场
技术栈:React 19 + react-router-dom 7 + zustand 5 + axios + vite-plugin-mock + jsonwebtoken
目录
- 一、先搞懂:HTTP 是无状态的,那"你是谁"怎么办
- 二、全景图:token 的一生和它走过的文件
- 三、jsonwebtoken:sign 和 verify 两个动作
- 四、用 mock 把后端"假"出来
- 五、axios 拦截器:token 的隐身携带者
- 六、zustand:登录状态的中央仓库
- 七、路由守卫 RequireAuth:把门的保安
- 八、把完整流程串起来
- 九、我踩过的坑
- 十、面试高频 N 问
- 总结
一、先搞懂:HTTP 是无状态的,那"你是谁"怎么办
1.1 痛点:服务器是个"脸盲"
HTTP 协议有个特性叫 无状态(Stateless)。什么意思呢?
打个比方:你去一家会员制餐厅,服务员不记人脸。你点一次菜,他给你上;你下次再来点菜,他完全不记得你是谁、上次充了多少钱、是不是会员。你每次都得重新自我介绍一遍。
Web 也是一样。浏览器发一个请求,服务器处理完就把这事忘了。你刚登录过,再发第二个请求,服务器还是不认识你。那问题就来了:
我已经登录了,凭什么每次请求都要重新证明"我是我"?
这就是**鉴权(Authentication)**要解决的问题:让服务器在每一次"无状态"的请求里,认出你是有身份的人。
1.2 最经典的方案:cookie + session
老办法是这样的:
┌────────┐ 1.登录 ┌────────┐
│ 浏览器 │ ──────► │ 服务器 │ 服务器记下你是谁,生成一个 sessionId
└────────┘ └────────┘
│ ┌──────┴──────┐
│ 2.把 sessionId │ 内存/Redis │
│ 存进 cookie │ 存 session │
│◄───────────────┴─────────────┘
│
│ 3.之后每次请求,cookie 自动带上 sessionId
│ ───────────────────────────► 服务器拿 sessionId 去查:哦,是你!
好处是服务器能"记住"你,坏处也很明显:服务器得维护一份 session 数据。你登录的这台服务器记下了 session,换一台服务器它就不知道了。
⚠️ 面试考点:为什么 cookie/session 不适合分布式? 因为 session 存在某台服务器的内存里,用户的 sessionId 在这台机器上能查到,换到另一台机器就查不到了。要么做 session 共享(增加复杂度),要么用别的方案。
1.3 JWT 的思路:把身份"刻"在 token 里
JWT 的思路完全不同:服务器不记你,而是把你是谁"打包"成一个加密的字符串,塞给你自己保管。
| cookie + session | JWT | |
|---|---|---|
| 服务器存不存状态 | 存(session) | 不存(无状态) |
| 身份信息放哪 | 服务器内存/Redis | 编码在 token 里 |
| 换台服务器还能认吗 | 认不了(要共享 session) | 能(任何机器都能解) |
| 类比 | 餐厅记本子上你是谁 | 给你发一张带防伪的会员卡 |
一句话类比:cookie/session 像"网吧登记本"(老板记着你是谁),JWT 像"游乐园手环"(手环就是凭证,验手环就行,不查你是谁)。
JWT 的核心流程就四步:
1. 登录 → 服务器校验账号密码,用 jwt.sign 把 {username, role} 签成一个 token 发给你
2. 存储 → 前端把 token 存起来(localStorage)
3. 携带 → 之后每个请求,拦截器自动把 token 塞进请求头 Authorization: Bearer xxx
4. 校验 → 服务器用 jwt.verify 解开 token,还原出 JSON 身份对象
这四步,就是贯穿全文的主线。下面我们看看它们分别发生在哪个文件里。
二、全景图:token 的一生和它走过的文件
在讲代码之前,先给你一张"地图"。这个项目不算大,但文件之间的依赖关系特别值得看清楚——很多人卡住,就是因为不知道"这个变量从哪来、这个函数去哪了"。
2.1 目录结构(每个文件一句职责)
login-demo/
├── mock/
│ └── user.js ← 假后端:处理 /api/login 和 /api/repo,签发和校验 token
├── src/
│ ├── api/
│ │ ├── config.js ← axios 实例 + 拦截器(核心!token 的隐身携带者)
│ │ ├── user.js ← 登录接口封装(调用 config 的实例)
│ │ └── repo.js ← 受保护接口封装(调用 config 的实例)
│ ├── store/
│ │ └── user.js ← zustand 全局仓库:token / user 状态 + setAuth / logout
│ ├── components/
│ │ ├── Nav.jsx ← 导航栏:根据 token 显示 Login / Logout 按钮
│ │ └── RequireAuth.jsx ← 路由守卫:没 token 就 Navigate 到 /login
│ ├── pages/
│ │ ├── Login.jsx ← 登录页:表单 → 调 login → setAuth → 跳转
│ │ ├── Home.jsx ← 首页(公开)
│ │ └── Pay.jsx ← 付费页(受保护)
│ ├── App.jsx ← 路由表 + 懒加载
│ └── main.jsx ← 入口
├── vite.config.js ← 注册 vite-plugin-mock 插件
└── package.json
2.2 文件依赖关系图
这张图是全文最重要的一张,先印在脑子里:
┌────────────────────────────────────────┐
│ vite.config.js │
│ 注册 viteMockServe(mockPath:'mock') │
└──────────────┬─────────────────────────┘
│ 加载
▼
┌────────────────────────────────────────┐
│ mock/user.js │
│ /api/login → jwt.sign 签发 token │
│ /api/repo → jwt.verify 校验 token │
└──────────────▲─────────────────────────┘
│ 拦截请求,返回假数据
│
┌─────────────┐ useNavigate ┌────────────────────────────────────────┐
│ pages/ │ useLocation │ src/api/ │
│ Login.jsx ──┼──────────────► │ user.js ──┐ │
│ │ login() │ repo.js ──┼──► config.js (拦截器) │
└──────┬──────┘ └─────────────┘ baseURL:'/api' │
│ 自动加 Authorization │
│ setAuth(token,user) └─────────────────────┘
▼
┌─────────────────────┐ useAuthStore ┌─────────────────────┐
│ store/user.js │◄───────────────────────────►│ components/ │
│ token / user │ │ Nav.jsx (读token) │
│ setAuth / logout │ │ RequireAuth.jsx │
└─────────────────────┘ │ (读token做守卫) │
│ └─────────────────────┘
│ localStorage 持久化
▼
localStorage: token / user
看懂这张图,后面的每一节都是给某个小方块"补细节"。
2.3 一个 token 的完整旅程(先剧透,后面逐帧拆解)
① 你在 /login 页输入 admin / 123456,点登录
↓
② Login.jsx 调 user.js 的 login() → config.js 的 axios.post('/login', ...)
↓
③ config.js 的 request 拦截器拦下请求(此时没 token,不带 Authorization)
↓
④ 请求发到 /api/login,被 vite-plugin-mock 拦下,交给 mock/user.js
↓
⑤ mock 校验账号密码,通过后用 jwt.sign 签出一个 token,返回 {code:0, user, token}
↓
⑥ config.js 的 response 拦截器把 res.data 返回,login() 拿到 {code:0, user, token}
↓
⑦ Login.jsx 调 setAuth({token, user}) → store/user.js 写 localStorage + 更新全局状态
↓
⑧ navigate 跳转回你原来想去的页面
↓
⑨ 之后你访问 /pay 或调 getRepo(),config.js 拦截器自动从 localStorage 读 token
↓
⑩ 请求头自动带上 Authorization: Bearer xxx → mock 用 jwt.verify 校验 → 通过!
有了这条主线,下面我们逐个文件拆开讲。
三、jsonwebtoken:sign 和 verify 两个动作
JWT 全称 JSON Web Token,本质就是把一个 JSON 身份对象,用加密算法 + 密钥转换成一个长字符串(token)。
这个库就两个核心动作,记住这两个词就够了:
| 动作 | 作用 | 类比 |
|---|---|---|
jwt.sign() | 把 JSON 对象"签发"成一个 token | 盖章:在身份卡上盖上防伪章 |
jwt.verify() | 把 token"校验"还原成 JSON 对象 | 验章:检查防伪章是不是真的 |
import jwt from 'jsonwebtoken';
const secret = 'secret819!$'; // 密钥,相当于"防伪章",只有你自己知道
// sign:签发 —— 把身份对象变成 token
const token = jwt.sign(
{ user: 'admin', role: 'admin' }, // ① 要装进 token 的 JSON 身份对象
secret, // ② 密钥(加盐)
{ expiresIn: 86400 } // ③ 过期时间,单位秒(86400 = 1 天)
);
// verify:校验 —— 把 token 还原成身份对象
const decoded = jwt.verify(token, secret); // { user: 'admin', role: 'admin', iat: ..., exp: ... }
逐行拆解:
jwt.sign(payload, secret, options):三个参数分别是要签发的数据、密钥、配置(比如过期时间)。expiresIn: 86400表示 token 1 天后过期,过期了 verify 就会抛错。jwt.verify(token, secret):用同一个密钥解,解出来的就是你当初塞进去的那个 JSON 对象。
一句话记住:sign 是"把身份锁进保险箱",verify 是"用钥匙开箱验货"。密钥(secret)就是那把钥匙,谁有钥匙谁就能签、能验。
这里有个关键点要理解:JWT 是"单向"的。你不能从 token 反向推导出密钥,但只要有密钥,任何一台服务器都能解这个 token。这正是它适合分布式的原因——签发的机器和解的机器不需要是同一台。
⚠️ 面试考点:为什么说 JWT 无状态、适合分布式? 因为身份信息就编码在 token 里,服务器不需要存任何东西。任何一台持有密钥的服务器都能
verify出同样的 JSON 对象,不存在"这台机器认识你、那台不认识"的问题。
四、用 mock 把后端"假"出来
4.1 为什么要 mock
讲 JWT 必然要有"后端"配合——谁来签发 token?谁来校验?但你很可能还没有后端(或者后端同学还没写好接口)。这时候 vite-plugin-mock 就派上用场了:在前端开发环境里,伪造一个假后端。
它的原理是:拦截浏览器的请求,如果 URL 匹配你定义的规则,就直接返回假数据,请求根本不会发到真实服务器。
4.2 先注册插件
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock', // mock 文件所在的目录
localEnabled: true // 本地开发环境启用
})],
})
mockPath: 'mock' 告诉插件"我的假接口写在 mock/ 目录下",localEnabled: true 表示开发时开启。
4.3 mock/user.js:假后端的两个接口
// mock/user.js
import jwt from 'jsonwebtoken';
const secret = 'secret819!$'
export default [
{
// 受保护接口:校验 token
url: '/api/repo',
method: 'get',
response: req => {
const token = req.headers['authorization'].split(' ')[1]; // ① 从请求头取 token
try {
let decoded = jwt.verify(token, secret); // ② 校验
return { code: 0, data: decoded.user }; // ③ 通过,返回身份
} catch (err) {
return { code: 401, msg: 'Invalid token' }; // ④ 失败,返回 401
}
}
},
{
// 登录接口:签发 token
url: '/api/login',
method: 'post',
response: (req) => {
const body = req.body;
if (body.username !== 'admin' || body.password !== '123456') {
return { code: -1, message: 'username or password 错误' };
}
// 服务器端给用户颁发 token
const token = jwt.sign(
{ user: body.username, role: 'admin' }, // 身份对象
secret, // 密钥
{ expiresIn: 86400 } // 1 天过期
);
return { code: 0, user: { username: body.username }, token };
}
}
]
逐行拆解(重点看两个接口的对称关系):
/api/login用sign:用户密码正确 → 把身份签成 token → 返回给前端。这是 token 的出生地。/api/repo用verify:从请求头里抠出 token → 校验 → 通过就返回身份。这是 token 的验票口。req.headers['authorization']里的值是"Bearer xxx"这种格式,所以.split(' ')[1]是去掉前缀Bearer拿到纯 token。
一句话记住:login 是"发手环",repo 是"验手环"——sign 发、verify 验,一个闭环。
这里先记住:前端 config.js 的 baseURL 是 /api,mock 里接口 URL 也是 /api/login、/api/repo,两边对上了,请求才会被 mock 拦到。这个"对上"很关键,后面还会提到。
五、axios 拦截器:token 的隐身携带者
这是整个项目里最关键、也最容易被忽略的一个文件。它的作用是:让你每次发请求,不用手动写 token,axios 偷偷帮你带上。
5.1 config.js:axios 实例 + 两个拦截器
// src/api/config.js
import axios from 'axios';
const instance = axios.create({
baseURL: '/api', // 所有请求自动拼上 /api 前缀
timeout: 5000 // 超时 5 秒
});
// ① request 拦截器:每个请求发出去之前,先被这里拦一下
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`; // 自动塞进请求头
}
return config; // 必须 return,否则请求发不出去
});
// ② response 拦截器:每个响应回来之后,先被这里拦一下
instance.interceptors.response.use(res => {
return res.data; // 直接把 data 层剥出来,省得每次 .data
});
export default instance;
逐行拆解:
axios.create({ baseURL: '/api' }):创建一个"定制版 axios"。之后写axios.get('/repo'),实际请求的是/api/repo。- request 拦截器:在请求发出去之前执行。它从
localStorage读 token,如果有就塞进config.headers['Authorization']。这就是 token 的隐身携带——你在业务代码里根本不用写。 - response 拦截器:在响应回来之后执行。它
return res.data,把 axios 包的那层data剥掉,业务代码拿到的直接就是后端返回的 JSON。
一句话记住:request 拦截器管"出发前带东西",response 拦截器管"到站后卸货"。
5.2 两个"使用 config 实例"的文件
有了 config.js 这个定制实例,业务接口就都来用它:
// src/api/repo.js —— 受保护的接口
import axios from './config'; // ✅ 引入的是 config.js 的实例,不是 axios 本尊
export const getRepo = async () => {
const res = await axios.get('/repo');
return res.data;
};
// src/api/user.js —— 登录接口
import axios from './config'; // ✅ 正确写法:从 ./config 引入实例
export const login = async (data) => {
const res = await axios.post('/login', data);
return res.data;
};
注意看这两行 import:它们 import 的都是 ./config,不是 'axios'。这是个特别容易踩的坑,下面专门说。
5.3 一个大坑:instance 未定义
我一开始 user.js 是这么写的:
// ❌ 错误:import 了 axios 本尊,却用了 instance 变量
import axios from 'axios';
export const login = async (data) => {
const res = await instance.post('/login', data); // instance 哪来的?undefined!
return res.data;
};
报错是 instance is not defined(或者运行时报错)。根因:这个文件里根本没有 instance 这个变量——它定义在 config.js 里,而且 config.js 导出的是 default 导出。
// ✅ 正确:从 ./config 引入定制实例,用它来发请求
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res.data;
};
一句话记住:
import axios from './config'拿到的才是"带拦截器"的定制实例;import axios from 'axios'拿到的只是裸 axios,啥都没带。
这个坑的根源,其实就是文件之间的联系没理清:config.js 是"工厂",repo.js/user.js 是"下单的客户",客户必须从工厂提货,而不是自己造一个。
六、zustand:登录状态的中央仓库
6.1 为什么登录状态要全局管
登录成功后,token 和用户信息要被很多地方用到:
Nav.jsx要看有没有 token,决定显示"登录"还是"登出"按钮RequireAuth.jsx要看有没有 token,决定放行还是拦截- 其它业务页面可能要看用户信息
如果靠 React 的 useState + 父子传参,token 得从最顶层一路 prop 传下去,传到 Nav、传到 RequireAuth……这就是传说中的 prop drilling(逐层传递地狱)。
zustand 就是来解决这个的:把登录状态集中到一个"中央仓库",任何组件直接去仓库拿,不用一层层传。
类比:不用 zustand 像"传话游戏"(一层传一层,传到后面都变了);用 zustand 像"公告栏"(谁要谁自己抬头看)。
6.2 store/user.js:仓库长这样
// src/store/user.js
import { create } from 'zustand';
export const useAuthStore = create(set => ({
// ① 初始状态:从 localStorage 读,刷新页面也不丢
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// ② action:登录后存状态
setAuth: ({ token, user }) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user }); // 更新仓库,通知所有订阅的组件重新渲染
},
// ③ action:登出清状态
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
set({ token: '', user: null });
}
}));
逐行拆解:
create(set => ({...})):zustand 的核心 API。create是个高阶函数,接收一个函数,返回一个 hook(useAuthStore)。- 状态(
token、user)和 action(setAuth、logout)写在一起,这是 zustand 的风格——一个仓库,状态和改状态的方法全在里面。 set({...})是改状态的方法,它会让所有用了useAuthStore的组件自动重新渲染。- 关键细节:
token的初始值从localStorage.getItem('token')读。这样刷新页面,登录状态还在(因为 token 持久化在 localStorage 里了)。
6.3 组件怎么"去仓库拿东西"
zustand 的用法是按需订阅,用选择器 state => state.xxx 精确拿:
// Nav.jsx 里,只拿自己需要的 token 和 logout
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);
// RequireAuth.jsx 里,只拿 token
const token = useAuthStore(state => state.token);
// Login.jsx 里,只拿 setAuth 这个 action
const setAuth = useAuthStore(state => state.setAuth);
一句话记住:
useAuthStore(state => state.xxx)这个选择器语法,就是"去仓库拿指定的那件货",只订阅自己需要的,避免无谓重渲染。
七、路由守卫 RequireAuth:把门的保安
现在 token 有了、也存进仓库了,但怎么拦住没登录的人访问受保护页面呢?答案是路由守卫。
7.1 什么是路由守卫
路由守卫 = 一个组件,包在受保护的页面上,进去之前先检查你有没有 token,没有就把你踢去登录页。
// src/components/RequireAuth.jsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token);
if (!token) {
return <Navigate to="/login" replace />; // 没登录 → 踢去登录页
}
return children; // 登录了 → 正常渲染子页面
}
export default RequireAuth;
逐行拆解:
useAuthStore(state => state.token):从仓库读 token。if (!token):没有 token,说明没登录,返回<Navigate to="/login" replace />。<Navigate>是 react-router 的声明式跳转组件,渲染它就会触发跳转。replace参数:跳转时替换当前历史记录,而不是新增一条。这样用户按"后退"键不会退回到被拦截的页面,体验更好。- 有 token 就
return children,把包裹的子页面正常渲染出来。
7.2 在路由表里怎么用
// src/App.jsx(节选)
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
}/>
</Routes>
看到没?/pay 这个受保护的页面,外面包了一层 <RequireAuth>。访问 /pay 时,React 先渲染 RequireAuth,它检查 token:
- 有 token → 渲染
<Pay />,放行 - 没 token → 渲染
<Navigate to="/login" />,滚去登录
一句话记住:路由守卫就是"把门保安",
children是你要保护的房间,Navigate是把你送回"登记处"(登录页)。
八、把完整流程串起来(重点!)
前面都是"零件",这一节把零件装成整机,带你走三遍完整流程。请配合第二章的图一起看。
8.1 流程一:登录(token 的诞生)
① 用户访问 /pay
→ App.jsx 渲染 <RequireAuth><Pay/></RequireAuth>
→ RequireAuth.jsx 读 token → 没有 → <Navigate to="/login" replace/>
→ 浏览器跳到登录页 Login.jsx
② 用户输入 admin / 123456,点登录
→ Login.jsx 的 handleLogin 触发 → 调 user.js 的 login(formData)
③ user.js 的 login() → axios.post('/login', data)
→ 这里的 axios 是 config.js 的实例
④ config.js 的 request 拦截器先执行:
→ 从 localStorage 读 token → 第一次没有 → 不带 Authorization
→ 请求继续,发往 /api/login(baseURL /api + /login)
⑤ vite-plugin-mock 拦下 /api/login → 交给 mock/user.js
→ 校验 username === 'admin' && password === '123456'
→ 通过 → jwt.sign({user, role}, secret, {expiresIn:86400}) 签出 token
→ 返回 { code: 0, user: {username:'admin'}, token }
⑥ config.js 的 response 拦截器执行 → return res.data
→ login() 拿到 { code:0, user, token } → return 出去
⑦ Login.jsx 里:res.code === 0
→ setAuth({ token: res.token, user: res.user })
→ store/user.js 里:写 localStorage + set() 更新全局状态
→ 此时 Nav.jsx 看到 token 有值,自动从"登录"按钮变成"登出"按钮
⑧ navigate(from, { replace: true }) → 跳回原来想去的 /pay
→ 这次 RequireAuth 再读 token → 有了 → 放行,渲染 <Pay/>
登录流程一句话总结: 表单 → 接口 → 拦截器(空手)→ mock 签发 → 拦截器(剥壳)→ setAuth 存储 → 跳转放行。
8.2 流程二:鉴权(token 的携带与校验)
登录成功后,token 存在 localStorage 里。之后再访问受保护的接口,就是自动携带 + 校验的过程:
① 用户访问受保护接口,比如 App.jsx 挂载时调 getRepo()
→ repo.js 的 getRepo() → axios.get('/repo')
② config.js 的 request 拦截器执行:
→ 这次 localStorage 里有 token 了!
→ config.headers['Authorization'] = 'Bearer ' + token
→ 请求头自动带上令牌,发往 /api/repo
③ vite-plugin-mock 拦下 /api/repo → 交给 mock/user.js
→ req.headers['authorization'].split(' ')[1] 抠出纯 token
→ jwt.verify(token, secret) 校验
→ 通过 → return { code:0, data: decoded.user }
④ response 拦截器 return res.data
→ getRepo() 拿到 { code:0, data: 'admin' }
鉴权流程一句话总结: 接口 → 拦截器(自动带头)→ mock 校验 → 通过返回。
8.3 流程三:登出(token 的销毁)
① 用户在 Nav.jsx 点 "Logout" 按钮
→ handleLogout → logout()(来自 store/user.js)
② store/user.js 的 logout():
→ localStorage.removeItem('token')
→ localStorage.removeItem('user')
→ set({ token:'', user:null })
③ token 变成 '',全局状态更新
→ Nav.jsx 看到 token 为空 → 按钮从"登出"变回"登录"
→ 此时再访问 /pay,RequireAuth 发现没 token → 踢回登录页
登出流程一句话总结: 点登出 → 清 localStorage + 清仓库 → 全局瞬间"失忆"。
8.4 一张表看透"每个文件在流程里的角色"
| 文件 | 角色 | 在流程里的关键时刻 |
|---|---|---|
Login.jsx | 发起登录 | 流程一 ②⑦⑧ |
api/user.js | 登录接口 | 流程一 ③ |
api/repo.js | 受保护接口 | 流程二 ① |
api/config.js | 拦截器(带 token / 剥 data) | 流程一 ④⑥、流程二 ②④ |
mock/user.js | 假后端(sign / verify) | 流程一 ⑤、流程二 ③ |
store/user.js | 中央仓库(存 / 清) | 流程一 ⑦、流程三 ② |
RequireAuth.jsx | 路由守卫(拦截 / 放行) | 流程一 ①⑧ |
Nav.jsx | 状态展示 + 登出入口 | 流程一 ⑦、流程三 ③ |
App.jsx | 路由表 + 装配 | 流程一 ① |
九、我踩过的坑
坑 1:instance is not defined
前面第五节讲过。user.js 里 import axios from 'axios' 却用了 instance,变量根本不存在。
// ❌ instance 从哪来?
import axios from 'axios';
const res = await instance.post('/login', data);
// ✅ 从 ./config 引入定制实例
import axios from './config';
const res = await axios.post('/login', data);
坑 2:jsonwebtoken 忘了装
mock/user.js 里 import jwt from 'jsonwebtoken',但这个包不在依赖里。一启动 vite 就 500,报 Failed to fetch dynamically imported module,非常迷惑——它表面看是 Login.jsx 加载失败,实际是 mock 文件解析不到 jsonwebtoken。
# 解决方案:装上它
pnpm add jsonwebtoken
教训:vite 报 500 时,别只看报错的那个文件,它很可能是被间接依赖的某个 import 解析失败拖累的。
坑 3:pnpm 忽略 esbuild 构建脚本
用 pnpm 装依赖时看到:
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28
pnpm 10+ 默认不执行依赖的 postinstall 脚本(安全考虑),但 esbuild 需要它来装原生二进制。忽略后 vite 可能启动失败。解决:
pnpm approve-builds esbuild
然后 pnpm install 重新装一遍。注意 pnpm 11 里这个配置写在 pnpm-workspace.yaml,不是 package.json 的 pnpm 字段。
坑 4:token 存 localStorage 的安全隐患
⚠️ 注意:把 token 放在 localStorage 是有 XSS 风险的(脚本能读到 localStorage)。更安全的做法是 httpOnly cookie。这个小 demo 为了方便直接用 localStorage,但面试被问到"token 放哪安全",要能答出区别。
| 方案 | 优点 | 缺点 |
|---|---|---|
| localStorage | 简单,前端好操作 | 易被 XSS 窃取 |
| httpOnly cookie | JS 读不到,防 XSS | 要防 CSRF |
十、面试高频 N 问
Q1:JWT 是什么?结构是什么?
JWT(JSON Web Token)是一种无状态的鉴权令牌,把用户身份信息编码进一个加密字符串里。结构是三段,用 . 分隔:Header.Payload.Signature。
- Header:声明算法(如 HS256)和类型(JWT)
- Payload:真正的身份数据(如
{username, role})+ 过期时间 - Signature:用密钥对前两段做的签名,用于防篡改
Q2:cookie/session 和 JWT 的区别?
- cookie/session:服务器存状态,sessionId 存在服务器内存/Redis,客户端 cookie 只存个 id。缺点:分布式下要共享 session。
- JWT:服务器不存状态,身份信息编码在 token 里,客户端自己保管。任何持有密钥的服务器都能 verify。缺点:token 一旦签发,在过期前无法主动作废(除非加黑名单)。
一句话:session 是"服务器记",JWT 是"token 自带"。
Q3:为什么说 JWT 适合分布式/微服务?
因为身份信息就在 token 里,服务器无需查库、无需共享 session。任何一台机器拿到同一个密钥就能 verify 出身份,天然无状态、可水平扩展。
Q4:axios 拦截器的作用?
- request 拦截器:请求发出前统一处理,最典型的就是自动给请求头加 token、统一加前缀等。
- response 拦截器:响应返回后统一处理,如统一剥 data、统一错误处理、401 跳登录等。
好处是把重复逻辑收口到一处,业务代码不用每个请求都写一遍。
Q5:路由守卫是怎么实现的?
用一个包装组件(如 RequireAuth)包裹受保护路由,组件内部读全局登录状态(如 zustand 的 token),没登录就 <Navigate to="/login" replace /> 重定向,登录了就渲染 children。本质是条件渲染 + 声明式跳转。
总结
核心概念速查表
| 概念 | 一句话 |
|---|---|
| JWT | 把身份"刻"进加密字符串的无状态令牌 |
| sign / verify | 签发 / 校验,一对"盖章/验章"动作 |
| 无状态 | 服务器不记你,每次请求都自报家门 |
| baseURL | axios 实例统一加的请求前缀 |
| request 拦截器 | 请求出发前自动塞 token |
| response 拦截器 | 响应到站后统一剥 data |
| zustand store | 全局状态的"公告栏",按需订阅 |
| 路由守卫 | 把门保安,没 token 踢去登录页 |
| localStorage 持久化 | 让刷新后登录状态不丢 |
一句口诀
登录签 token,仓库存 token,拦截器带 token,守卫查 token,登出清 token。
核心骨架(精简版)
// 1. 登录 → sign 签发 token(mock 后端)
const token = jwt.sign({ user, role }, secret, { expiresIn: 86400 });
// 2. 拦截器 → 自动携带 token
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
// 3. 仓库 → 存 / 清 token
const useAuthStore = create(set => ({
token: localStorage.getItem('token') || '',
setAuth: ({ token }) => { localStorage.setItem('token', token); set({ token }); },
logout: () => { localStorage.removeItem('token'); set({ token: '' }); }
}));
// 4. 守卫 → 查 token 放行 / 拦截
const token = useAuthStore(s => s.token);
if (!token) return <Navigate to="/login" replace />;
结尾
这篇文章没有把所有概念面面俱到地讲一遍,而是想帮你把 token 从出生到销毁的这条线捋顺——搞懂了文件之间的联系,剩下的细节就都是往骨架上填肉了。
完整代码可以直接照着上面的文件一个个建,跑通之后,你会对"前端登录鉴权"这件事有全新的整体感。
希望这篇文章对你有帮助!有问题欢迎在评论区交流,如果觉得有用,求个点赞收藏 🔥