写在前面:HTTP 是无状态的------你上一秒刚登录,下一秒刷新页面,服务器就问"你谁?" 这节课就是解决这个问题:JWT(JSON Web Token)登录鉴权 。从登录表单到 token 签发,从 Axios 拦截器自动戴 token 到路由守卫拦截未登录用户,从 Zustand 全局管理登录状态到 localStorage 持久化------一条完整的鉴权链路,一个零件不少。最妙的是,这套流程跟酒店入住一模一样。以下所有代码均来自课堂真实文件。
一、HTTP 是个失忆症患者
HTTP 协议有一个" feature"------无状态(Stateless)。
意思是:每个请求都是独立的,服务器不会记住"你刚才来过"。你上一秒登录成功,下一秒发个请求,服务器一脸懵:你是谁?
readme 的原话:
"HTTP 是无状态 Stateless,用户身份?你是谁?"
这就像一家酒店,前台没有入住系统------你早上办了入住,中午回来想进房间,门卫拦住你:请问您是?
怎么办?给你一张房卡。
二、JWT:酒店的加密房卡
房卡的原理
你办入住时,前台把你的身份信息(姓名、房间号、入住天数)编码进一张房卡。你拿着房卡刷卡进门,门锁解码验证------对了,开门。
JWT 就是这张房卡。
readme 记了完整的流程:
dart
/login admin 123456
{
id: 1,
username: 'admin',
role: 'admin'
}
JSON 身份对象 => JWT(单向操作)=> token 颁发给登录者
每次带上 token => Authorization => decode => JSON 对象
翻译一下:
| 步骤 | 酒店 | JWT |
|---|---|---|
| 办入住 | 前台核验身份证 | /login 接口验证用户名密码 |
| 做房卡 | 把信息编码进房卡 | sign 把 JSON 对象加密成 token |
| 刷房卡 | 门锁解码验证 | verify 解码 token 还原 JSON |
| 每次进门 | 拿着房卡刷一下 | 每次请求带上 Authorization: Bearer <token> |
为什么不用 Cookie/Session?
readme 对比了两种方案:
"cookie/session 登陆方案:cookie 请求每次都会带上 sessionId。sessionId → 内存中 session 会话对象,不太适合分布式。"
Session 的毛病在哪?状态存在服务器内存里。 用户 A 在服务器 1 登录,session 存在服务器 1 的内存。下次请求打到服务器 2,服务器 2 没有这个 session------用户 A 被踢出去了。
JWT 没这个问题:
"jwt 没有这个问题,任何一台服务器签发的 token 都可以再任何一台其他自己的服务器上解码出来,JSON 对象。"
token 自带身份信息,不需要查服务器内存。哪台机器收到都一样------解码就完事了。
JWT 只有两个动作:sign(签发) 和 verify(验证)。
"sign 用户 json 对象(身份信息,json 表现力)。"
三、Zustand:酒店的前台管理系统
身份信息有了,但前端也要管理登录状态------当前用户是谁、有没有登录、怎么退出。这就是 Zustand 的活。
readme 定位了 Zustand 在 React 全家桶中的角色:
"轻量级的状态管理框架。React 全家桶:react + react-router-dom + zustand。"
为什么不用 React 自带的 useContext?readme 给了答案:
"父子传递组件通信状态共享;createContext + useContext 跨层级共享。登录与否、用户信息全局状态、全局共享,跨路由------zustand 统一管理 store 状态仓库。"
useContext 能跨层级共享,但大型项目里 context 嵌套深了会变慢。Zustand 的方案更直接------一个 create 函数,一个全局 store,哪里需要哪里取。
用户状态仓:useAuthStore
store/user.js 就是前台的入住登记簿:
javascript
import { create } from 'zustand'
export const useAuthStore = create(set => ({
// 初始化时从 localStorage 读取------酒店开门营业先查昨日记录
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// 办理入住:存 token + 存用户信息 + 写入 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 });
}
}))
三个关键设计:
- 初始化读 localStorage:页面刷新后 store 会重新创建,但 token 还在 localStorage 里。所以初始化时先从 localStorage 读------相当于酒店每天开门营业,先查昨天的入住记录。
- setAuth 双写:同时更新 store(内存)和 localStorage(持久化)。内存给 React 用,localStorage 给刷新后的下次启动用。
- logout 双清:内存和 localStorage 一起清,干净利落。
Todos 子仓:大型项目的分仓设计
课堂还展示了 Zustand 的分仓模式。store/todos.js:
javascript
import { create } from 'zustand';
export const useTodosStore = create(set => ({
todos: [],
setTodos: ({ todos }) => {
set({ todos });
}
}))
注释写明了定位:
"todos 状态的子仓,大型项目。中小型还是用传统的状态共享。"
用户状态一个仓,Todos 状态一个仓------各管各的,互不干扰。大型项目按业务领域拆 store,小型项目一个仓搞定。
readme 总结了 Zustand 的架构观:
"React App = UI Component + Store"
组件只管渲染,状态归 Store 管。干净。
四、Axios 拦截器:房卡自动刷卡机
现在有了 token,但每个请求都要手动加 Authorization 头?那太烦了。
readme 的方案:
"1. 后端签发的 token 放在 localStorage。2. axios 配置里添加一个 interceptors------request 每个 axios 请求拦下来,config 请求配置对象,config.header'Authorization',每次请求自动戴上。"
api/config.js 就是这台"自动刷卡机":
javascript
import axios from 'axios';
const instance = axios.create({
baseURL: '/api',
timeout: 5000
})
// 请求拦截器:每个请求出发前,自动把 token 塞进 header
instance.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
})
// 响应拦截器:每个响应回来后,自动解包,只返回 data
instance.interceptors.response.use(res => {
return res.data;
})
export default instance;
两个拦截器,各司其职:
请求拦截器------出发前自动戴 token。
不管你从哪个 API 文件发请求,token 都会自动加上。业务代码里不需要写一行 headers 相关的逻辑:
javascript
// api/user.js ------ 干干净净,没有任何 token 相关代码
import axios from './config';
export const login = async (data) => {
const res = await axios.post('/login', data);
return res;
}
javascript
// api/repo.js ------ 同样干净
import axios from './config';
export const getRepo = async () => {
const res = await axios.get('/repo');
return res;
}
响应拦截器------回来后自动拆包。
Axios 原始响应是 { data, status, headers, config } 一大坨。响应拦截器直接 return res.data,业务代码拿到的就是后端返回的纯数据。所以 repo.js 和 user.js 里的 res 已经是业务数据了。
readme 对拦截器的总结:
"axios 默默的做了很多。"
一句"默默的做了很多",道尽了拦截器的价值------业务代码写起来干净,是因为脏活在拦截器里做完了。
五、路由守卫:电梯口的保安
有些页面不需要登录就能看(首页),有些页面必须登录才能进(支付页)。怎么管?
在路由上放一个保安。
App.jsx:路由表 + 懒加载
jsx
import React, { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import RequireAuth from './components/RequireAuth'
import Nav from './components/Nav';
import { getRepo } from './api/repo';
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
function App() {
useEffect(() => {
(async () => {
const res = await getRepo();
console.log(res);
})();
}, []);
return (
<Router>
<Nav />
<Suspense fallback={<div>loading...</div>}>
<Routes>
<Route path="/" element={<Home />}/>
<Route path="/login" element={<Login />}/>
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
}/>
</Routes>
</Suspense>
</Router>
)
}
三个设计要点:
1. 懒加载(lazy + Suspense)
Home、Login、Pay 三个页面都是 lazy(() => import(...)),只有访问到对应路由时才加载 JS。配 <Suspense fallback={<div>loading...</div>}>,加载时显示 loading。
2. 路由表清晰
| 路由 | 页面 | 需要登录? |
|---|---|---|
/ |
Home | 否 |
/login |
Login | 否 |
/pay |
Pay | 是 |
3. RequireAuth 包裹受保护路由
<Pay /> 被 <RequireAuth> 包了一层。想进 Pay?先过保安。
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;
逻辑只有三步:
- 从 Zustand store 取 token
- 没 token → 重定向到
/login - 有 token → 放行,渲染子组件
<Navigate to="/login" replace /> 里的 replace 很关键------替换历史记录而不是 push。这样用户按浏览器后退键不会回到受保护页面又被弹回登录页。
这就是路由守卫 ------不需要在每个页面里写 if (!token) redirect,在路由层统一拦截。
六、登录页:办入住的完整流程
Login.jsx 是今天最复杂的文件,也是整个鉴权链路的起点。
第一步:表单验证
jsx
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]);
验证规则用 useEffect 监听 formData 变化,实时校验。用户名至少 3 位,密码至少 6 位。校验不通过,登录按钮 disabled。
第二步:记录来源页面
jsx
const location = useLocation();
const from = location.state?.from || '/';
用户本来想去 /pay,被保安拦到 /login。登录成功后应该回到 /pay,而不是回首页。location.state?.from 就是记录"你从哪来"的。
第三步:发起登录请求
jsx
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('登录失败');
}
};
登录成功的流程:
- 调
login(formData)发 POST 请求 - 后端返回
{ code: 0, token, user } - 调
setAuth存 token 和 user(同时写 Zustand + localStorage) navigate(from, { replace: true })跳回原来想去的页面
replace: true 的作用------登录页不应该出现在浏览器历史里。用户登录后按后退,不应该回到登录页。
Zustand 的精准取值
注意这行:
jsx
const setAuth = useAuthStore(state => state.setAuth);
不是 const { setAuth } = useAuthStore(),而是用选择器函数只取 setAuth。这样只有 setAuth 变化时组件才重新渲染,token 变了不会触发这个组件 re-render------精准订阅,避免无谓渲染。
七、导航栏:根据登录状态显示不同 UI
Nav.jsx 是用户感知最直接的地方------登录了显示用户名和退出按钮,没登录显示登录链接。
jsx
import { Link } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function Nav() {
const token = useAuthStore(state => state.token);
const logout = useAuthStore(state => state.logout);
const user = useAuthStore(state => state.user);
const handleLogout = () => {
logout();
}
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.username}!</a>}
{token && <button onClick={handleLogout}>Logout</button>}
</nav>
)
}
三个状态判断:
| 条件 | 显示 |
|---|---|
!token |
Login 链接 |
user |
用户名 |
token |
Logout 按钮 |
Zustand 的好处在这里体现得淋漓尽致------Nav 组件不需要通过 props 接收登录状态,不需要 Context Provider 包裹,直接 useAuthStore 取值就行。跨路由、跨组件,一处更新,处处同步。
退出按钮调 logout(),Zustand store 清空 token 和 user,同时清 localStorage。整个应用瞬间回到未登录状态------Nav 刷新(Login 链接出现、Logout 消失),RequireAuth 拦截(再访问 /pay 跳到登录页)。
八、MockJS:没有后端也能玩鉴权
readme 提到了一个关键配置:
"mockjs 大前端鉴权。axios baseURL,vite mockjs 插件 /api/。"
config.js 里 baseURL: '/api'------所有请求都走 /api 前缀。配合 Vite 的 MockJS 插件,前端开发时不需要真后端,MockJS 拦截 /api 开头的请求返回假数据。
这意味着------前端鉴权流程可以在没有后端的情况下完整跑通。 登录接口是 mock 的,token 是 mock 签发的,但前端的整套鉴权逻辑(拦截器、路由守卫、状态管理)全是真的。等后端就绪,换掉 baseURL 就能无缝对接。
这就是 readme 说的"大前端"------前后端并行开发,鉴权不阻塞。
九、一张图看懂完整鉴权链路
css
用户访问 /pay
↓
RequireAuth 检查 token(从 Zustand store 取)
↓
有 token ──────────────────→ 渲染 Pay 页面 ✓
没 token ↓
↓
Navigate to /login(记录来源 from=/pay)
↓
Login 页面:表单验证 → 发起 login 请求
↓
Axios 请求拦截器:自动戴上 Authorization: Bearer <token>
↓
后端验证 → 返回 { code: 0, token, user }
↓
Axios 响应拦截器:解包返回 res.data
↓
setAuth({ token, user }):写入 Zustand + localStorage
↓
navigate(from) → 回到 /pay
↓
RequireAuth 再次检查 token → 有 → 放行 ✓
↓
后续每个请求 → Axios 拦截器自动戴 token
↓
用户点 Logout → logout() 清空 store + localStorage
↓
所有受保护页面立即不可访问
十、状态管理的分层哲学
今天的代码体现了 React 状态管理的三层设计:
| 层级 | 技术 | 存什么 | 生命周期 |
|---|---|---|---|
| 组件局部状态 | useState |
表单数据、错误信息 | 组件卸载即销毁 |
| 全局共享状态 | Zustand store | token、user、todos | 应用运行期间 |
| 持久化存储 | localStorage | token、user | 浏览器关闭后仍在 |
Login.jsx 里的 formData 和 errors 用 useState------表单提交完就扔了,不需要全局共享。
token 和 user 用 Zustand------多个组件都要用(Nav、RequireAuth、Login),必须全局共享。
token 和 user 同时写 localStorage------刷新页面后 Zustand store 会重建,从 localStorage 恢复数据。
三层各司其职,不越界。表单数据不会进 store,store 数据不会只存内存。什么数据放什么层,这是架构设计的基本功。
PS:HTTP 不认识你,但 JWT 记得你。下次有人问"前端鉴权怎么做",把这篇文章甩给他。