实战 React + JWT + Zustand:从零搭建登录鉴权系统

告别繁琐的 Context + useReducer,用 Zustand 优雅管理全局状态,配合 JWT 实现完整的登录鉴权流程。

前言

在前后端分离的开发模式中,用户鉴权 是每个应用都绕不开的核心功能。传统的 session-cookie 方案在分布式环境下略显力不从心,而 JWT(JSON Web Token) 凭借其无状态、易扩展的特性,成为了现代 Web 应用的首选。

本文将带你从零开始,使用 React + React Router + Zustand + axios + Mock.js 搭建一套完整的登录鉴权系统。你将学到:

  • JWT 的核心原理与使用方式
  • Zustand 的极简状态管理
  • 路由守卫实现页面访问控制
  • axios 拦截器的优雅封装
  • Mock.js 模拟后端接口

一、JWT 登录鉴权核心原理

1.1 为什么需要 JWT?

HTTP 协议本身是 无状态(Stateless) 的,每次请求都是独立的。服务器需要知道「你是谁」,于是就有了身份认证的需求。

方案 特点 适用场景
Session-Cookie 服务端存储 session,客户端存 sessionId 单体应用
JWT 服务端不存状态,客户端存 token 分布式、微服务

JWT 的核心思想是:服务器将用户身份信息(JSON 对象)通过加密算法签名,生成一串 token 颁发给客户端 。客户端后续请求只需在 Authorization 头中携带该 token,服务器即可解码还原用户信息,无需额外存储。

1.2 JWT 的两大动作

  • sign(签发) :将用户 JSON 对象 + 密钥 → 生成 token
  • verify(验证) :将 token + 密钥 → 还原用户 JSON 对象

javascript

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

const secret = 'secret819!$';

// 签发 token
const token = jwt.sign(
  { user: 'admin', role: 'admin' },
  secret,
  { expiresIn: 86400 } // 24小时过期
);

// 验证 token
const decoded = jwt.verify(token, secret);

二、项目结构与技术选型

2.1 技术栈

技术 用途
React 18 UI 框架
React Router v6 路由管理
Zustand 轻量级状态管理
axios HTTP 请求库
vite-plugin-mock 模拟后端接口
jsonwebtoken JWT 签名与验证

2.2 目录结构

text

ruby 复制代码
login-demo/
├── mock/
│   └── user.js                # Mock 接口定义
├── src/
│   ├── api/
│   │   ├── config.js          # axios 实例 + 拦截器
│   │   ├── user.js            # 用户相关 API
│   │   └── repo.js            # 示例 API
│   ├── components/
│   │   ├── Nav.jsx            # 导航组件
│   │   └── RequireAuth.jsx    # 路由守卫
│   ├── pages/
│   │   ├── Home.jsx           # 首页
│   │   ├── Login.jsx          # 登录页
│   │   ├── Login.module.css   # 登录页样式
│   │   └── Pay.jsx            # 受保护页面
│   ├── store/
│   │   ├── user.js            # 用户状态仓库
│   │   └── todos.js           # 示例仓库
│   ├── App.jsx
│   ├── main.jsx
│   └── index.css
├── vite.config.js
└── package.json

三、Zustand:极简的全局状态管理

3.1 为什么选择 Zustand?

React 官方推荐 Context + useReducer 进行跨层级状态共享,但存在 渲染性能模板代码过多 的问题。Zustand 是一个轻量级状态管理库,API 简洁、性能优异,非常适合中小型项目。

3.2 创建用户状态仓库

src/store/user.js 中定义用户状态:

javascript

javascript 复制代码
// 全局配置:提供用户身份状态存储
import { create } from 'zustand';

// 从 localStorage 读取初始状态
const getInitialState = () => ({
  token: localStorage.getItem('token') || '',
  user: JSON.parse(localStorage.getItem('user')) || null,
});

export const useAuthStore = create((set) => ({
  ...getInitialState(),

  // 设置认证信息(登录时调用)
  setAuth: ({ token, user }) => {
    // 持久化到 localStorage
    localStorage.setItem('token', token);
    localStorage.setItem('user', JSON.stringify(user));
    set({ token, user });
  },

  // 清除认证信息(退出时调用)
  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    set({ token: '', user: null });
  },
}));

💡 关键点 :Zustand 的 create 接收一个函数,返回包含状态和 action 的 store。set 方法用于更新状态,同时我们手动同步到 localStorage 实现持久化。

3.3 在组件中使用

jsx

javascript 复制代码
// 获取 token
const token = useAuthStore((state) => state.token);

// 获取用户信息
const user = useAuthStore((state) => state.user);

// 获取 action
const logout = useAuthStore((state) => state.logout);

四、Mock.js:模拟后端接口

开发阶段无需真实后端,使用 vite-plugin-mock 即可快速模拟 RESTful API。

4.1 配置 vite-plugin-mock

javascript

javascript 复制代码
// 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,    // 开发环境启用
    }),
  ],
});

4.2 编写 Mock 接口

mock/user.js 中定义 /api/login/api/repo 两个接口:

javascript

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

const secret = 'secret819!$';

export default [
  // ---------- 登录接口 ----------
  {
    url: '/api/login',
    method: 'post',
    timeout: 2000,
    response: ({ body }) => {
      const { username, password } = body;

      // 模拟验证
      if (username !== 'admin' || password !== '123456') {
        return {
          code: -1,
          message: '用户名或密码错误',
        };
      }

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

      return {
        code: 0,
        user: { username },
        token,
      };
    },
  },

  // ---------- 获取仓库信息(需要鉴权) ----------
  {
    url: '/api/repo',
    method: 'get',
    response: (req) => {
      // 从 Authorization 头中提取 token
      const authHeader = req.headers.authorization || '';
      const token = authHeader.split(' ')[1];

      try {
        // 验证 token
        const decoded = jwt.verify(token, secret);
        return {
          code: 0,
          data: decoded.user,
        };
      } catch (err) {
        return {
          code: 401,
          message: 'Invalid token',
        };
      }
    },
  },
];

🔐 安全提示:实际生产环境中,密钥应存放在环境变量中,切勿硬编码。


五、axios 拦截器:统一处理请求与响应

5.1 创建 axios 实例

src/api/config.js 中封装 axios:

javascript

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

const instance = axios.create({
  baseURL: '/api',   // 所有请求以 /api 开头
  timeout: 5000,
});

// ---------- 请求拦截器 ----------
// 在每个请求发出前,自动从 localStorage 获取 token 并添加到 header
instance.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
});

// ---------- 响应拦截器 ----------
// 统一处理响应数据,直接返回 data 部分
instance.interceptors.response.use((res) => {
  return res.data;
});

export default instance;

5.2 封装 API 方法

javascript

javascript 复制代码
// src/api/user.js
import axios from './config';

export const login = async (data) => {
  const res = await axios.post('/login', data);
  return res;
};

javascript

javascript 复制代码
// src/api/repo.js
import axios from './config';

export const getRepo = async () => {
  const res = await axios.get('/repo');
  return res;
};

六、登录页面:表单验证 + 状态更新

6.1 表单验证

使用 useEffect 实时校验表单数据,控制登录按钮的可用状态:

jsx

ini 复制代码
// src/pages/Login.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) {
        // ✅ 存储认证信息到 Zustand + localStorage
        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;

6.2 样式文件

css

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

h2 {
  text-align: center;
  margin-bottom: 1.5rem;
}

.formGroup {
  margin-bottom: 1rem;
}

label {
  display: block;
  font-weight: bold;
  margin-bottom: 0.5rem;
}

input {
  width: 100%;
  padding: 0.5rem;
  font-size: 1rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

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

button {
  width: 100%;
  padding: 0.75rem;
  font-size: 1rem;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

七、路由守卫:保护私有页面

7.1 实现 RequireAuth 组件

RequireAuth 是一个高阶组件,检查用户是否已登录,未登录则重定向到登录页:

jsx

javascript 复制代码
// 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 state={{ from: window.location.pathname }} />;
  }

  return children;
}

export default RequireAuth;

7.2 在路由中配置守卫

使用 React.lazy + Suspense 实现代码分割,提升首屏加载速度:

jsx

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

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

function App() {
  return (
    <Router>
      <Nav />
      <Suspense fallback={<div>加载中...</div>}>
        <Routes>
          {/* 公开路由 */}
          <Route path="/" element={<Home />} />
          <Route path="/login" element={<Login />} />

          {/* 受保护路由 */}
          <Route
            path="/pay"
            element={
              <RequireAuth>
                <Pay />
              </RequireAuth>
            }
          />
        </Routes>
      </Suspense>
    </Router>
  );
}

export default App;

八、导航组件:动态展示登录状态

根据登录状态显示不同的导航项:

jsx

javascript 复制代码
// src/components/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 1rem', borderBottom: '1px solid #ccc' }}>
      <Link to="/">🏠 Home</Link>
      <Link to="/pay">💳 Pay</Link>

      {!token ? (
        <Link to="/login">🔐 登录</Link>
      ) : (
        <>
          <span style={{ margin: '0 1rem' }}>👤 {user?.username}</span>
          <button onClick={handleLogout}>退出</button>
        </>
      )}
    </nav>
  );
}

export default Nav;

九、完整流程回顾

下面用一张流程图总结整个登录鉴权的完整链路:

text

javascript 复制代码
┌────────────┐     1. 输入用户名密码     ┌──────────────┐
│   Login    │ ──────────────────────────▶ │  /api/login │
│   页面     │                             │   Mock 接口 │
└────────────┘                             └──────────────┘
      │                                            │
      │                                            ▼
      │                                   2. JWT sign 生成 token
      │                                            │
      │                                   3. 返回 { code:0, token }
      │                                            │
      ▼                                            │
┌────────────┐                                     │
│ setAuth    │ ◀────────────────────────────────────┘
│ Zustand    │    4. 存入 Zustand + localStorage
│ + localStorage
└────────────┘
      │
      ▼
┌────────────┐     5. 登录成功,跳转到目标页
│  navigate  │
│  (from)    │
└────────────┘

┌────────────────────────────────────────────────────────┐
│                   后续每次请求                          │
├────────────────────────────────────────────────────────┤
│  axios 请求拦截器 → 从 localStorage 取 token          │
│  → 添加到 Authorization: Bearer <token>               │
│  → 发送请求                                           │
│                                                       │
│  服务器(Mock)→ 验证 token → 返回数据               │
│  token 无效 → 返回 401                                │
└────────────────────────────────────────────────────────┘

十、总结与思考

通过本篇文章,我们完成了一套完整的 React 登录鉴权系统,核心收获包括:

知识点 核心价值
JWT 无状态认证,适合分布式系统
Zustand 极简状态管理,告别 Context 地狱
路由守卫 优雅保护私有路由
axios 拦截器 统一处理 token,避免重复代码
Mock.js 前后端分离开发,不依赖后端即可联调

进一步优化方向

  1. token 刷新机制:使用 refresh_token 实现无感刷新
  2. 错误统一处理:响应拦截器中统一处理 401 跳转登录
  3. 权限细化:基于角色(role)控制页面/按钮访问
  4. TypeScript:为项目增加类型安全
相关推荐
晴天162 小时前
DeepSeek Harness 全景技术解析-Day23
前端·deepseek
风月说与山鬼3 小时前
三、uni-app页面配置(pages.json)
前端·uni-app
daols883 小时前
vue 实现基于 vxe-table 构建多维度产品对比表
前端·javascript·vue.js
前端 贾公子3 小时前
第08章:中间件(5)
服务器·前端·javascript
tech_zjf3 小时前
当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit
前端·后端
常宇佳3 小时前
vue3 @代指src路径设置
前端·typescript·vue
砚凝霜4 小时前
软考网络工程师|案例分析:Eth‑Trunk 链路聚合、iStack 堆叠、CSS 集群核心考点总结
前端·css·网络
珐恩AI-人工智能4 小时前
大模型意图召回偏差分析:GEO如何解决“有收录却不触发问答曝光”的难题
大数据·前端·人工智能·html·流量运营·geo优化
程序员老赵4 小时前
Docker 部署禅道 ZenTao:轻松搭建研发项目管理平台
前端·后端·github