一文讲透 JWT 登录鉴权:token 的「颁发 → 存储 → 携带 → 校验」完整闭环

登录之后,那个长长的 token 到底是怎么"活"起来的?这篇文章不背概念,带着你跟着 token 从出生到校验,把每一个文件、每一次跳转、每一次自动加在请求头里的 Authorization 都走一遍。

前言

最近在做一个小项目,卡在"登录之后怎么让后端认识我"这个问题上折腾了很久。市面上讲 JWT 的文章很多,但大多数是各讲各的 :这篇讲 jsonwebtoken 怎么 sign,那篇讲 axios 拦截器怎么写,还有讲 zustand 的、讲路由守卫的......单看都懂,合在一起就不知道这些文件是怎么串起来的了。

所以这篇文章我想换一种讲法:以 token 的一生为主线,把 React + react-router-dom + zustand + axios + vite-plugin-mock + jsonwebtoken 这堆东西,用"文件之间的联系"串成一条完整的线。

你将会收获:

  • 🎯 搞清楚 JWT 到底解决了什么问题,和 cookie/session 有啥区别
  • 🔑 理解 jsonwebtokensign(签发)和 verify(校验)两个动作的本质
  • 🚀 用 vite-plugin-mock 在前端就把"后端服务器"假出来,不用等真后端
  • 🕵️ 看懂 axios 拦截器怎么"隐身"地给每个请求自动加上 token
  • 🏪 理解为什么登录状态要用 zustand 全局管,而不是在组件里传
  • 🛡️ 学会写路由守卫,没登录就滚去登录页
  • 📌 走一遍完整的登录 → 鉴权 → 登出流程,知道每个文件在哪个环节出场

技术栈:React 19 + react-router-dom 7 + zustand 5 + axios + vite-plugin-mock + jsonwebtoken


目录

  • [一、先搞懂:HTTP 是无状态的,那"你是谁"怎么办](#一、先搞懂:HTTP 是无状态的,那"你是谁"怎么办 "#%E4%B8%80")
  • [二、全景图:token 的一生和它走过的文件](#二、全景图:token 的一生和它走过的文件 "#%E4%BA%8C")
  • [三、jsonwebtoken:sign 和 verify 两个动作](#三、jsonwebtoken:sign 和 verify 两个动作 "#%E4%B8%89")
  • [四、用 mock 把后端"假"出来](#四、用 mock 把后端"假"出来 "#%E5%9B%9B")
  • [五、axios 拦截器:token 的隐身携带者](#五、axios 拦截器:token 的隐身携带者 "#%E4%BA%94")
  • 六、zustand:登录状态的中央仓库
  • [七、路由守卫 RequireAuth:把门的保安](#七、路由守卫 RequireAuth:把门的保安 "#%E4%B8%83")
  • 八、把完整流程串起来
  • 九、我踩过的坑
  • [十、面试高频 N 问](#十、面试高频 N 问 "#%E5%8D%81")
  • 总结

一、先搞懂:HTTP 是无状态的,那"你是谁"怎么办

1.1 痛点:服务器是个"脸盲"

HTTP 协议有个特性叫 无状态(Stateless)。什么意思呢?

打个比方:你去一家会员制餐厅,服务员不记人脸。你点一次菜,他给你上;你下次再来点菜,他完全不记得你是谁、上次充了多少钱、是不是会员。你每次都得重新自我介绍一遍。

Web 也是一样。浏览器发一个请求,服务器处理完就把这事忘了。你刚登录过,再发第二个请求,服务器还是不认识你。那问题就来了:

我已经登录了,凭什么每次请求都要重新证明"我是我"?

这就是**鉴权(Authentication)**要解决的问题:让服务器在每一次"无状态"的请求里,认出你是有身份的人。

1.2 最经典的方案:cookie + session

老办法是这样的:

markdown 复制代码
┌────────┐  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 的核心流程就四步:

markdown 复制代码
1. 登录   →  服务器校验账号密码,用 jwt.sign 把 {username, role} 签成一个 token 发给你
2. 存储   →  前端把 token 存起来(localStorage)
3. 携带   →  之后每个请求,拦截器自动把 token 塞进请求头 Authorization: Bearer xxx
4. 校验   →  服务器用 jwt.verify 解开 token,还原出 JSON 身份对象

这四步,就是贯穿全文的主线。下面我们看看它们分别发生在哪个文件里。


二、全景图:token 的一生和它走过的文件

在讲代码之前,先给你一张"地图"。这个项目不算大,但文件之间的依赖关系特别值得看清楚------很多人卡住,就是因为不知道"这个变量从哪来、这个函数去哪了"。

2.1 目录结构(每个文件一句职责)

lua 复制代码
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 文件依赖关系图

这张图是全文最重要的一张,先印在脑子里:

bash 复制代码
                    ┌────────────────────────────────────────┐
                    │             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 的完整旅程(先剧透,后面逐帧拆解)

scss 复制代码
① 你在 /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 对象 验章:检查防伪章是不是真的
js 复制代码
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 先注册插件

js 复制代码
// 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:假后端的两个接口

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/loginsign :用户密码正确 → 把身份签成 token → 返回给前端。这是 token 的出生地
  • /api/repoverify :从请求头里抠出 token → 校验 → 通过就返回身份。这是 token 的验票口
  • req.headers['authorization'] 里的值是 "Bearer xxx" 这种格式,所以 .split(' ')[1] 是去掉前缀 Bearer 拿到纯 token。

一句话记住:login 是"发手环",repo 是"验手环"------sign 发、verify 验,一个闭环。

这里先记住:前端 config.jsbaseURL/api,mock 里接口 URL 也是 /api/login/api/repo,两边对上了,请求才会被 mock 拦到。这个"对上"很关键,后面还会提到。


五、axios 拦截器:token 的隐身携带者

这是整个项目里最关键、也最容易被忽略 的一个文件。它的作用是:让你每次发请求,不用手动写 token,axios 偷偷帮你带上。

5.1 config.js:axios 实例 + 两个拦截器

js 复制代码
// 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 这个定制实例,业务接口就都来用它:

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;
};
js 复制代码
// 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 是这么写的:

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 导出。

js 复制代码
// ✅ 正确:从 ./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:仓库长这样

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 是个高阶函数,接收一个函数,返回一个 hookuseAuthStore)。
  • 状态tokenuser)和 actionsetAuthlogout)写在一起,这是 zustand 的风格------一个仓库,状态和改状态的方法全在里面
  • set({...}) 是改状态的方法,它会让所有用了 useAuthStore 的组件自动重新渲染。
  • 关键细节token 的初始值从 localStorage.getItem('token') 读。这样刷新页面,登录状态还在(因为 token 持久化在 localStorage 里了)。

6.3 组件怎么"去仓库拿东西"

zustand 的用法是按需订阅 ,用选择器 state => state.xxx 精确拿:

jsx 复制代码
// Nav.jsx 里,只拿自己需要的 token 和 logout
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);
jsx 复制代码
// RequireAuth.jsx 里,只拿 token
const token = useAuthStore(state => state.token);
jsx 复制代码
// Login.jsx 里,只拿 setAuth 这个 action
const setAuth = useAuthStore(state => state.setAuth);

一句话记住:useAuthStore(state => state.xxx) 这个选择器语法,就是"去仓库拿指定的那件货",只订阅自己需要的,避免无谓重渲染。


七、路由守卫 RequireAuth:把门的保安

现在 token 有了、也存进仓库了,但怎么拦住没登录的人访问受保护页面呢?答案是路由守卫。

7.1 什么是路由守卫

路由守卫 = 一个组件,包在受保护的页面上,进去之前先检查你有没有 token,没有就把你踢去登录页。

jsx 复制代码
// 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 在路由表里怎么用

jsx 复制代码
// 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 的诞生)

bash 复制代码
① 用户访问 /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 里。之后再访问受保护的接口,就是自动携带 + 校验的过程:

scss 复制代码
① 用户访问受保护接口,比如 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 的销毁)

bash 复制代码
① 用户在 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.jsimport axios from 'axios' 却用了 instance,变量根本不存在。

js 复制代码
// ❌ 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.jsimport jwt from 'jsonwebtoken',但这个包不在依赖里 。一启动 vite 就 500,报 Failed to fetch dynamically imported module,非常迷惑------它表面看是 Login.jsx 加载失败,实际是 mock 文件解析不到 jsonwebtoken

bash 复制代码
# 解决方案:装上它
pnpm add jsonwebtoken

教训 :vite 报 500 时,别只看报错的那个文件,它很可能是被间接依赖的某个 import 解析失败拖累的。

坑 3:pnpm 忽略 esbuild 构建脚本

用 pnpm 装依赖时看到:

csharp 复制代码
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28

pnpm 10+ 默认不执行依赖的 postinstall 脚本(安全考虑),但 esbuild 需要它来装原生二进制。忽略后 vite 可能启动失败。解决:

bash 复制代码
pnpm approve-builds esbuild

然后 pnpm install 重新装一遍。注意 pnpm 11 里这个配置写在 pnpm-workspace.yaml,不是 package.jsonpnpm 字段。

坑 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。

核心骨架(精简版)

js 复制代码
// 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 从出生到销毁的这条线捋顺------搞懂了文件之间的联系,剩下的细节就都是往骨架上填肉了。

完整代码可以直接照着上面的文件一个个建,跑通之后,你会对"前端登录鉴权"这件事有全新的整体感。

希望这篇文章对你有帮助!有问题欢迎在评论区交流,如果觉得有用,求个点赞收藏 🔥

相关推荐
LayZhangStrive2 小时前
融360 一面
java·面试·后端开发
城管不管2 小时前
重生——第十次面试之开源中国一面挂
java·linux·开发语言·算法·面试·职场和发展·开源
程序猿阿森4 小时前
Python 闭包与装饰器:从入门到精通
开发语言·python·面试
做前端的娜娜子4 小时前
async/await 错误处理:try...catch vs .catch() 完全指南
前端·面试·掘金·金石计划
Scabbards_4 小时前
面试Leetcode - 算法合集
算法·leetcode·面试
程序员爱钓鱼7 小时前
Rust Trait Object详解:dyn Trait与动态分发
后端·面试·rust
用户479492835691516 小时前
字节Agent开发技术分享—旧会话越聊越笨,新会话又得重讲?我给 Matt Pocock Skill 炼了套《影分身之术》:本体想清楚,分身写代码,完事回来汇报
面试·agent
蒸蒸yyyyzwd19 小时前
cpp 选手准备秋招学习笔记 day10
笔记·面试·求职招聘
蔓越莓20 小时前
Webpack 常用 Loader +手写Loader
前端·面试