引言
你有没有遇到过这样的场景:用户登录成功后,刷新页面就丢了登录态?或者每个请求都要手动写一遍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里我们只更新了token和user,如果 store 里还有其他状态(比如count),它们不会被影响。
2. 传入一个函数,基于旧状态计算新状态
set(state => ({ count: state.count + 1 }))当你需要依赖旧状态来更新时,一定要用函数形式。比如计数器、数组追加等场景,避免因闭包导致的状态过期问题。
在我们的store/user.js中,使用的是对象形式,因为setAuth和logout都是整体替换token和user,不依赖旧值。但如果你的 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:验证用户名密码,签 tokenGET /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 动态展示,让用户一眼就知道自己是否登录、是谁。
八、完整流程串联
让我们把整个流程串起来:
- 用户登录:输入用户名密码,提交
/api/login - 后端签发 token:验证通过后,用
jwt.sign生成 token 并返回 - 前端存储状态:
setAuth将 token 和 user 存入 Zustand store 和 localStorage - 后续请求自动带 token:Axios 请求拦截器从 localStorage 读取 token,写入
authorization头 - 后端验证 token:需要鉴权的接口用
jwt.verify验签,返回用户数据 - 路由守卫保护页面:未登录访问敏感页面自动跳转登录
- 导航栏动态展示:根据登录态显示不同内容
整个方案的核心思想是:状态统一管理,请求统一拦截,页面统一守卫。
九、避坑指南与总结
常见坑点
- localStorage 的 XSS 风险:token 存在 localStorage 中,如果网站存在 XSS 漏洞,攻击者可以窃取 token。更安全的做法是使用 httpOnly cookie,但那需要后端配合设置。对于多数内部系统或学习项目,localStorage 足够方便。
- token 过期处理:JWT 有过期时间(expiresIn),过期后后端会返回 401。前端可以在响应拦截器中统一处理 401,比如清除状态并跳转登录页。本文示例没有处理,实际项目中应该加上。
- 刷新页面状态初始化:Zustand 初始化时从 localStorage 读取,这是同步操作,不会导致组件初始渲染时状态缺失。但如果用户信息很大,可以考虑只存 token,用户信息通过接口获取。
- secret 安全:mock 中的 secret 是硬编码的,生产环境一定要放在环境变量中,并且定期更换。
总结
我们用一套简洁的技术栈(React + Zustand + Axios + JWT)实现了完整的登录鉴权流程。核心代码覆盖了:
- JWT 的 sign / verify
- Zustand 全局状态管理(含
set方法解析) - Axios 请求/响应拦截器
- mockjs 本地模拟接口
- 路由守卫保护页面
- 导航栏登录态展示
技术选型没有银弹,但适合自己的就是最好的。Zustand 足够轻,JWT 足够通用,Axios 拦截器足够优雅,这套组合拳非常适合中小型 React 项目快速落地。