JWT 登录鉴权全链路:从 Zustand 状态管理到 Axios 拦截器,彻底搞懂前端鉴权工程

JWT 登录鉴权全链路:从 Zustand 状态管理到 Axios 拦截器,彻底搞懂前端鉴权工程

HTTP 是无状态的------服务器不知道你是谁。每次请求都是"陌生人"。JWT(JSON Web Token) 就是解决这个问题的工业标准:登录时签发 token,后续请求自动携带 token,服务器解码验证身份。本文从零搭建一个完整的 JWT 鉴权全栈项目------Zustand 管理全局登录状态、Axios 拦截器自动注入 token、React Router 路由守卫保护受保护页面、Vite Mock 模拟后端签发与验证 token。16 个文件,一条链路打通前端鉴权工程。建议收藏后动手实践。


一、为什么需要登录鉴权?

1.1 HTTP 的无状态问题

bash 复制代码
HTTP 是无状态的(Stateless):

  请求 1:POST /login  { username: 'admin', password: '123456' }
  → 服务器验证通过,返回登录成功

  请求 2:GET /pay
  → 服务器:你是谁?我不认识你
  → HTTP 协议设计如此:每个请求都是独立的
  → 请求 1 和请求 2 之间没有关联

  问题:用户登录后,后续请求怎么证明"我就是刚才登录的那个人"?

1.2 登录鉴权的演进

css 复制代码
┌──────────────────────────────────────────────────────────┐
│                  登录鉴权方案演进                          │
│                                                          │
│  ① Cookie + Session(传统方案)                           │
│  → 服务器创建 Session,返回 SessionID(写在 Cookie 中)   │
│  → 浏览器每次请求自动带上 Cookie                          │
│  → 服务器根据 SessionID 找到 Session 对象                │
│  → 问题:Session 存在服务器内存,不适合分布式             │
│                                                          │
│  ② JWT(现代方案)                                       │
│  → 服务器签发 token(包含用户信息的加密字符串)           │
│  → 客户端存储 token(localStorage)                      │
│  → 每次请求在 Header 中带上 token                        │
│  → 服务器解码 token,验证身份                             │
│  → 优势:无状态、分布式友好、跨域支持好                   │
└──────────────────────────────────────────────────────────┘

1.3 JWT 的核心概念

javascript 复制代码
JWT = JSON Web Token

  ┌──────────────────────────────────────────────────┐
  │  JWT 的三个部分                                  │
  │                                                  │
  │  Header.Payload.Signature                        │
  │  │       │       │                              │
  │  │       │       └── 签名:用密钥加密的验证码     │
  │  │       └── 载荷:用户身份信息(JSON 对象)      │
  │  └── 头部:算法类型                              │
  │                                                  │
  │  两个核心动作:                                   │
  │  ├── sign:签名,把 JSON 对象 → 加密 → token      │
  │  └── verify:验证,把 token → 解密 → JSON 对象    │
  └──────────────────────────────────────────────────┘
css 复制代码
JWT 的完整流程:

  ① 用户登录
     POST /login { username: 'admin', password: '123456' }

  ② 服务器验证通过,签发 token
     jwt.sign({ user: 'admin', role: 'admin' }, secret, { expiresIn: 86400 })
     → 生成 token 字符串

  ③ 服务器返回 token
     { code: 0, user: { username: 'admin' }, token: 'eyJhbG...' }

  ④ 客户端存储 token
     localStorage.setItem('token', token)

  ⑤ 后续请求自动携带 token
     GET /repo
     Headers: { Authorization: 'Bearer eyJhbG...' }

  ⑥ 服务器验证 token
     jwt.verify(token, secret)
     → 解码出 { user: 'admin', role: 'admin' }
     → 身份确认,返回数据

  ⑦ token 过期
     → verify 抛异常
     → 返回 401,要求重新登录

二、项目架构全景

2.1 技术栈

arduino 复制代码
React 全家桶 + 鉴权工程:
  ├── React              → UI 框架
  ├── React Router DOM   → 路由管理(BrowserRouter, lazy, Suspense)
  ├── Zustand            → 轻量级状态管理(替代 Redux)
  ├── Axios              → HTTP 客户端(拦截器自动注入 token)
  ├── Vite Mock Server   → 模拟后端 API(JWT 签发与验证)
  └── CSS Modules        → 样式隔离

2.2 项目结构

ruby 复制代码
src/
├── App.jsx                  # 应用入口,路由配置,路由守卫
├── main.jsx                 # React 挂载入口
├── pages/
│   ├── Home.jsx            # 首页(公开页面)
│   ├── Login.jsx           # 登录页(表单校验 + 登录逻辑)
│   ├── Pay.jsx             # 支付页(受保护页面)
│   └── Login.module.css    # 登录页样式(CSS Module)
├── components/
│   ├── Nav.jsx             # 导航栏(条件渲染登录/登出)
│   └── RequireAuth.jsx     # 路由守卫(无 token 跳转登录)
├── api/
│   ├── config.js           # Axios 实例 + 拦截器
│   ├── user.js             # 登录接口
│   └── repo.js             # 受保护接口
├── store/
│   ├── user.js             # Zustand 鉴权 Store(token + user)
│   └── todos.js            # Zustand Todos Store(子仓库)
└── mock/
    └── user.js             # Vite Mock 后端(JWT 签发 + 验证)

2.3 架构全景图

bash 复制代码
┌──────────────────────────────────────────────────────────────────┐
│                    JWT 鉴权全链路架构                             │
│                                                                  │
│  浏览器                                                           │
│    │                                                             │
│    │  ① 访问 /pay(受保护页面)                                   │
│    ▼                                                             │
│  React Router                                                    │
│    │                                                             │
│    │  ② RequireAuth 检查 token                                  │
│    │  → 无 token:Navigate to /login                             │
│    │  → 有 token:渲染 Pay 组件                                  │
│    ▼                                                             │
│  组件(Pay / Login / Home)                                      │
│    │                                                             │
│    │  ③ 调用 API                                                 │
│    │  await login(formData)                                     │
│    │  await getRepo()                                            │
│    ▼                                                             │
│  api/user.js / api/repo.js                                       │
│    │                                                             │
│    │  ④ 发起 Axios 请求                                          │
│    ▼                                                             │
│  api/config.js(Axios 实例 + 拦截器)                            │
│    │                                                             │
│    │  ⑤ 请求拦截器:自动注入 token                                │
│    │  config.headers['Authorization'] = `Bearer ${token}`      │
│    │                                                             │
│    │  ⑥ 响应拦截器:提取 res.data                                 │
│    │  return res.data                                            │
│    ▼                                                             │
│  Vite Mock Server(模拟后端)                                    │
│    │                                                             │
│    │  ⑦ /api/login:验证账密 → jwt.sign → 返回 token             │
│    │  ⑦ /api/repo:jwt.verify → 返回用户数据或 401               │
│    ▼                                                             │
│  Zustand Store(全局状态)                                       │
│    │                                                             │
│    │  ⑧ setAuth({ token, user })                                │
│    │  → 存入 localStorage                                        │
│    │  → 更新 store 状态                                          │
│    │  → 组件自动重新渲染                                         │
│    ▼                                                             │
│  Nav 组件响应状态变化                                             │
│    → 显示 Logout 按钮                                            │
│    → 显示 Hello, admin!                                           │
└──────────────────────────────────────────────────────────────────┘

三、Zustand:轻量级全局状态管理

3.1 为什么用 Zustand?

bash 复制代码
React 状态管理方案对比:

  Props 传递               → 简单但层数多时痛苦(Props Drilling)
  Context + useContext     → 跨层通信,但 Provider 嵌套地狱
  Redux                   → 功能强大但样板代码太多
  Zustand                  → 轻量、无 Provider、hooks 风格

  Zustand 优势:
  ├── 不需要 Provider 包裹
  ├── 不需要 reducer/action/type
  ├── 直接在组件中使用
  ├── 跨路由全局共享
  └── API 极简,create(set => ({ ... }))

3.2 鉴权 Store:store/user.js

javascript 复制代码
import { create } from 'zustand';

export const useAuthStore = create(set => ({
  // ======== 状态 ========
  token: localStorage.getItem('token') || '',
  user: JSON.parse(localStorage.getItem('user')) || null,

  // ======== 动作(Actions) ========
  setAuth: ({ token, user }) => {
    localStorage.setItem('token', token);
    localStorage.setItem('user', JSON.stringify(user));
    set({ token, user });
  },

  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    set({ token: '', user: null });
  },
}));

逐段解析

javascript 复制代码
create(set => ({ ... }))
// create 是高阶函数:
// → 接收一个函数,函数参数是 set(修改状态的方法)
// → 返回一个自定义 Hook:useAuthStore
// → 组件调用 useAuthStore 获取状态

// 状态初始化时从 localStorage 读取
token: localStorage.getItem('token') || '',
// → 页面刷新后 state 不会丢失
// → localStorage 持久化存储
// → token 为 null 时默认为空字符串

user: JSON.parse(localStorage.getItem('user')) || null,
// → localStorage 存的是 JSON 字符串
// → JSON.parse 还原为对象
// → 没有存储时返回 null
bash 复制代码
Zustand Store 的设计理念:

  React App = UI Component + Store

  ┌──────────────────────────────────────────────┐
  │  Store(状态仓库)                            │
  │  ├── state:token, user                      │
  │  └── actions:setAuth, logout               │
  │                                              │
  │  职责:                                      │
  │  ├── 管理全局共享的登录状态                   │
  │  ├── 持久化到 localStorage                   │
  │  └── 提供修改状态的方法                       │
  └──────────────────────────────────────────────┘
            │
            │ 组件通过 useAuthStore 获取
            ▼
  ┌──────────────────────────────────────────────┐
  │  Components                                  │
  │  ├── Nav:读取 token/user,条件渲染           │
  │  ├── RequireAuth:检查 token,守卫路由        │
  │  └── Login:调用 setAuth,更新登录状态        │
  └──────────────────────────────────────────────┘

setAuth 做了三件事

javascript 复制代码
setAuth: ({ token, user }) => {
  localStorage.setItem('token', token);         // ① 持久化 token
  localStorage.setItem('user', JSON.stringify(user)); // ② 持久化 user
  set({ token, user });                          // ③ 更新 store 状态
}
// 三步缺一不可:
// localStorage → 页面刷新后状态不丢
// set → 触发所有使用该状态的组件重新渲染

logout 做了三件事

javascript 复制代码
logout: () => {
  localStorage.removeItem('token');  // ① 清除持久化
  localStorage.removeItem('user');   // ② 清除持久化
  set({ token: '', user: null });    // ③ 重置状态
}
// 登出后所有依赖 token 的组件自动更新
// Nav 隐藏 Logout 按钮,显示 Login 链接
// RequireAuth 失去 token,路由被拦截

3.3 子仓库拆分:store/todos.js

javascript 复制代码
import { create } from 'zustand';

export const useTodosStore = create(set => ({
  todos: [],
  setTodos: ({ todos }) => set({ todos }),
}));
bash 复制代码
大型项目的 Store 拆分:

  小型项目:
  ├── 一个 store 管理所有状态
  └── 简单够用

  中大型项目:
  ├── store/user.js    → 鉴权子仓库
  ├── store/todos.js   → Todos 子仓库
  ├── store/products.js → 产品子仓库
  └── 按业务领域拆分,各自管理

  Zustand 天然支持拆分:
  → 每个 create() 返回独立的 Hook
  → 组件按需引入,互不干扰
  → 不需要像 Redux 那样 combineReducers

四、Axios 拦截器:自动注入 Token

4.1 api/config.js

javascript 复制代码
import axios from 'axios';

const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
});

// 请求拦截器:每个请求自动带上 token
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:自动提取 res.data
instance.interceptors.response.use(res => {
  return res.data;
});

export default instance;

4.2 axios.create:创建实例

javascript 复制代码
const instance = axios.create({
  baseURL: '/api',
  timeout: 5000,
});
csharp 复制代码
axios.create vs 直接用 axios:

  直接用 axios:
  axios.get('http://localhost:3000/api/login')
  → 每次都要写完整 URL
  → 没有统一配置

  axios.create 创建实例:
  const instance = axios.create({ baseURL: '/api' })
  instance.get('/login')
  → 自动拼接为 /api/login
  → baseURL 统一配置
  → timeout 统一设置
  → 拦截器统一挂载
  → 一个项目可以有多个实例(不同后端服务)

4.3 请求拦截器

javascript 复制代码
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
});
arduino 复制代码
请求拦截器的执行流程:

  组件调用 axios.get('/repo')
    │
    ▼
  请求拦截器拦截
    │
    │  config = {
    │    url: '/repo',
    │    method: 'get',
    │    headers: {}
    │  }
    │
    │  从 localStorage 读取 token
    │  config.headers['Authorization'] = 'Bearer eyJhbG...'
    │
    │  return config  ← 必须返回,否则请求发不出去
    │
    ▼
  真正发起 HTTP 请求
    GET /api/repo
    Headers: { Authorization: 'Bearer eyJhbG...' }

  价值:
  ├── 不用在每个 API 函数里手动加 token
  ├── 统一管理,改一处全局生效
  ├── 自动从 localStorage 读取,不依赖组件状态
  └── token 不存在时不加 Header(登录请求不需要 token)
bash 复制代码
Bearer Token 格式:

  Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

  → Bearer 是固定的前缀关键字
  → 后面跟一个空格
  → 然后是 token 字符串
  → 服务器按空格分割:split(' ')[1] 取到 token

  Bearer 的含义:"持有者"
  → 谁持有这个 token,谁就是合法用户
  → token 本身就是身份凭证

4.4 响应拦截器

javascript 复制代码
instance.interceptors.response.use(res => {
  return res.data;
});
css 复制代码
响应拦截器的执行流程:

  服务器返回响应
    │
    │  res = {
    │    data: { code: 0, user: {...}, token: '...' },
    │    status: 200,
    │    headers: { ... },
    │    config: { ... }
    │  }
    │
    ▼
  响应拦截器拦截
    │
    │  return res.data
    │  → 只返回业务数据
    │  → 剥掉 HTTP 元信息(status, headers, config)
    │
    ▼
  组件拿到的就是:
  { code: 0, user: {...}, token: '...' }

  价值:
  ├── 组件代码更简洁,不用 res.data.data
  ├── 统一处理响应格式
  └── 可以在这里做全局错误处理(401 跳登录等)

4.5 API 函数

javascript 复制代码
// api/user.js ------ 登录接口
import axios from './config';

export const login = async (data) => {
  const res = await axios.post('/login', data);
  return res;
};
javascript 复制代码
// api/repo.js ------ 受保护接口
import axios from './config';

export const getRepo = async () => {
  const res = await axios.get('/repo');
  return res;
};
scss 复制代码
API 层的设计原则:

  config.js(Axios 实例 + 拦截器)
    │
    ├── user.js(用户相关接口)
    │   └── login(data) → POST /api/login
    │
    └── repo.js(仓库相关接口)
        └── getRepo() → GET /api/repo

  每个接口函数:
  ├── 只管调用 axios 实例
  ├── 不关心 token 怎么来的(拦截器自动处理)
  ├── 不关心响应怎么提取的(拦截器自动处理)
  └── 只负责定义 URL 和 HTTP 方法

五、Vite Mock Server:模拟后端 JWT

5.1 vite.config.js

javascript 复制代码
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,         // 开发环境启用
    }),
  ],
});
bash 复制代码
Vite Mock 的工作原理:

  浏览器请求 /api/login
    │
    ▼
  Vite Dev Server 拦截
    │
    │  匹配 mock/user.js 中定义的规则
    │  { url: '/api/login', method: 'post', response: ... }
    │
    │  执行 response 函数
    │  返回模拟数据
    │
    ▼
  Axios 收到响应
    → 响应拦截器提取 res.data
    → 组件拿到 { code, user, token }

  价值:
  ├── 前端独立开发,不依赖后端
  ├── baseURL: '/api' → Mock 拦截 /api/* 请求
  ├── 可以模拟 JWT 签发和验证
  └── 切换到真实后端只需改 baseURL

5.2 Mock 后端:mock/user.js

javascript 复制代码
import jwt from 'jsonwebtoken';

const secret = 'secret819!$';

export default [
  // ======== 受保护接口:验证 token ========
  {
    url: '/api/repo',
    method: 'get',
    response: req => {
      // 提取 Bearer Token
      const token = req.headers['authorization'].split(' ')[1];
      try {
        const decoded = jwt.verify(token, secret);
        return { code: 0, data: decoded.user };
      } catch (err) {
        return { code: 401, msg: 'Invalid token' };
      }
    },
  },

  // ======== 登录接口:签发 token ========
  {
    url: '/api/login',
    method: 'post',
    timeout: 2000,
    response: (req, res) => {
      const body = req.body;
      if (body.username !== 'admin' || body.password !== '123456') {
        return { code: -1, message: 'username or password 错误' };
      }

      // 签发 JWT
      const token = jwt.sign(
        { user: body.username, role: 'admin' },
        secret,
        { expiresIn: 86400 }
      );

      return {
        code: 0,
        user: { username: body.username },
        token: token,
      };
    },
  },
];

5.3 登录接口:jwt.sign

javascript 复制代码
const token = jwt.sign(
  { user: body.username, role: 'admin' },  // 载荷:用户身份信息
  secret,                                     // 密钥:加盐加密
  { expiresIn: 86400 }                       // 选项:过期时间 24 小时
);
javascript 复制代码
jwt.sign 的三个参数:

  ┌──────────────────────────────────────────────────┐
  │  payload(载荷)                                  │
  │  { user: 'admin', role: 'admin' }               │
  │  → JSON 对象,包含用户身份信息                     │
  │  → 可以放任何非敏感信息                            │
  │  → 不要放密码!token 可以被解码                    │
  │                                                  │
  │  secret(密钥)                                   │
  │  'secret819!$'                                  │
  │  → 服务器私有的加密密钥                            │
  │  → 只有知道 secret 的服务器才能签发和验证          │
  │  → 相当于"加盐",防止 token 被伪造                 │
  │                                                  │
  │  options(选项)                                  │
  │  { expiresIn: 86400 }                           │
  │  → 86400 秒 = 24 小时                            │
  │  → 过期后 token 失效,需要重新登录                 │
  │  → 防止 token 被盗后永久有效                      │
  └──────────────────────────────────────────────────┘

5.4 受保护接口:jwt.verify

javascript 复制代码
const token = req.headers['authorization'].split(' ')[1];
try {
  const decoded = jwt.verify(token, secret);
  return { code: 0, data: decoded.user };
} catch (err) {
  return { code: 401, msg: 'Invalid token' };
}
css 复制代码
jwt.verify 的流程:

  ① 提取 token
     req.headers['authorization'] → 'Bearer eyJhbG...'
     .split(' ')[1] → 'eyJhbG...'
     → 按空格分割,取第二部分

  ② 验证 token
     jwt.verify(token, secret)
     → 用相同的 secret 解密
     → 验证签名是否正确
     → 检查是否过期
     → 验证通过:返回 decoded = { user: 'admin', role: 'admin' }
     → 验证失败:抛出异常 TokenExpiredError / JsonWebTokenError

  ③ 根据结果返回
     成功 → { code: 0, data: decoded.user }
     失败 → { code: 401, msg: 'Invalid token' }

  JWT 的分布式优势:
  → 任何一台服务器只要有 secret 就能验证 token
  → 不需要共享 Session
  → 天然适合微服务、分布式部署

六、路由守卫:RequireAuth 组件

6.1 components/RequireAuth.jsx

jsx 复制代码
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/user';

function RequireAuth({ children }) {
  const token = useAuthStore(state => state.token);
  const location = useLocation();

  if (!token) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }

  return children;
}

export default RequireAuth;

6.2 路由守卫的工作原理

ini 复制代码
┌──────────────────────────────────────────────────────────┐
│                  路由守卫工作流程                          │
│                                                          │
│  用户访问 /pay                                            │
│    │                                                     │
│    ▼                                                     │
│  React Router 匹配路由                                    │
│    <Route path="/pay" element={                          │
│      <RequireAuth>                                       │
│        <Pay />                                           │
│      </RequireAuth>                                      │
│    } />                                                  │
│    │                                                     │
│    ▼                                                     │
│  RequireAuth 组件渲染                                     │
│    │                                                     │
│    │  ① 从 store 读取 token                              │
│    │  const token = useAuthStore(state => state.token)   │
│    │                                                     │
│    │  ② 判断 token 是否存在                               │
│    │                                                     │
│    ├── 有 token → return children → 渲染 Pay 组件         │
│    │                                                     │
│    └── 无 token → return <Navigate to="/login" />        │
│                   → 自动跳转到登录页                      │
│                   → state={{ from: location }}            │
│                   → 记住用户想去的页面                     │
│                                                          │
│  children 模式:                                          │
│  → RequireAuth 包裹在路由外部                             │
│  → children 就是 Pay 组件                                 │
│  → 有权限才渲染 children                                   │
│  → 无权限跳转登录                                          │
└──────────────────────────────────────────────────────────┘

6.3 登录后跳回原页面

jsx 复制代码
// RequireAuth 跳转时记录来源页面
<Navigate to="/login" replace state={{ from: location }} />
//                                 └── 记住用户想访问的路径

// Login 页面登录成功后跳回原页面
const from = location.state?.from || '/';
navigate(from, { replace: true });
// → 从 state 中取出 from
// → 如果有 from,跳回原页面
// → 如果没有(直接访问登录页),跳到首页
bash 复制代码
场景演示:

  ① 用户访问 /pay(未登录)
  → RequireAuth 无 token → 跳转 /login
  → state = { from: { pathname: '/pay' } }

  ② 用户在登录页输入账密,登录成功
  → navigate(from) → 跳回 /pay
  → 用户无感知,体验丝滑

  ③ 用户直接访问 /login(已登录或未登录)
  → state 为空
  → from = '/'
  → 登录成功后跳首页

七、App.jsx:路由配置与集成

7.1 完整代码

jsx 复制代码
import React, { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import RequireAuth from './components/RequireAuth';
import Nav from './components/Nav';
import { getRepo } from './api/repo';

const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));

function App() {
  useEffect(() => {
    (async () => {
      const res = await getRepo();
      console.log(res);
    })();
  }, []);

  return (
    <Router>
      <Nav />
      <Suspense fallback={<div>loading...</div>}>
        <Routes>
          {/* 公开页面 */}
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />

          {/* 受保护页面:用 RequireAuth 包裹 */}
          <Route path="/pay" element={
            <RequireAuth>
              <Pay />
            </RequireAuth>
          } />
        </Routes>
      </Suspense>
    </Router>
  );
}

export default App;

7.2 路由配置分析

javascript 复制代码
路由表:

  路径       组件          是否需要鉴权    懒加载
  ─────────────────────────────────────────────
  /         Home          否              是
  /login    Login         否              是
  /pay      Pay           是(RequireAuth)是

  懒加载(lazy + Suspense):
  → const Home = lazy(() => import('./pages/Home'))
  → 首页只加载当前路由的代码
  → 提升首屏加载速度
  → Suspense 包裹,加载中显示 fallback

  受保护路由:
  → <RequireAuth><Pay /></RequireAuth>
  → Pay 组件被 RequireAuth 包裹
  → 渲染前先检查 token
  → 无 token 不渲染,跳转登录

7.3 Zustand 的选择器模式

javascript 复制代码
// 组件中按需获取状态
const token = useAuthStore(state => state.token);
const user = useAuthStore(state => state.user);
const logout = useAuthStore(state => state.logout);
ini 复制代码
选择器模式 vs 全量获取:

  全量获取(不推荐):
  const store = useAuthStore();
  → store 的任何状态变化都会触发组件重新渲染
  → 性能浪费

  选择器获取(推荐):
  const token = useAuthStore(state => state.token);
  → 只订阅 token 的变化
  → 只有 token 变了才重新渲染
  → user 变化不影响只用到 token 的组件

  这和 Redux 的 useSelector 思想一致:
  → 精确订阅,按需渲染

八、Login 页面:表单校验与登录流程

8.1 Login.jsx 完整代码

jsx 复制代码
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { login } from '../api/user';
import { useAuthStore } from '../store/user';
import styles from './Login.module.css';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from || '/';

  const setAuth = useAuthStore(state => state.setAuth);

  const [formData, setFormData] = useState({ username: '', password: '' });
  const [errors, setErrors] = useState({ username: '', password: '' });
  const [isValid, setIsValid] = useState(false);

  // 表单验证
  useEffect(() => {
    const newErrors = { username: '', password: '' };

    if (!formData.username.trim()) {
      newErrors.username = '用户名不能为空';
    } else if (formData.username.length < 3) {
      newErrors.username = '用户名至少3位';
    }

    if (!formData.password.trim()) {
      newErrors.password = '密码不能为空';
    } else if (formData.password.length < 6) {
      newErrors.password = '密码至少6位';
    }

    setErrors(newErrors);
    setIsValid(!newErrors.username && !newErrors.password);
  }, [formData]);

  const handleChange = e => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleLogin = async e => {
    e.preventDefault();
    try {
      const res = await login(formData);
      if (res.code === 0) {
        setAuth({ token: res.token, user: res.user });
        navigate(from, { replace: true });
      } else {
        alert(res.message || '登录失败');
      }
    } catch (err) {
      console.error(err);
      alert('登录失败');
    }
  };

  return (
    <div className={styles.container}>
      <h2>登录</h2>
      <form onSubmit={handleLogin}>
        <div className={styles.formGroup}>
          <label htmlFor="username">用户名</label>
          <input
            id="username"
            type="text"
            name="username"
            value={formData.username}
            onChange={handleChange}
            required
          />
          {errors.username && <div className={styles.error}>{errors.username}</div>}
        </div>

        <div className={styles.formGroup}>
          <label htmlFor="password">密码</label>
          <input
            id="password"
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required
          />
          {errors.password && <div className={styles.error}>{errors.password}</div>}
        </div>

        <button type="submit" disabled={!isValid}>
          登录
        </button>
      </form>
    </div>
  );
}

export default Login;

8.2 登录流程拆解

ini 复制代码
┌──────────────────────────────────────────────────────────┐
│                  完整登录流程                              │
│                                                          │
│  ① 用户输入用户名和密码                                    │
│     handleChange → setFormData 更新表单数据                │
│                                                          │
│  ② useEffect 实时校验                                      │
│     → 用户名不能为空,至少 3 位                            │
│     → 密码不能为空,至少 6 位                              │
│     → 校验通过:isValid = true,按钮可点击                 │
│     → 校验失败:isValid = false,按钮禁用                 │
│                                                          │
│  ③ 点击登录按钮                                             │
│     handleLogin → e.preventDefault() 阻止默认提交         │
│                                                          │
│  ④ 调用 API                                                │
│     const res = await login(formData)                    │
│     → axios.post('/login', { username, password })       │
│     → 请求拦截器:不需要 token(登录请求)                 │
│     → Mock 服务器验证账密                                 │
│     → jwt.sign 签发 token                                 │
│     → 响应拦截器:提取 res.data                            │
│                                                          │
│  ⑤ 处理响应                                                │
│     res.code === 0                                        │
│     → setAuth({ token: res.token, user: res.user })     │
│       → 存入 localStorage                                  │
│       → 更新 Zustand store                                │
│       → Nav 组件重新渲染(显示 Logout)                   │
│     → navigate(from, { replace: true })                  │
│       → 跳回用户之前想访问的页面                            │
│                                                          │
│  ⑥ 登录失败                                                │
│     res.code !== 0                                       │
│     → alert(res.message)                                 │
│     → 留在登录页                                          │
└──────────────────────────────────────────────────────────┘

8.3 CSS Module:样式隔离

css 复制代码
/* Login.module.css */
.container {
  max-width: 400px;
  margin: 50px auto;
  padding: 2rem;
  border: 1px solid #ddd;
  border-radius: 8px;
  background-color: #fafafa;
}

.error {
  color: red;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
vbnet 复制代码
CSS Module 的价值:

  普通 CSS:
  → .container { ... }
  → 全局作用域,可能和其他组件冲突

  CSS Module:
  → Login.module.css
  → 编译后类名变成 Login_container_xxx
  → 天然隔离,不会冲突
  → import styles from './Login.module.css'
  → className={styles.container}

九、Nav 组件:条件渲染与登出

jsx 复制代码
import { Link } from 'react-router-dom';
import { useAuthStore } from '../store/user';

function Nav() {
  const token = useAuthStore(state => state.token);
  const user = useAuthStore(state => state.user);
  const logout = useAuthStore(state => state.logout);

  const handleLogout = () => {
    logout();
  };

  return (
    <nav style={{ padding: 0, borderBottom: '1px solid #ccc' }}>
      <Link to="/">Home</Link>
      <Link to="/pay">Pay</Link>
      {!token && <Link to="/login">Login</Link>}
      {user && <span>Hello, {user.username}!</span>}
      {token && <button onClick={handleLogout}>Logout</button>}
    </nav>
  );
}

export default Nav;

9.2 条件渲染逻辑

yaml 复制代码
Nav 组件根据登录状态显示不同内容:

  未登录状态(token 为空字符串):
  ├── Home 链接
  ├── Pay 链接
  ├── Login 链接        ← !token 为 true
  └── 不显示用户名和登出按钮

  已登录状态(token 不为空):
  ├── Home 链接
  ├── Pay 链接
  ├── 不显示 Login 链接  ← !token 为 false
  ├── Hello, admin!    ← user 存在
  └── Logout 按钮       ← token 存在

  Zustand 的响应式更新:
  → setAuth 更新 store
  → Nav 组件自动重新渲染
  → 不需要手动管理 DOM

十、完整鉴权链路总结

10.1 端到端流程

scss 复制代码
┌──────────────────────────────────────────────────────────────────┐
│                    JWT 鉴权完整链路                                │
│                                                                  │
│  【未登录访问受保护页面】                                          │
│                                                                  │
│  ① 用户访问 /pay                                                  │
│     → RequireAuth 检查 token                                     │
│     → token 为空 → Navigate to /login                            │
│     → state = { from: '/pay' }                                  │
│                                                                  │
│  ② 登录页展示                                                     │
│     → 用户输入 admin / 123456                                    │
│     → 实时校验通过                                                │
│     → 点击登录                                                    │
│                                                                  │
│  【登录流程】                                                      │
│                                                                  │
│  ③ login(formData)                                               │
│     → axios.post('/api/login', { username, password })           │
│     → 请求拦截器:无 token 不加 Header                           │
│     → Mock 服务器验证账密                                         │
│     → jwt.sign({ user, role }, secret, { expiresIn })           │
│     → 返回 { code: 0, user: { username }, token: 'eyJ...' }    │
│     → 响应拦截器:提取 res.data                                    │
│                                                                  │
│  ④ setAuth({ token, user })                                      │
│     → localStorage.setItem('token', token)                       │
│     → localStorage.setItem('user', JSON.stringify(user))         │
│     → set({ token, user }) → Zustand 更新                       │
│     → Nav 组件重新渲染:显示 Logout + Hello, admin!              │
│                                                                  │
│  ⑤ navigate(from)                                                │
│     → 跳回 /pay                                                   │
│     → RequireAuth 检查 token → 通过 → 渲染 Pay                  │
│                                                                  │
│  【后续请求自动携带 token】                                        │
│                                                                  │
│  ⑥ getRepo()                                                     │
│     → axios.get('/api/repo')                                     │
│     → 请求拦截器:config.headers['Authorization'] = Bearer xxx  │
│     → Mock 服务器 jwt.verify(token, secret)                      │
│     → 验证通过:返回 { code: 0, data: 'admin' }                │
│     → 验证失败:返回 { code: 401, msg: 'Invalid token' }        │
│                                                                  │
│  【登出流程】                                                      │
│                                                                  │
│  ⑦ handleLogout()                                                 │
│     → logout()                                                   │
│     → localStorage.removeItem('token')                          │
│     → localStorage.removeItem('user')                            │
│     → set({ token: '', user: null })                            │
│     → Nav 组件重新渲染:显示 Login 链接                          │
│     → 访问 /pay → RequireAuth 拦截 → 跳转 /login                │
└──────────────────────────────────────────────────────────────────┘

10.2 各层职责

scss 复制代码
┌──────────────────────────────────────────────────────────┐
│                  各层职责边界                            │
│                                                          │
│  组件层(pages/ + components/)                          │
│  ├── Login:表单校验 + 调用 API + 更新 Store             │
│  ├── RequireAuth:检查 token + 路由守卫                  │
│  ├── Nav:条件渲染 + 登出操作                             │
│  └── App:路由配置 + 懒加载                              │
│                                                          │
│  API 层(api/)                                          │
│  ├── config.js:Axios 实例 + 请求/响应拦截器              │
│  ├── user.js:login(data)                               │
│  └── repo.js:getRepo()                                  │
│                                                          │
│  状态层(store/)                                        │
│  ├── user.js:token + user + setAuth + logout            │
│  └── todos.js:todos + setTodos                          │
│                                                          │
│  Mock 层(mock/)                                        │
│  └── user.js:jwt.sign + jwt.verify                     │
│                                                          │
│  原则:                                                  │
│  组件不直接操作 localStorage → 通过 Store               │
│  组件不直接拼接 token Header → 通过 Axios 拦截器         │
│  组件不直接验证 token → 通过后端 API                     │
│  每层只做自己的事,通过约定的接口协作                     │
└──────────────────────────────────────────────────────────┘

十一、总结

11.1 知识体系图

bash 复制代码
JWT 登录鉴权全链路
│
├── JWT 理论
│   ├── HTTP 无状态 → 需要身份凭证
│   ├── JWT = JSON Web Token
│   ├── sign:JSON 对象 → 加密 → token
│   ├── verify:token → 解密 → JSON 对象
│   ├── Bearer Token 格式:Authorization: Bearer xxx
│   └── 优势:无状态、分布式友好、跨域支持
│
├── Zustand 状态管理
│   ├── create(set => ({ state, actions }))
│   ├── 选择器模式:useAuthStore(s => s.token)
│   ├── auth Store:token + user + setAuth + logout
│   ├── localStorage 持久化
│   └── 子仓库拆分(auth / todos / ...)
│
├── Axios 拦截器
│   ├── axios.create({ baseURL, timeout })
│   ├── 请求拦截器:自动注入 Bearer Token
│   ├── 响应拦截器:自动提取 res.data
│   └── 组件不需要手动处理 token
│
├── 路由守卫
│   ├── RequireAuth 组件包裹受保护路由
│   ├── 无 token → Navigate to /login
│   ├── state={{ from }} 记住来源页面
│   └── 登录成功后跳回原页面
│
├── Vite Mock Server
│   ├── viteMockServe 插件配置
│   ├── mock/user.js 模拟后端
│   ├── /api/login:验证账密 → jwt.sign → 返回 token
│   └── /api/repo:jwt.verify → 返回数据或 401
│
├── Login 页面
│   ├── useEffect 实时表单校验
│   ├── 受控组件 + 状态管理
│   ├── 调用 login API → setAuth → navigate
│   └── CSS Module 样式隔离
│
└── Nav 组件
    ├── 条件渲染(!token / token / user)
    ├── Link 路由导航
    └── logout 清除状态

11.2 核心概念速查

概念 要点
JWT JSON Web Token,sign 签发 / verify 验证
Bearer Token Authorization: Bearer xxx,服务器 split(' ')1 取 token
zustand 轻量状态管理,create(set => ({ state, actions }))
选择器模式 useAuthStore(s => s.token),精确订阅,按需渲染
请求拦截器 每个请求自动注入 Authorization: Bearer ${token}
响应拦截器 自动提取 res.data,组件拿到的就是业务数据
RequireAuth 路由守卫,无 token 跳登录页,记住来源页面
localStorage 持久化 token 和 user,页面刷新不丢失
jwt.sign { payload, secret, { expiresIn } } → token 字符串
jwt.verify (token, secret) → decoded 对象,失败抛异常
Navigate React Router 声明式跳转,replace 替换历史记录
CSS Module .module.css 后缀,编译后类名带 hash,样式隔离

11.3 一句话总结

前端鉴权工程 = JWT(身份凭证)+ Zustand(全局状态)+ Axios 拦截器(自动注入)+ 路由守卫(访问控制)。登录时 jwt.sign 签发 token,Zustand setAuth 存储 token 到 localStorage,Axios 请求拦截器自动注入 Bearer Token,RequireAuth 路由守卫拦截未授权访问------四个角色各司其职,组成完整的鉴权闭环。


如果这篇文章对你有帮助,欢迎点赞收藏

相关推荐
可涵不会debug1 小时前
LangChain 示例选择器(Example selectors)完整基础概念解读
服务器·前端·数据库
Csvn1 小时前
😱 React `<StrictMode>`:为什么 useEffect 被调用了两次?别慌,这是特性不是 bug
前端
Asize1 小时前
2 道大厂面试题:TS 工具类型我懂了,CSS 3 列布局把我问住了
前端·css·typescript
胡萝卜术1 小时前
从"氛围编程"到规范驱动:两次创造如何让 AI 协作从碰运气变成工程流水线
前端·面试·github
Imchendiana1 小时前
《狂人日记NO.11》— 给 AI 装一本"项目说明书":我把"自己"蒸馏成了一个编码知识库Skill
前端·ai编程
l1258651 小时前
# RAG多轮对话检索设计:Query重写如何让“那它呢“变成完整问题
前端·数据库·人工智能·python·算法·fastapi·milvus
এ慕ོ冬℘゜1 小时前
前端实战:使用 jQuery 与 CSS3 打造动态数据表格与滑块交互
前端·css3·jquery
Csvn1 小时前
✂️ AbortController:一个 API 统一取消 fetch、事件监听与 AI 流式请求
前端
invicinble1 小时前
对于vue2转vue3相关技术整合
前端