React + Zustand + JWT:从零实现登录鉴权与请求拦截

引言

你有没有遇到过这样的场景:用户登录成功后,刷新页面就丢了登录态?或者每个请求都要手动写一遍 Authorization 头,代码又臭又长?

说白了,登录鉴权的本质,就是在无状态的 HTTP 协议上,建立起一个可信任的身份传递机制。今天这篇文章,我会用 React + Zustand + Axios + JWT,带你从零搭建一个完整的前端登录鉴权方案,并且用 mockjs 模拟后端接口,让你读完就能直接落地到自己的项目里。

读完你将收获:

  • JWT 的 sign / verify 核心原理
  • Zustand 管理全局登录态的实战用法
  • Axios 拦截器自动携带 token 的优雅实现
  • 基于 mockjs 的本地后端模拟
  • 路由守卫与导航栏登录态展示的完整思路

一、JWT:无状态 HTTP 的身份令牌

HTTP 是无状态的,每次请求都是独立的,服务器不知道你是谁。所以我们需要一个"身份凭证",让用户在登录后,后续每次请求都能证明"我是 admin"。

JWT(JSON Web Token)就是为此而生的。它本质上是一个字符串,由三部分组成:

复制代码
Header.Payload.Signature
  • Header:声明加密算法(如 HS256)
  • Payload:存放用户身份信息(JSON 对象)
  • Signature:对前两部分的签名,防止篡改

JWT 有两个核心动作:

  • sign:把用户 JSON 对象加密成 token(登录时颁发)
  • verify:把 token 解密还原成 JSON 对象(后续请求验证)

对比传统的 cookie/session 方案,JWT 最大的优势是无状态

  • session 方案:服务器需要保存 sessionId 和会话对象,分布式环境下要共享 session,麻烦。
  • JWT 方案:任何一台服务器签发的 token,在其他服务器上都能用同一个 secret 验签,天然适合微服务和分布式架构。

下面看我们 mock 后端如何实现 sign 和 verify:

复制代码
// mock/user.js 中的登录接口(部分)
const token = jwt.sign(
  {
    user: body.username,   // payload 放用户信息
    role: 'admin'
  },
  secret,                  // 加密密钥(生产环境请放环境变量)
  {
    expiresIn: 86400       // 过期时间 24h
  }
)

// mock/user.js 中的 /api/repo 接口(验证 token)
const authHeader = req.headers?.authorization;
if (!authHeader) {
  return { code: 401, msg: 'Missing authorization header' };
}
const token = authHeader.split(' ')[1]; // Bearer xxx
try {
  let decoded = jwt.verify(token, secret);
  // decoded 就是之前 sign 进去的 payload
  return { code: 0, data: decoded.user }
} catch (err) {
  return { code: 401, msg: 'Invalid token' }
}

你会发现,JWT 就像一个自带身份信息的加密信封,前端拿到后每次请求带上它,后端验签通过就知道你是谁。


二、Zustand:轻量全局状态管理

登录状态(token、user 信息)是典型的跨组件、跨路由共享的数据。如果用 React 自带的 Context 也能做,但代码会显得啰嗦;用 Redux 又太重。Zustand 就是一个非常轻量的选择------API 简单,不需要 Provider 包裹,直接 hooks 调用。

全局状态不要散落在组件里,统一交给 store,组件只负责渲染和交互。

我们创建 store/user.js

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

export const useAuthStore = create(set => ({
  // 初始化时从 localStorage 读取,解决刷新丢状态问题
  token: localStorage.getItem('token') || '',
  user: JSON.parse(localStorage.getItem('user')) || null,

  // 登录成功后设置状态,并持久化到 localStorage
  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 });
  }
}))

在组件中使用:

复制代码
const setAuth = useAuthStore(state => state.setAuth);
const user = useAuthStore(state => state.user);
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);

Zustand 的 create 返回一个 hook,组件里按需订阅状态,避免不必要的渲染。这就是它比 Context 更优雅的地方。

深入理解 set 方法

上面的代码里,create 的函数参数接收一个 set,这个 set 就是 Zustand 用来更新状态的核心方法。理解它,你就掌握了 Zustand 的精髓。

set 的用法非常灵活,主要有两种形式:

1. 直接传入一个对象(部分状态)

复制代码
set({ token: 'xxx', user: { name: 'admin' } })

这是最常见的写法,Zustand 会浅合并 这个对象到当前状态中。也就是说,你只传需要更新的字段,其他字段保持不变。比如 setAuth 里我们只更新了 tokenuser,如果 store 里还有其他状态(比如 count),它们不会被影响。

2. 传入一个函数,基于旧状态计算新状态

复制代码
set(state => ({ count: state.count + 1 }))

当你需要依赖旧状态来更新时,一定要用函数形式。比如计数器、数组追加等场景,避免因闭包导致的状态过期问题。

在我们的 store/user.js 中,使用的是对象形式,因为 setAuthlogout 都是整体替换 tokenuser,不依赖旧值。但如果你的 store 有更复杂的更新逻辑,函数形式会更安全。

另外,注意 set同步 执行的,调用后 Zustand 会立即通知所有订阅了相关状态的组件重新渲染。所以不需要 await,也不要在 set 后面写依赖新状态的同步逻辑(不过实际上状态已经更新了,可以读取到最新值)。

再看另一个例子 store/todos.js

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

export const useTodosStore = create(set => ({
  todos: [],
  setTodos: (todos) => {
    set({ todos });
  }
}))

这里 setTodos 也是用对象形式整体替换 todos 数组。如果要在原数组上追加,就可以改成函数形式:

复制代码
addTodo: (todo) => set(state => ({ todos: [...state.todos, todo] }))

一句话总结:set 是 Zustand 的状态更新入口,对象形式用于普通更新,函数形式用于依赖旧状态的更新。掌握它,你的 store 就能灵活应对各种场景。


三、mockjs:前端独立开发的后端模拟器

前后端分离开发时,后端接口可能还没好。用 vite-plugin-mock 可以轻松在本地模拟接口,让你专注于前端逻辑。

安装配置:

复制代码
// 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  // 本地开发启用
    })
  ],
})

然后在 mock/user.js 中定义接口。我们有两个接口:

  • POST /api/login:验证用户名密码,签 token
  • GET /api/repo:需要携带 token,验签后返回数据

完整的 mock 代码上面已经展示过了,这里不再重复。需要注意的是,mock 接口的路径要和 axios 的 baseURL 匹配 ,我们后面会设置 baseURL: '/api',这样请求 /login 实际就是 /api/login


四、登录流程实战:表单验证 + 状态更新

登录页面是鉴权的入口,我们来看核心逻辑:

复制代码
// 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';

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);

  // 表单验证逻辑(略)

  const handleLogin = async e => {
    e.preventDefault();
    try {
      const res = await login(formData); // 调用 API
      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('登录失败');
    }
  };

  // 渲染表单(略)
}

登录成功后,token 和 user 同时写入了 localStorage 和 Zustand store。这样即使用户刷新页面,也能从 localStorage 恢复登录态。

注意:真实项目中,res.user 应该由后端返回完整用户信息,不要只信任前端传的用户名


五、Axios 拦截器:自动携带 token

如果每次请求都要手动写:

复制代码
axios.get('/repo', {
  headers: { authorization: `Bearer ${token}` }
})

那代码会非常重复且容易遗漏。Axios 拦截器就是来解决这个问题的。

复制代码
// api/config.js
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;

拦截器就像一道安检门,每个请求都要经过它------请求前检查有没有 token,有就带上;响应后统一解包,业务代码直接拿数据。

这样,业务 API 函数就变得非常干净:

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

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

六、路由守卫:保护敏感页面

有些页面(如支付页 /pay)必须登录后才能访问。我们需要一个路由守卫组件:

复制代码
// 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;

然后在路由中包裹需要保护的页面:

复制代码
// App.jsx
<Route path="/pay" element={
  <RequireAuth>
    <Pay />
  </RequireAuth>
} />

这样,未登录用户访问 /pay 就会被重定向到 /login

进阶优化 :如果你希望登录后能跳回用户原本想访问的页面,可以在 RequireAuth 中传递 state

复制代码
// 改进版 RequireAuth
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" state={{ from: location }} replace />;
  }
  return <>{children}</>;
}

这样登录页中的 location.state?.from 就能拿到原始路径,实现"从哪里来,回哪里去"的体验。


七、导航栏:登录态的条件渲染

导航栏是展示登录状态的绝佳位置。根据 token 和 user 的不同,显示不同的链接和按钮:

复制代码
// 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();
    // 可选:跳转到首页
    // window.location.href = '/';
  };

  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 && <a>{user.name}</a>}
      {token && <button onClick={handleLogout}>Logout</button>}
    </nav>
  );
}

export default Nav;

登录态的管理,不仅仅是为了鉴权,更是为了提供个性化的用户体验。导航栏根据 token 和 user 动态展示,让用户一眼就知道自己是否登录、是谁。


八、完整流程串联

让我们把整个流程串起来:

  1. 用户登录 :输入用户名密码,提交 /api/login
  2. 后端签发 token :验证通过后,用 jwt.sign 生成 token 并返回
  3. 前端存储状态setAuth 将 token 和 user 存入 Zustand store 和 localStorage
  4. 后续请求自动带 token :Axios 请求拦截器从 localStorage 读取 token,写入 authorization
  5. 后端验证 token :需要鉴权的接口用 jwt.verify 验签,返回用户数据
  6. 路由守卫保护页面:未登录访问敏感页面自动跳转登录
  7. 导航栏动态展示:根据登录态显示不同内容

整个方案的核心思想是:状态统一管理,请求统一拦截,页面统一守卫


九、避坑指南与总结

常见坑点

  1. localStorage 的 XSS 风险:token 存在 localStorage 中,如果网站存在 XSS 漏洞,攻击者可以窃取 token。更安全的做法是使用 httpOnly cookie,但那需要后端配合设置。对于多数内部系统或学习项目,localStorage 足够方便。
  2. token 过期处理:JWT 有过期时间(expiresIn),过期后后端会返回 401。前端可以在响应拦截器中统一处理 401,比如清除状态并跳转登录页。本文示例没有处理,实际项目中应该加上。
  3. 刷新页面状态初始化:Zustand 初始化时从 localStorage 读取,这是同步操作,不会导致组件初始渲染时状态缺失。但如果用户信息很大,可以考虑只存 token,用户信息通过接口获取。
  4. secret 安全:mock 中的 secret 是硬编码的,生产环境一定要放在环境变量中,并且定期更换。

总结

我们用一套简洁的技术栈(React + Zustand + Axios + JWT)实现了完整的登录鉴权流程。核心代码覆盖了:

  • JWT 的 sign / verify
  • Zustand 全局状态管理(含 set 方法解析)
  • Axios 请求/响应拦截器
  • mockjs 本地模拟接口
  • 路由守卫保护页面
  • 导航栏登录态展示

技术选型没有银弹,但适合自己的就是最好的。 Zustand 足够轻,JWT 足够通用,Axios 拦截器足够优雅,这套组合拳非常适合中小型 React 项目快速落地。

相关推荐
葡萄城技术团队1 小时前
下拉多选类型单元格:在 SpreadJS 里接入 xm\-select
前端
粥里有勺糖1 小时前
视野修炼-技术周刊第130期 | 光影效果
前端·javascript·github
小岛前端2 小时前
AI Skills 已经封神,但新的问题却越来越严重!
前端·后端·github
拖孩2 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序
用户93983829415992 小时前
安卓自动化脚本热更新怎么做?不重新打包 APK 也能更新代码
前端
猫七先生2 小时前
在 React 里用 PMTiles 自托管底图
前端
tanghonghanhaoli2 小时前
【图书翻译】有意义的游戏设计:桌面游戏的方法论与心理学(第1、2章)
前端·游戏·ui
光影少年2 小时前
react native打包优化、包体积瘦身、离线包/热更新方案
前端·react native·react.js
Cache技术分享2 小时前
504. Java 反射 - 创建一个简单的依赖注入框架
前端·后端