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
模拟登录接口接收用户名和密码。账号不匹配时返回错误;校验通过后,通过 jsonwebtoken 的 sign 方法签发 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 的三个参数分别表示:
- 载荷:需要写入 Token 的身份信息,这里包含用户名和角色。
- 签名密钥:签发和验证 Token 时使用同一个值。
- 配置项:
expiresIn: 86400表示 Token 的有效期为 86400 秒,也就是一天。
登录成功后,响应中既返回 user,也返回 token。user 适合直接用于界面展示,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.code、res.user 和 res.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 中,其他组件很难共享。虽然可以使用 createContext 和 useContext 跨层传递,但这里选择更轻量的 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 包含两类内容:
- 状态:
token和user。 - 动作:
setAuth和logout。
为什么 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("登录失败");
}
};
登录成功后的两个动作很关键:
- 调用
setAuth,同时更新 Zustand 和localStorage。 - 调用
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。
十四、把完整登录流程串起来
到这里,可以把各模块连成一条完整链路。
第一次访问
- Zustand 初始化时没有读到本地 Token。
- 导航栏显示 Login,不显示 Logout。
- 用户访问
/pay。 RequireAuth发现 Token 为空,将用户导航到/login。
登录过程
- 用户输入
admin和123456。 - 表单校验通过,提交按钮变为可用。
- 登录页调用
POST /api/login。 - Mock 接口校验账号密码,通过
jwt.sign签发有效期一天的 Token。 - 登录页调用
setAuth,把 Token 和用户信息写入 Zustand 与localStorage。 - 页面跳转,导航栏随全局状态更新。
访问受保护资源
- 页面调用
getRepo()。 - Axios 请求拦截器从
localStorage读取 Token。 - 请求头加入
Authorization: Bearer <token>。 - Mock 接口提取 Token,通过
jwt.verify验证。 - 验证成功后返回 Token 中的用户身份。
刷新页面
- React 应用重新创建。
- Zustand 初始化时从
localStorage恢复 Token 和用户信息。 - 应用检测到本地 Token,调用受保护接口。
- Axios 继续自动携带 Token,服务端再次验证身份。
退出登录
- 用户点击 Logout。
logout删除localStorage中的 Token 和用户信息。- Zustand 把
token重置为空字符串,把user重置为null。 - 依赖这些状态的组件自动重新渲染。
- 再次进入
/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 则负责未登录时的页面访问控制。
登录鉴权不是某一个组件的功能,而是服务端接口、客户端状态、网络请求和页面路由共同协作的结果。