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

23 阅读8分钟

登录之后,那个长长的 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 是无状态的,那"你是谁"怎么办

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 + sessionJWT
服务器存不存状态存(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.jslogin()  →  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.jsxsetAuth({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/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 实例 + 两个拦截器

// 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 是个高阶函数,接收一个函数,返回一个 hookuseAuthStore)。
  • 状态tokenuser)和 actionsetAuthlogout)写在一起,这是 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.jsgetRepo() → 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.datagetRepo() 拿到 { 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.jsimport 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.jsimport 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.jsonpnpm 字段。

坑 4:token 存 localStorage 的安全隐患

⚠️ 注意:把 token 放在 localStorage 是有 XSS 风险的(脚本能读到 localStorage)。更安全的做法是 httpOnly cookie。这个小 demo 为了方便直接用 localStorage,但面试被问到"token 放哪安全",要能答出区别。

方案优点缺点
localStorage简单,前端好操作易被 XSS 窃取
httpOnly cookieJS 读不到,防 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签发 / 校验,一对"盖章/验章"动作
无状态服务器不记你,每次请求都自报家门
baseURLaxios 实例统一加的请求前缀
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 从出生到销毁的这条线捋顺——搞懂了文件之间的联系,剩下的细节就都是往骨架上填肉了。

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

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