引言
你有没有遇到过这样的场景:用户登录成功后,刷新页面就丢了登录态?或者每个请求都要手动写一遍 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 项目快速落地。