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 小时前
一个没测暗色模式的 Bug,吃掉了我一半 谷歌扩展用户
前端·javascript·程序员
liangshanbo12151 小时前
React useState 函数式更新面试题整理
前端·react.js·前端框架
涛涛ing1 小时前
为什么你的页面在 Safari 上总出问题?Interop 2027 正在解决这个 20 年老毛病
前端
不一样的少年_1 小时前
WebP 压缩到底在干嘛?小白也能看懂的原理拆解
前端·后端·图片资源
问心无愧05131 小时前
ctf show web 177
前端·笔记
不一样的少年_2 小时前
PNG/JPG 如何变成 WebP?真相不是改后缀!
前端·后端·图片资源
杉氧2 小时前
RN 性能调优指南:重渲染(Re-renders)控制与长列表(FlatList)优化
android·前端·react native
一_个前端2 小时前
[JS] 一站式搞定 PDF、图片、Dom弹窗、表格的浏览器打印功能
前端
JavaGuide2 小时前
阿里 Qoder 又开源了一个专门给 Claude Code、Codex 做“体检”的项目
前端·后端
不一样的少年_2 小时前
JPEG 压缩到底在干嘛?小白也能看懂的 8 步拆解
前端·后端·图片资源