React JWT 登录鉴权实战:Mock、Zustand、Axios 与路由守卫全流程

React JWT 登录鉴权实战:从 Mock 签发 Token 到 Zustand 与路由守卫

登录看起来只是提交用户名和密码,真正需要解决的问题却不止这些:服务器怎样识别后续请求来自谁?刷新页面后登录状态怎样恢复?每个请求都手动添加 Token 会不会太繁琐?未登录用户进入受保护页面时又该如何处理?

本文用一个 React 登录示例串起完整流程,技术栈包括 React、React Router、Zustand、Axios、Vite Mock 和 JSON Web Token。我们会从 HTTP 的无状态特性出发,逐步实现登录、Token 签发、全局身份状态、请求拦截、Token 校验、路由守卫与退出登录。

示例账号如下:

text 复制代码
用户名:admin
密码:123456

一、为什么登录后还需要 Token

HTTP 是无状态的。服务器处理完一次请求后,不会天然记得下一次请求与上一次请求来自同一个用户。

假设用户先调用登录接口:

http 复制代码
POST /api/login

即使用户名和密码正确,用户下一次请求 /api/repo 时,服务器仍然需要一种身份凭证来判断"这个请求已经登录"。本例采用 JWT,完整过程可以概括为:

text 复制代码
用户提交账号密码
        ↓
服务器校验账号密码
        ↓
服务器把用户身份写入 JWT 并签名
        ↓
客户端保存 Token
        ↓
后续请求通过 Authorization 请求头携带 Token
        ↓
服务器验证 Token,取出用户身份

请求头采用 Bearer 形式:

http 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Cookie/Session 与 JWT 的思路差异

传统的 Cookie/Session 登录通常采用下面的过程:

text 复制代码
服务器创建 Session,并保存用户会话
        ↓
客户端 Cookie 保存 Session ID
        ↓
浏览器后续请求自动携带 Cookie
        ↓
服务器根据 Session ID 查询会话对象

这种方案需要服务器保存并查询会话状态。如果会话只存在某一台服务器的内存中,多台服务器之间还要处理会话数据的共享问题。

本文的 JWT 方案不在服务端保存对应的 Session 对象。服务器签发包含身份信息的 Token,后续服务只要持有相同的验签密钥,就可以验证 Token 并读取身份。客户端在这里不依赖 Cookie 自动携带凭证,而是主动把 Token 放入 Authorization 请求头。

二、示例的业务结构

与登录鉴权相关的代码可以分成五层:

text 复制代码
mock/                 模拟服务端接口,负责签发和验证 JWT
src/api/              封装 Axios 实例以及具体请求
src/store/            使用 Zustand 管理全局登录状态
src/components/       导航栏与路由守卫
src/pages/            首页、登录页和受保护页面

各层职责是分开的:mock 层判断凭证是否有效,API 层统一处理请求,store 保存客户端身份状态,路由守卫决定页面是否允许进入,页面组件负责交互和展示。

三、使用 Vite Mock 模拟服务端

前端开发阶段没有真实后端时,可以用 vite-plugin-mock 模拟 /api 接口。在 Vite 配置中同时注册 React 插件和 Mock 插件:

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",
      localEnabled: true,
    }),
  ],
});

这里有两个关键配置:

  • mockPath: 'mock' 表示 Mock 接口定义放在 mock 目录。
  • localEnabled: true 表示开发环境启用本地 Mock。

这样,请求 /api/login/api/repo 时,就能由本地定义的响应函数处理。

四、登录成功后签发 JWT

模拟登录接口接收用户名和密码。账号不匹配时返回错误;校验通过后,通过 jsonwebtokensign 方法签发 Token:

js 复制代码
import jwt from "jsonwebtoken";

const secrit = "secret819!$";

export default [
  {
    url: "/api/login",
    method: "post",
    timeout: 2000,
    response: (req) => {
      const body = req.body;

      if (body.username !== "admin" || body.password !== "123456") {
        return {
          code: -1,
          message: "username or password error",
        };
      }

      const token = jwt.sign(
        {
          user: body.username,
          role: "admin",
        },
        secrit,
        {
          expiresIn: 86400,
        },
      );

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

jwt.sign 的三个参数分别表示:

  1. 载荷:需要写入 Token 的身份信息,这里包含用户名和角色。
  2. 签名密钥:签发和验证 Token 时使用同一个值。
  3. 配置项:expiresIn: 86400 表示 Token 的有效期为 86400 秒,也就是一天。

登录成功后,响应中既返回 user,也返回 tokenuser 适合直接用于界面展示,token 则负责后续请求的身份认证。

示例把密钥直接写在 Mock 代码中,是为了演示流程。它在这里扮演的是模拟服务端密钥,客户端不需要也不应该参与签名和验签。

五、受保护接口怎样验证 Token

/api/repo 是一个需要登录后才能访问的接口。它先读取 Authorization 请求头,再验证 Bearer Token:

js 复制代码
{
  url: '/api/repo',
  method: 'get',
  response: req => {
    const authorization = req.headers?.authorization

    if (!authorization?.startsWith('Bearer ')) {
      return {
        code: 401,
        message: 'Unauthorized'
      }
    }

    const token = authorization.slice(7)

    try {
      const decoded = jwt.verify(token, secrit)

      return {
        code: 0,
        decoded: decoded.user
      }
    } catch (error) {
      return {
        code: 401,
        message: 'Invalid token'
      }
    }
  }
}

这段代码分三步完成验证。

1. 检查请求头是否存在

js 复制代码
const authorization = req.headers?.authorization;

可选链 ?. 可以避免 headers 不存在时直接报错。接下来不仅要判断请求头存在,还要确认它以 Bearer 开头:

js 复制代码
if (!authorization?.startsWith("Bearer ")) {
  return {
    code: 401,
    message: "Unauthorized",
  };
}

不能直接写成下面这样:

js 复制代码
req.headers["authorization"].split(" ")[1];

因为未登录请求可能根本没有 authorization,此时对 undefined 调用 split 会抛出异常,甚至让开发服务器退出。身份校验面对的是外部输入,先判断再使用是必需的。

2. 提取 Token

Bearer 一共占 7 个字符,所以可以取出后面的 Token:

js 复制代码
const token = authorization.slice(7);

3. 验证签名与有效期

js 复制代码
const decoded = jwt.verify(token, secrit);

验证通过后可以读取载荷中的 user;Token 无效或过期时,verify 会抛出异常,因此需要放在 try...catch 中处理。

本例返回对象里的 code: 401 是业务数据中的状态字段。前端通过响应体中的 code 判断结果。

六、用 Axios 实例统一管理请求

如果每个接口都重复编写 /api 前缀、超时时间和响应处理,代码会越来越散。可以先创建一个 Axios 实例:

js 复制代码
import axios from "axios";

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

设置 baseURL: '/api' 后,业务接口只需传入 /login/repo,最终请求地址会自动组合成 /api/login/api/repo

请求拦截器:自动携带 Token

登录成功后,Token 会保存到 localStorage。请求拦截器会在每次请求发出前读取它:

js 复制代码
instance.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");

  if (token) {
    config.headers["Authorization"] = `Bearer ${token}`;
  }

  return config;
});

这样,页面调用接口时不必反复传 Token。只要本地存在 Token,Axios 就会自动生成:

http 复制代码
Authorization: Bearer <token>

拦截器最后必须返回 config,否则后续请求拿不到正常的配置对象。

响应拦截器:直接返回业务数据

Axios 的原始响应中不仅有后端数据,还包含状态码、响应头和请求配置等信息。这个示例只关心响应体,因此统一返回 res.data

js 复制代码
instance.interceptors.response.use((res) => {
  return res.data;
});

经过这一层处理,业务代码中的 res 就已经是 Mock 接口返回的对象,可以直接读取 res.coderes.userres.token

七、按业务模块封装接口

有了统一的 Axios 实例,登录请求可以写得很简单:

js 复制代码
import axios from "./config";

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

受保护资源使用 GET 请求:

js 复制代码
import axios from "./config";

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

这里导入的变量虽然叫 axios,实际拿到的是已经配置好 baseURL 和拦截器的实例。页面不需要了解 Token 具体怎样添加,只负责调用对应的业务函数。这个 GET 请求没有额外配置,因此直接调用 axios.get('/repo') 即可;Axios 的第二个参数是请求配置对象,并不是像 POST 那样的请求体。

八、用 Zustand 管理全局登录状态

登录状态会被导航栏、登录页和路由守卫共同使用。如果只把它放在某个页面的 useState 中,其他组件很难共享。虽然可以使用 createContextuseContext 跨层传递,但这里选择更轻量的 Zustand。

可以把 React 应用理解为两部分:

text 复制代码
React App = UI Components + Store

组件负责展示和交互,store 负责保存跨组件、跨路由共享的状态。

js 复制代码
import { create } from "zustand";

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

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

这个 store 包含两类内容:

  • 状态:tokenuser
  • 动作:setAuthlogout

为什么 Zustand 和 localStorage 都要更新

Zustand 中的状态负责驱动当前页面立即更新。例如登录成功后,导航栏能马上显示退出按钮。

localStorage 负责持久化。页面刷新后,JavaScript 内存状态会重新创建,但保存在浏览器中的 Token 和用户信息仍然存在。初始化 store 时读取本地数据,就能恢复登录状态:

js 复制代码
token: localStorage.getItem("token") || "";

用户对象不能直接保存到 localStorage,因为它只能存储字符串,所以写入时使用 JSON.stringify,读取时使用 JSON.parse

js 复制代码
localStorage.setItem("user", JSON.stringify(user));
JSON.parse(localStorage.getItem("user"));

注销时也要同步清除两份状态。set 中的属性名必须写成 user;如果误写成其他名字,即使 Token 被清除了,内存中的用户对象仍可能残留。

按需订阅状态

组件通过选择器只订阅自己需要的数据:

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

useAuthStore 的使用方式和自定义 Hook 一致,组件不需要 Provider 包裹就能读取或修改全局状态。

还可以按业务拆分其他子 store。例如待办事项状态可以单独管理:

js 复制代码
export const useTodosStore = create((set) => ({
  todos: [],
  setTodos: ({ todos }) => {
    set({ todos });
  },
}));

登录状态和待办状态彼此独立,这种按业务拆分的方式在状态逐渐增多时会更清晰。

九、实现受控登录表单

登录页使用三个局部状态:表单数据、错误信息和表单是否有效。

js 复制代码
const [formData, setFormData] = useState({
  username: "",
  password: "",
});

const [errors, setErrors] = useState({
  username: "",
  password: "",
});

const [isValid, setIsValid] = useState(false);

输入框的 value 来自 React 状态,onChange 再把新值写回状态,这就是受控表单。

js 复制代码
const handleChange = (e) => {
  const { name, value } = e.target;

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

两个输入框共用同一个事件函数。[name] 是计算属性名:修改用户名输入框时更新 username,修改密码输入框时更新 password。展开 prev 可以保留另一个字段的原值。

根据输入实时校验

formData 变化时,useEffect 会重新计算错误信息:

js 复制代码
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]);

这里的校验规则包括:

  • 用户名不能为空,且长度至少为 3。
  • 密码不能为空,且长度至少为 6。

只有两个错误信息都为空时,isValid 才为 true。提交按钮据此决定是否可用:

jsx 复制代码
<button type="submit" disabled={!isValid}>
  登录
</button>

提交登录请求

提交表单时先阻止浏览器默认刷新,再调用登录接口:

js 复制代码
const setAuth = useAuthStore((state) => state.setAuth);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from || "/";

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("登录失败");
  }
};

登录成功后的两个动作很关键:

  1. 调用 setAuth,同时更新 Zustand 和 localStorage
  2. 调用 navigate 完成页面跳转。

replace: true 会替换当前历史记录,而不是再压入一条新的登录页记录。location.state?.from || '/' 为来源页面预留了位置;如果没有传入来源信息,登录成功后默认回到首页。

十、用路由守卫保护页面

支付页不能允许未登录用户直接进入。可以写一个 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;

它的判断逻辑非常直接:

  • 没有 Token:渲染 Navigate,跳转登录页。
  • 存在 Token:渲染传入的 children

路由配置如下:

jsx 复制代码
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/login" element={<Login />} />
  <Route
    path="/pay"
    element={
      <RequireAuth>
        <Pay />
      </RequireAuth>
    }
  />
</Routes>

需要注意,前端路由守卫负责页面体验,但它不能代替接口鉴权。用户是否有权读取数据,最终仍要由 /api/repo 对 Token 进行验证。示例同时实现路由判断和接口验签,正是因为二者职责不同。

十一、路由懒加载与 Suspense

首页、登录页和支付页使用 lazy 动态加载:

js 复制代码
const Home = lazy(() => import("./pages/Home"));
const Login = lazy(() => import("./pages/Login"));
const Pay = lazy(() => import("./pages/Pay"));

动态模块尚未加载完成时,由 Suspense 显示兜底内容:

jsx 复制代码
<Suspense fallback={<div>loading...</div>}>
  <Routes>{/* 路由配置 */}</Routes>
</Suspense>

这部分与鉴权配合后,应用结构就很清楚:Router 管理页面地址,Suspense 处理懒加载等待状态,RequireAuth 判断受保护页面能否渲染。

十二、应用启动时验证受保护资源

应用首次挂载时,可以在本地存在 Token 的前提下请求受保护资源:

js 复制代码
useEffect(() => {
  const token = localStorage.getItem("token");

  if (!token) {
    return;
  }

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

这里先判断 Token,是为了避免未登录用户一打开页面就调用受保护接口。请求真正发出时,Axios 请求拦截器会再次读取 Token,并自动添加 Authorization

这一段只在应用首次挂载时执行,因此它展示的是"刷新页面后,使用本地 Token 请求资源"的过程。登录动作本身则通过 Zustand 立即更新界面状态。

十三、导航栏怎样响应登录状态

导航栏同样从 store 中选择所需状态:

jsx 复制代码
function Nav() {
  const token = useAuthStore((state) => state.token);
  const user = useAuthStore((state) => state.user);
  const logout = useAuthStore((state) => state.logout);

  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/pay">Pay</Link>

      {!token && <Link to="/login">Login</Link>}
      {user && <span>{user.username}</span>}
      {token && <button onClick={logout}>Logout</button>}
    </nav>
  );
}

这里使用了 JSX 条件渲染:

  • 没有 Token 时显示登录入口。
  • 有用户信息时显示用户名。
  • 有 Token 时显示退出按钮。

点击退出按钮后,logout 清除 store 和本地存储。Zustand 状态发生变化,导航栏会自动重新渲染,不需要手动操作 DOM。

十四、把完整登录流程串起来

到这里,可以把各模块连成一条完整链路。

第一次访问

  1. Zustand 初始化时没有读到本地 Token。
  2. 导航栏显示 Login,不显示 Logout。
  3. 用户访问 /pay
  4. RequireAuth 发现 Token 为空,将用户导航到 /login

登录过程

  1. 用户输入 admin123456
  2. 表单校验通过,提交按钮变为可用。
  3. 登录页调用 POST /api/login
  4. Mock 接口校验账号密码,通过 jwt.sign 签发有效期一天的 Token。
  5. 登录页调用 setAuth,把 Token 和用户信息写入 Zustand 与 localStorage
  6. 页面跳转,导航栏随全局状态更新。

访问受保护资源

  1. 页面调用 getRepo()
  2. Axios 请求拦截器从 localStorage 读取 Token。
  3. 请求头加入 Authorization: Bearer <token>
  4. Mock 接口提取 Token,通过 jwt.verify 验证。
  5. 验证成功后返回 Token 中的用户身份。

刷新页面

  1. React 应用重新创建。
  2. Zustand 初始化时从 localStorage 恢复 Token 和用户信息。
  3. 应用检测到本地 Token,调用受保护接口。
  4. Axios 继续自动携带 Token,服务端再次验证身份。

退出登录

  1. 用户点击 Logout。
  2. logout 删除 localStorage 中的 Token 和用户信息。
  3. Zustand 把 token 重置为空字符串,把 user 重置为 null
  4. 依赖这些状态的组件自动重新渲染。
  5. 再次进入 /pay 时会被路由守卫导航到登录页。

十五、几个容易忽略但很关键的细节

1. JWT 的载荷不等于密文

jwt.sign 为载荷生成签名,用来判断内容是否被篡改。示例把用户名和角色放入载荷,但没有把密码放进去。

2. Bearer 后面有一个空格

请求头格式是:

http 复制代码
Authorization: Bearer <token>

因此服务端检查的是 Bearer ,末尾包含一个空格;提取 Token 时从第 7 个字符之后开始。

3. 缺少请求头是正常输入,不应让服务崩溃

未登录、Token 被清除或请求不是由当前 Axios 实例发出时,都可能没有 Authorization。接口必须先做存在性和格式检查,再调用 jwt.verify

4. Axios 拦截器和 Zustand 各司其职

Zustand 负责让 React 组件共享并响应登录状态;Axios 拦截器负责在网络请求发出前统一添加 Token。二者解决的是不同问题。

5. 路由守卫与接口验签缺一不可

路由守卫避免未登录用户看到受保护页面,接口验签则决定数据是否可以返回。页面跳转只是客户端行为,真正的身份检查仍然在接口侧完成。

6. 退出登录必须清理正确的状态字段

清除 localStorage 只能影响持久化数据,还要通过 Zustand 的 set 清除当前内存状态,并确保写的是已有的 user 字段。字段名拼错会创建一个无关属性,原用户信息不会真正重置。

总结

这个示例虽然页面不多,却覆盖了 React 登录鉴权最核心的一条链路:

text 复制代码
账号密码登录
→ 服务端签发 JWT
→ Zustand 保存全局身份
→ localStorage 持久化
→ Axios 拦截器自动携带 Token
→ 服务端验证 Token
→ RequireAuth 保护路由
→ logout 清除登录状态

理解这条链路后,再看每一段代码就不会是孤立的 API:jwt.sign 负责签发身份凭证,jwt.verify 负责验证,Zustand 负责共享状态,localStorage 负责刷新恢复,Axios 拦截器负责把凭证带到接口,React Router 则负责未登录时的页面访问控制。

登录鉴权不是某一个组件的功能,而是服务端接口、客户端状态、网络请求和页面路由共同协作的结果。

相关推荐
XingXingXxx11 分钟前
Skill 能自己变强吗?从轨迹归纳到文本空间优化的进化之路
后端
光影少年22 分钟前
react navite实现全局弹窗、Toast 组件
前端·react native·react.js
jvmind_dev24 分钟前
频繁 new JedisCluster 之后,对象池为什么回不去了?
java·后端
掘金者阿豪25 分钟前
给你的AI龙虾打造一间像素办公室!Star Office UI部署+公网访问完整教程
后端
学长毕业设计36 分钟前
基于SpringBoot的咖啡馆管理系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
骇客野人38 分钟前
SpringBoot业财一体化系统设计和落地实施方案
java·spring boot·后端
学长毕业设计42 分钟前
基于SpringBoot的教学资源推荐系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
用户850869276151 小时前
一次线上文件上传 Bug 引发的区块链分布式存储深度排查
后端
名字还没想好☜1 小时前
Java Cleaner 实战:替代废弃的 finalize() 做堆外资源清理,以及强引用导致永不回收的坑
java·后端·spring