React + Zustand + JWT 前端权限体系完整实现:从登录鉴权到路由守卫全流程拆解

前言

这是一套最小可运行的前端 JWT 权限系统 Demo,完整覆盖「登录 → Token 持久化 → 请求自动带鉴权头 → 路由守卫拦截 → 后端 Token 校验」全链路。本文以复习为目的,逐文件逐行拆解逻辑,对所有陌生 API 做单独解析,并整理实战中所有踩坑点。

技术栈:React 18 + React Router v6 + Zustand 状态管理 + Axios 封装 + jsonwebtoken + vite-plugin-mock 模拟后端

核心流程总览

  1. 用户访问受保护页面 /pay
  2. 路由守卫 RequireAuth 检测无 Token,跳转登录页并携带「原目标地址」
  3. 用户提交账号密码,Mock 后端签发 JWT
  4. 前端将 Token + 用户信息存入 Zustand + localStorage 持久化
  5. 跳转回用户原本想访问的页面
  6. 后续所有请求由 Axios 拦截器自动带上 Authorization: Bearer Token
  7. Mock 后端校验 Token 合法性,合法则返回业务数据

一、项目结构与文件职责

arduino 复制代码
src/
├── api/
│   ├── config.js      // Axios 实例创建 + 请求/响应拦截器
│   ├── repo.js        // 受保护业务接口
│   └── user.js        // 登录接口
├── component/
│   ├── Nav.jsx        // 顶部导航栏
│   └── RequireAuth.jsx // 路由守卫组件
├── pages/
│   ├── Home.jsx       // 公开首页
│   ├── Login.jsx      // 登录页(表单验证 + 登录逻辑)
│   └── Pay.jsx        // 受保护页面
├── store/
│   └── user.js        // Zustand 全局状态 + localStorage 持久化
├── App.jsx            // 路由配置 + 路由懒加载
mock/
└── user.js            // 模拟后端:JWT 签发 + 校验

二、核心模块逐行拆解

1. 路由入口:App.jsx

作用:配置路由表,实现路由懒加载,对受保护路由包裹路由守卫。

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

// 路由懒加载
const Home = lazy(() => import('./pages/Home'))
const Login = lazy(() => import('./pages/Login'))
const Pay = lazy(() => import('./pages/Pay'));

function App(){
  const token = useAuthStore(state => state.token);

  return (
    <>
      <Router>
        <Nav/>
        {/* 懒加载降级:组件加载完成前显示 loading */}
        <Suspense fallback={<div>loading...</div>}>
          <Routes>
            <Route path='/' element={<Home/>}></Route>
            <Route path='/Login' element={<Login/>}></Route>
            {/* 受保护路由:用 RequireAuth 包裹 */}
            <Route path='/pay' element={<RequireAuth><Pay/></RequireAuth>}></Route>
          </Routes>
        </Suspense>
      </Router>
    </>
  )
}
export default App;

关键 API 解析

  • React.lazy(() => import('./pages/xxx')) 路由懒加载,只有访问到该路由时才会加载对应组件的代码,减少首屏包体积。 必须配合 <Suspense> 使用,否则会报错。
  • <Suspense fallback={<div>loading...</div>}> 懒加载组件加载过程中,显示 fallback 里的降级内容,避免白屏。
  • <RequireAuth><Pay/></RequireAuth> 高阶组件包裹模式:把受保护页面当作 children 传入守卫组件,由守卫决定是否渲染页面。

2. 路由守卫:RequireAuth.jsx

作用:拦截未登录用户,强制跳转登录页,同时记录用户原本想访问的地址。

javascript 复制代码
import { Navigate } from 'react-router-dom'
import { useAuthStore } from '../store/user'

export default function RequireAuth({ children }) {
  const token = useAuthStore(state => state.token)
  
  // 没有 token → 跳转登录页
  if (!token) {
    return <Navigate to='./login' replace />
  }
  // 有 token → 渲染目标页面
  return <>{children}</>
}

关键 API 解析

  • <Navigate to='./login' replace /> 编程式导航的组件写法,replace: true 表示替换浏览器历史记录。 不写 replace 的话,登录页会留在历史栈里,用户点返回会又回到登录页;用 replace 后,登录页被替换掉,返回直接回到上上个页面。

3. 登录页:Login.jsx

作用:表单校验、发起登录请求、存储登录状态、跳转回原目标页面。

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

关键 API 解析

  • location.state?.from

    • location.state:React Router 路由跳转时携带的额外数据对象
    • ?. 可选链操作符:如果 state 不存在,不会报错,直接返回 undefined
    • 业务含义:用户被路由守卫拦下来时,守卫会把「原本想去的地址」放在 state.from 里传过来
  • navigate(from, { replace: true }) 登录成功后跳回原目标页面;replace: true 把登录页从历史记录中抹掉,避免用户点返回又回到登录页。

  • 表单验证思路 把校验逻辑放在 useEffect 里,依赖 formData,输入变化时实时校验,计算出错误信息和按钮可用状态。


4. 全局状态:Zustand + localStorage 持久化

作用:管理登录态(token + 用户信息),同时同步到 localStorage 实现刷新页面不丢失登录。

javascript 复制代码
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 });
  }
}))

核心逻辑解析

  1. 为什么 localStorage 存取 user 要转 JSON? localStorage 只能存字符串,不能直接存 JS 对象。

    • 存的时候:JSON.stringify(user) → JS 对象转 JSON 字符串

    • 读的时候:JSON.parse(字符串) → JSON 字符串转回 JS 对象

    token 本身就是字符串,所以直接存取,不需要转 JSON。

  2. Zustand create 函数 传入一个函数,函数接收 set 方法用来更新状态,返回的对象就是全局状态。 组件中使用:const token = useAuthStore(state => state.token),按需订阅状态。

  3. 可优化点 如果 localStorage 里的 user 是非法 JSON,JSON.parse 会直接报错导致页面白屏。生产环境建议加 try-catch 兜底:

    javascript 复制代码
    user: (() => {
      try {
        const str = localStorage.getItem('user');
        return str ? JSON.parse(str) : null;
      } catch {
        return null;
      }
    })()

5. Axios 封装:请求 / 响应拦截器

作用:统一给所有请求加鉴权头,统一处理响应数据格式。

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

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

// 请求拦截器:请求发出去之前执行
instance.interceptors.request.use(config => {
  const token = localStorage.getItem("token");
  // 只有 token 存在才加请求头!避免 null 导致非法字符报错
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
})

// 响应拦截器:拿到响应后统一剥掉外层 data
instance.interceptors.response.use(res => {
  return res.data
})

export default instance;

关键逻辑解析

  1. 请求拦截器为什么要先判断 token? 如果 token 为 null,直接赋值会变成 Authorization: Bearer null,后端会报「请求头非法字符」错误。 所以没有 token 就不要设置这个请求头
  2. Bearer ${token} 格式 这是 HTTP 鉴权的标准格式,Bearer 后面必须跟一个空格,再拼接 token 字符串。
  3. 响应拦截器的作用 axios 响应默认包在 res.data 里,业务接口真正需要的数据在第二层。 统一在拦截器里 return res.data,组件里拿到的就是直接的业务数据,不用每次都写 .data

6. API 层封装

6.1 受保护接口:repo.js

javascript 复制代码
import axios from "./config"; // 用封装好的实例,走拦截器

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

因为用了封装的 axios 实例,会自动走请求拦截器加 Authorization 头,也自动走响应拦截器剥 data。

6.2 登录接口:user.js

javascript 复制代码
import axios from "axios"; // 登录不需要 token,直接用原生 axios

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

登录接口本身不需要鉴权,所以不用走拦截器;同时因为没走响应拦截器,所以要手动 .data 取数据。


7. Mock 后端:JWT 签发与校验

作用:模拟后端接口,实现 JWT 的签发(登录)和校验(业务接口)。

javascript 复制代码
import jwt from "jsonwebtoken";
const secret = 'secret819!$' // 自定义密钥,签发和校验必须一致

export default [
  // 受保护接口:校验 token
  {
    url: '/api/repo',
    method: 'get',
    response: req => {
      const auth = req.headers['authorization'];
      // 先判断请求头是否存在,不存在直接返回 401
      if (!auth) {
        return { code: 401, msg: '缺少 Authorization 请求头' };
      }
      const token = auth.split(' ')[1];

      try {
        // 校验 token 合法性、是否过期
        let decoded = jwt.verify(token, secret);
        return {
          code: 0,
          data: decoded.user
        };
      } catch (error) {
        return {
          code: 401,
          msg: 'Invalid token'
        };
      }
    }
  },

  // 登录接口:签发 token
  {
    url: '/api/login',
    method: 'post',
    response: (req, res) => {
      const body = req.body;
      // 签发 JWT
      const token = jwt.sign(
        {
          user: body.username,
          role: 'admin'
        },
        secret,
        { expiresIn: 86400 } // 有效期:秒
      );

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

核心概念解析

  1. secret 密钥 不是库内置的固定值,是自定义的字符串,相当于一把密码锁。

    • jwt.sign(数据, secret):用密钥加密生成 token

    • jwt.verify(token, secret):用同一个密钥解密校验

    铁律:签发和校验的 secret 必须完全一模一样,一个字符都不能差,否则直接校验失败。

  2. jwt.sign(payload, secret, options)

    • payload:要存在 token 里的数据(用户名、角色等)
    • expiresIn:token 有效期,单位秒
  3. 为什么校验要包 try-catch? token 篡改、过期、格式错误,jwt.verify 都会直接抛出异常。 不包 try-catch 会导致 mock 服务崩溃,前端收到 Network Error。

  4. 经典踩坑 直接写 req.headers['authorization'].split(' ')[1],如果请求没带 Authorization 头,undefined.split() 直接报错。必须先判断 auth 是否存在,再执行 split


8. 导航组件:Nav.jsx

作用:根据登录态显示不同按钮,实现登录 / 退出入口。

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

export default function Nav(){
  const token = useAuthStore(state => state.token)
  const logout = useAuthStore(state => state.logout)

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

  return (
    <nav style={{padding: 0, borderBottom: '1px solid #ccc'}}>
      <Link to="/">Home</Link>
      <br />
      <Link to="/pay">Pay</Link>
      <br />
      {/* 未登录显示登录按钮,已登录显示退出按钮 */}
      {!token && <Link to="/login">Login</Link>}
      {token && <button onClick={handleLogout}>Logout</button>}
    </nav>
  )
}

三、全链路时序走一遍

  1. 访问受保护页面 :用户点 /payRequireAuth 读取 Zustand 中的 token → 无 token → <Navigate to="/login" replace />,同时携带 state: { from: '/pay' }
  2. 进入登录页Login 组件读取 location.state?.from → 记录目标地址 /pay
  3. 提交登录 :输入账号密码 → 调用 login(formData) → 请求 /api/login
  4. 后端签发 Token :mock 接收请求 → jwt.sign() 生成 token → 返回 {code:0, token, user}
  5. 前端存储登录态setAuth() → 同时写入 Zustand 内存 + localStorage 持久化
  6. 跳转原页面navigate('/pay', { replace: true })
  7. 访问业务接口 :页面调用 getRepo() → 请求拦截器读取 localStorage 的 token → 自动加上 Authorization: Bearer xxx
  8. 后端校验 Token :mock 读取请求头 → jwt.verify() 校验通过 → 返回业务数据
  9. 前端渲染数据:响应拦截器剥掉外层 data → 组件拿到数据渲染

四、踩坑复盘与避坑清单

1. JS 语法坑

  • undefined.split() 直接报错:访问对象属性前一定要先判断是否存在
  • ❌ localStorage 直接存对象:会变成 [object Object],必须 JSON.stringify
  • JSON.parse(null) 脏数据崩溃:本地存储数据不可信,建议加 try-catch

2. Axios 拦截器坑

  • ❌ token 为 null 也设置 Authorization 头:会生成 Bearer null,触发非法字符报错
  • ❌ 忘记 return config:请求会直接卡住发不出去
  • ❌ 登录接口用封装实例:登录不需要 token,用原生 axios 更清晰

3. React Router 坑

  • ❌ 跳转登录页不用 replace:用户点返回会又回到登录页
  • ❌ 不记录 from 地址:登录成功只能跳首页,体验差

4. Mock 后端坑

  • ❌ jwt.verify 不包 try-catch:token 无效直接服务崩溃,前端 Network Error
  • ❌ secret 签发和校验不一致:永远 401 校验失败
  • ❌ 不判空直接 split 请求头:不带 token 的请求直接打挂服务

5. 开发工具坑

  • ❌ CSS Module 文件不存在:import 了不存在的 .module.css 会直接编译失败
  • ❌ Vite HMR 热更新错乱:改文件后出现匿名 <anonymous> 报错,Ctrl+F5 硬刷新或重启 vite 即可

五、总结

这套权限系统的核心可以浓缩为三句话:

  1. 登录拿 token,存本地
  2. 请求拦截器统一加 Authorization 头
  3. 路由守卫 + 后端校验双重拦截未登录请求

整个链路中最容易出问题的地方集中在「边界判断」:token 有没有、请求头在不在、数据格式对不对。把所有边界情况都做了兜底,整个权限系统就会非常稳定。

适合作为 React 权限系统的入门模板,在此基础上可以扩展角色权限、动态路由、刷新 token 等更复杂的能力

相关推荐
汉堡大王952717 分钟前
面试必考:手写代码 new 做了什么?从原理到实现全解析
前端·javascript·面试
MindUp22 分钟前
企业私有化文件管理系统选型实录:从部署架构到AI能力的技术调研笔记
人工智能·笔记·架构
悟天特斯31 分钟前
智慧楼宇楼宇自控系统:从孤立控制到协同优化的BAS架构演进
人工智能·物联网·架构
BillKu41 分钟前
TypeScript中,字符串字面量联合类型(Union Type)、enum的用法说明
前端·javascript·typescript
jayson.h43 分钟前
PDF 合并+添加页码 相关库、类、函数
开发语言·前端·python
念何架构之路1 小时前
beego总体架构与工程结构
java·架构·beego
fb_123451 小时前
OpenStack架构深度解析
架构·openstack
慧一居士2 小时前
Sass和Less功能、使用场景、用法对比
前端·css·less·sass
代码方舟2 小时前
零信任架构实战:基于天远车辆过户详版查询构建自动化车辆估值网关
运维·人工智能·架构·自动化