HTTP 明明无状态,登录态怎么就保住了——React + Zustand + JWT 鉴权全流程拆解
上一课(8.12)我把 axios + mockjs 的前端接口工程搭了起来,前端终于不用傻等后端就能跑通请求。但那一课留了个口子:所有接口都是"裸奔"的,谁都能调,没人问"你是谁"。
这一课就把这个口子缝上。主题只有一个——登录鉴权。技术栈是 React + react-router-dom + Zustand + axios + mockjs + jsonwebtoken,一整套前端自己就能跑起来的鉴权闭环。
一、HTTP 是无状态的,那我刷新一次页面,"我是谁"就丢了
首先先了解一下 HTTP 的关键特质:Stateless(无状态)。
Stateless(无状态协议):HTTP 协议本身不记录"上一次请求是谁发的"。每一个请求对服务器来说都是新面孔,它不会因为你在 1 秒前刚登录过,就默认这次的请求还是你。
这句话听起来很反直觉。我打开一个网页,登录一下,然后点别的页面——我明明"还是我",怎么 HTTP 就记不住?
答案就是:HTTP 从来不记。记你的是浏览器,或者说是你每次请求时偷偷夹带的那张"身份证"。
那"夹带身份证"这件事怎么做?方案其实有两条路:
- 老路:cookie + session。浏览器每次请求自动带上 cookie,cookie 里存一个 sessionId,服务器拿 sessionId 去自己内存里找 session 对象。
- 新路:JWT。登录成功后服务器发一张加密过的 token,之后每次请求把它放进
Authorization请求头里带过去。
老师明确说,登录现在"一般用的都是 JWT,JSON Web Token"。cookie/session 那条路为什么被冷落?我整理了一下笔记里的两条理由:
- 内存压力:用户一多,服务器内存里要存一大堆 session 对象,每个 sessionId 都得对得上号。
- 不适合分布式:sessionId 是服务器内存里的"一个抽屉",如果你这台服务器签发的 sessionId 被另一台服务器收到,另一台服务器内存里压根没这个抽屉,就找不到用户。要做分布式就得把 session 挪到 Redis 之类的地方共享,工程复杂度立刻上来。
JWT 的思路反过来——服务器不再记人,客户端自己带身份证。服务器只守一个 secret,任何一台服务器拿到 token 都能自己验签、自己 decode 出 JSON 对象。分布式部署天然友好。
二、JWT 是一张加密过的身份证
2.1 两个动作:sign 和 verify
笔记里把 JWT 的全部动作压缩成两个字:
- sign(签发):登录成功后,服务器把用户的 JSON 身份对象(比如
{ user: 'caohui', role: 'user' })用 secret 加盐,加密成一段字符串 token,发给客户端。 - verify(验证):之后客户端带着 token 来访问受保护接口,服务器用同一个 secret 解开 token,拿到里面的 JSON 对象,就知道"你是谁"。
加盐(secret):JWT 不是加密算法意义上的"加密",更准确说是"签名"。服务器用一个只有自己知道的 secret 给 token 盖章。任何人拿到 token 都能读出 payload(这部分是 base64),但改不了 payload——改了签出来的签名就对不上。secret 越长越随机越安全。
2.2 cookie/session vs JWT:一张图就懂
笔记里有一段对比,我把它整理成一张表:
| 维度 | cookie/session | JWT |
|---|---|---|
| 身份存哪 | 服务器内存 | 客户端(localStorage / cookie) |
| 谁来记人 | 服务器 | 客户端自己带 |
| 分布式 | 不友好,sessionId 跨服务器找不到 | 友好,任何服务器有 secret 就能验 |
| 服务端内存 | 占用大 | 几乎不占(只验签不存状态) |
2.3 sign 的现场:mock/user.js
服务端这边的 sign 动作,用 mock 模拟出来了。代码在 mock/user.js:
import jwt from 'jsonwebtoken';
const secret = 'secret819!$';
export default [
{
url: '/api/login',// ?
method: 'POST',
timeout: 2000,
response: ({body })=> {
console.log(body);
if(body.username !== 'admin' || body.password !== '123456'){
return {
code:-1, // 出现错误
msg:'用户名或密码错误'
}
}
// 服务器端需要给用户颁发 token
// token 中要带上 user 的信息
// jwt json 格式, web 端(stateless),生成 token
// jwt 的生成有加密算法,颁发的令牌要 加盐(只有自己知道的安全锁) 秘密的 key
const token = jwt.sign(
{
user:body.username,
role:'user'
},
secret,
{
expiresIn:'86400s'
}
);
return {
code:0, // 未出现错误
user:{
username:body.username
},
token:token
}
}
},
读这段代码有几处我标出来,怕以后自己忘:
- 演示账号是
admin/ 123456。 jwt.sign的第一个参数是 payload——要放进去的"身份信息"。这里放了user和role。- 第二个参数
secret是加盐的密钥,只有服务器自己知道。 - 第三个参数
{ expiresIn: '86400s' }是过期时间,86400 秒 = 24 小时。token 是会过期的,过期了 verify 会失败,用户就得重新登录。 - 返回里
code: 0表示未出错,code: -1表示出错。这是约定俗成的前后端状态码。
2.4 verify 的现场
/api/repo 是一个受保护接口,它要做的事就是"拿 token 验签,验过了就把用户名还给前端":
{
url: '/api/repo',
method: 'GET',
timeout: 2000,
response: ({headers })=> {
const token = headers['authorization'].split(' ')[1];
console.log(token);
try{
const decoded = jwt.verify(token,secret);
console.log(decoded);
return {
code:0, // 未出现错误
data:decoded.user
}
}catch(err){
return {
code:401, // 出现错误
msg:'token 验证失败,无效的 token'
}
}
return {
code:0, // 未出现错误
token
}
}
}
]
这行值得停下来看一眼:
const token = headers['authorization'].split(' ')[1];
为什么是 .split(' ')[1]?因为前端发过来的 Authorization 头长这样:
Authorization: Bearer xxxx.yyyy.zzzz
前面那个 Bearer 是一个固定前缀,告诉服务器"后面这串是 Bearer Token"。所以服务器要先用空格切,取第二段才是真正的 token。
Bearer Token:RFC 6750 定义的一种 token 类型。
Bearer字面意思是"持票人"——谁手里有这张票,谁就是合法身份。它不绑死客户端设备,任何端只要拿到这张票就能用,所以 token 本身的安全性至关重要,丢了等于身份丢了。
三、Zustand:把登录态变成全站可读
3.1 为什么不用 Context
React 里管全局状态,老办法是 createContext + useContext。这个老办法有两个层次:
- 父子传递:组件通信,状态共享。
- 跨层级共享:用
createContext + useContext。
但登录这种状态,我们的要求是"全局共享,跨路由共享"——首页要读、支付页要读、导航栏要读,还要在路由切换时不丢。这种情况下 Context 的写法会变得啰嗦,所以引入了 Zustand。
Zustand:React 生态里的轻量级状态管理库。德语里 "Zustand" 就是"状态"的意思。它的核心就一个
create函数,返回一个自定义 hook,组件通过这个 hook 订阅状态,状态变了组件自动重渲染。没有 Provider 包裹,没有模板代码。
React 有了 zustand 之后开发就有了一个精美的公式:
React App = UI Component + Store
3.2 store 文件的设计
状态管理会生成一个 store 文件夹,称为"状态仓库"。这个项目里有两个 store:
- store/use.js:用户身份仓库,存 token 和 user。
- store/todos.js:todos 状态的子仓,演示用。
use.js 是这一课的重点:
// 全局负责 提供用户身份状态,存储用户身份状态
// 创建 store
import { create } from 'zustand';
// 采用 hooks 编程,自定义 hooks
export const useAuthStore = create((set) => ({
// 负责定义状态(属性),初始值
// set 是修改状态的方法
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
// action 行为方法, 负责定义修改状态的方法,要使得外部只能用 set 方法修改,而不能随意的直接修改状态
// 利用 localStorage 存储 token 和 user 信息
setUser: (token, user) => {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
set({ token, user });
},
// 退出登录
logout: () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
// hook 响应式更新状态,通知组件重新渲染
set({ token: '', user: null });
},
}));
读这个 store 我学到的几件事:
1. 初始值从 localStorage 读:token: localStorage.getItem('token') || ''。这一行解决了我在第一节问的那个问题——HTTP 无状态,那我刷新页面,"我是谁"就丢了吗?答案:只要 token 写进了 localStorage,刷新后 store 重新初始化时会把 token 读回来。**localStorage 是浏览器本地存储,关页面、刷新页面都不丢,只有手动清掉或过期才丢。**所以登录态就这样"保住"了——不是 HTTP 记住了我,是浏览器替我记着。
2. set 是修改状态的唯一入口:注释里那句"要使得外部只能用 set 方法修改,而不能随意的直接修改状态"是 Zustand 的纪律。组件不能直接 store.token = 'xxx',必须通过 setUser 这种 action 来改,状态才会触发响应式更新。
3. setUser 同步写 localStorage 和 set:这一步是双写——既要持久化(localStorage),又要通知组件(set)。少写任何一个都不行:只写 localStorage,组件不会重渲染;只 set,刷新就丢。
4. logout 做两件事:清 localStorage,再 set 成空状态。这正是退出登录该有的样子——不光把内存里的状态清了,浏览器本地的身份证也得撕掉。
3.3 todos.js:什么时候才上 Zustand
笔记里有一段很诚实的注释:
// todos 状态的子仓,大型项目才需要使用 zustand 进行状态管理
// 中小型项目还是使用传统的 createContext 和 useContext 进行状态管理,不要引入 zustand 这个包增添负责度
import { create } from 'zustand';
// create 是一个高阶函数,接受一个函数作为参数,返回一个函数
// 这个函数就是 store 的实例,可以用来获取状态和修改状态
export const useTodosStore = create((set) => ({
todos: [],
// action
setTodos: (todos) => set({ todos }),
}));
高阶函数:接收函数作为参数,或返回函数的函数。Zustand 的
create接收一个函数(这个函数接收set,返回一个状态对象),返回一个新的函数(也就是我们用的useAuthStore这个 hook)。所以create是一个高阶函数。
老师的观点很务实:中小型项目别一上来就 Zustand,Context 够用。Zustand 是大型项目里跨路由、跨组件状态一多,Context 模板代码写烦了之后的工具。这种"什么场景用什么工具"的判断,比工具本身更重要。
四、axios 拦截器:每次请求自动塞 token
4.1 拦截器是什么
axios "默默地做了很多"。拦截器就是这种"默默"的代表——它会在每个请求发出去之前、每个响应回来之后,自动插一脚。
笔记里给的两个动作是:
- request 拦截器:拦下每个请求,给请求配置对象(config)补上
Authorization头。之后业务代码里写axios.get('/api/repo')时根本不用手动带 token,拦截器会替你加。 - response 拦截器:拦下每个响应,把
response.data剥出来直接返回。这样业务代码里await instance.get(...)拿到的就是后端真正返回的数据,不用再.data一次。
4.2 config.js 全文
代码在 api/config.js:
import axios from 'axios';
// 单例模式
const instance = axios.create({
baseURL: '/api',
timeout: 5000,
});
// token 的持久化存储,因为每次请求都需要携带
// 拦截每个请求 request ,使用一个配置函数,给每个请求添加 token
instance.interceptors.request.use(
config => {
const token = localStorage.getItem('token');
if(token){
config.headers['authorization'] = `Bearer ${token}`;
}
// 请求配置对象
return config;
}
);
// 拦截每个响应 response ,使用一个配置函数
instance.interceptors.response.use(
response => {
return response.data;
}
);
export default instance;
几个点我自己重看时想清楚了:
1. 单例模式:注释写的"单例模式"是指 axios.create 只创建一个 instance,整个项目都用这一个。baseURL /api 设一次,所有请求路径前自动补 /api。
单例模式:一个类只有一个实例,并提供一个全局访问点。这里
instance就是这个全局唯一的 axios 实例,所有 api 文件都import它来发请求。这样 baseURL、拦截器、timeout 这些配置只写一次,全项目生效。
2. token 从 localStorage 读,不从 store 读:拦截器里 localStorage.getItem('token') 而不是 useAuthStore.getState().token。这是一个细节——拦截器在 axios 之外,不属于 React 组件树,直接读 localStorage 反而更简单。store 和 localStorage 里的 token 是同步双写的,读哪边都对。
3. Bearer ${token} 模板字符串:拼出来的就是 Bearer xxxx.yyyy.zzzz,正好对应第二节里服务器 .split(' ')[1] 取的那段。
4. response 拦截器只返回 data:业务代码里 await instance.post('/login', data) 拿到的直接是 { code, user, token },不用 res.data.token,直接 res.token。
4.3 api 文件:业务层只写"调哪个接口"
有了 config.js 托底,业务层的 api 文件就非常薄了。api/user.js 就是一个登录接口:
import instance from './config';
export const login = async (data) => {
const res = await instance.post('/login', data);
return res;
};
api/repo.js 就是一个受保护接口:
import instance from './config';
export const getRepo = async () => {
const res = await instance.get('/repo');
console.log(res);
return res.data;
}
注意这里有个细节我自己也卡了一下:getRepo 里 return res.data,但 config.js 的 response 拦截器已经把 response.data 返回了,那这里 res.data 取的其实是后端返回的 { code:0, data:'admin' } 这个对象里的 .data 字段——也就是真正的用户名字符串 'admin'。两层 .data 不是同一个 .data,第一层是 axios 的,第二层是后端业务约定的。我一开始看混了,记一下。
五、路由守卫 RequireAuth:没 token 就别想进 /pay
鉴权不只是"请求里带 token",还有"页面级别不让你进"。这就靠路由守卫。
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/use';
function RequireAuth({ children }) {
const token = useAuthStore((state) => state.token);
if (!token) {
return <Navigate to="/login" replace />;
}
return (
<div>
{children}
</div>
);
}
export default RequireAuth;
路由守卫:在用户进入某个路由前检查权限的组件。这里逻辑很简单——从 store 拿 token,没 token 就
<Navigate to="/login" replace />重定向到登录页。replace表示替换历史记录,用户点后退不会回到这个被拦截的页面。
children 是 React 的 children prop——RequireAuth 包裹的子组件。在 App.jsx 里看就懂了:
import React, { lazy, Suspense } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
// 路由守卫组件
import RequireAuth from './components/RequireAuth';
import Nav from './components/Nav';
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
function App() {
return (
<Router>
<Nav />
<Suspense fallback={<div className="page">加载中…</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route
path="/pay"
element={
<RequireAuth>
<Pay />
</RequireAuth>
}
/>
</Routes>
</Suspense>
</Router>
);
}
export default App;
/pay 这个路由的 element 不是直接 <Pay />,而是 <RequireAuth><Pay /></RequireAuth>。所以访问 /pay 时,先走 RequireAuth 检查 token——没登录就踢去登录页,登录了就把 Pay 渲染出来。
lazy + Suspense(懒加载):
const Home = lazy(() => import('./pages/Home'))是 React 的路由级代码分割。每个页面被打成单独的 chunk,用户访问到对应路由才加载。Suspense的fallback是加载过程中显示的占位 UI——这里就是一句"加载中…"。第一个加载完才显示真页面,首屏体积更小、白屏更短。
六、登录页:表单验证 + 颁发 token 的入口
登录页是整个鉴权流程的起点。代码在 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/use';
import styles from './Login.module.css';
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from || '/';
const setUser = useAuthStore(state => state.setUser);
const [formData, setFormData] = useState({ username: '', password: '' });
const [errors, setErrors] = useState({ username: '', password: '' });
const [isValid, setIsValid] = useState(false);
const [submitting, setSubmitting] = 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();
if (submitting) return;
setSubmitting(true);
try {
const res = await login(formData); // 发起登录请求
if (res.code === 0) {
setUser(res.token, res.user);
navigate(from, { replace: true }); // 登录成功后跳转
} else {
alert(res.message || '登录失败');
}
} catch (err) {
console.error(err);
alert('登录失败');
} finally {
setSubmitting(false);
}
};
这里我自己整理的几个学习点:
1. from = location.state?.from || '/':这是配合 RequireAuth 用的。用户原本想进 /pay,被守卫拦住踢去登录页时,登录页能从 location.state.from 读出"用户原本想去哪"。登录成功后 navigate(from, { replace: true }) 就把用户送回他原本想去的页面,而不是粗暴地都送回首页。这种"登录后回到原页面"的体验,全靠 location.state 这个上下文传递。
2. 表单验证用 useEffect 监听 formData:每次输入框变化,useEffect 重算 errors,isValid 实时更新。提交按钮 disabled={!isValid || submitting},表单没填对就不能点。
3. submitting 防重复提交:if (submitting) return; 这一行防止用户疯狂点登录按钮。点击后立刻 setSubmitting(true),按钮文字变成"登录中…",finally 里 setSubmitting(false) 恢复。
4. 登录成功后调 setUser:setUser(res.token, res.user) 这一行是登录页和 store 的接口。setUser 内部双写 localStorage + store,下一秒整站就都"知道"我登录了。
5. res.code === 0:业务约定的成功码。code: -1 是用户名密码错,code: 401 是 token 验证失败,code: 0 才是成功。
登录页 JSX 部分还展示了一个细节——hint 文本:
<p className={styles.hint}>
演示账号:<code>admin</code> · 密码 <code>123456</code>
</p>
七、首页:调用 /api/repo 验证 token
首页做的事是把整条鉴权链路串起来给用户看。代码在 pages/Home.jsx:
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getRepo } from '../api/repo';
import { useAuthStore } from '../store/use';
function Home() {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const [repoUser, setRepoUser] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 已登录时,携带 token 请求受保护接口 /api/repo,验证令牌
useEffect(() => {
if (!token) return;
let cancelled = false;
(async () => {
setLoading(true);
setError('');
try {
const data = await getRepo(); // 返回 decoded.user(用户名)
if (!cancelled) setRepoUser(data);
} catch (e) {
if (!cancelled) setError('获取认证信息失败,令牌可能已失效');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [token]);
这个 useEffect 是 React 异步数据获取的经典写法,我之前没在别的课见过这种 cancelled 标志位的写法,记一下:
1. if (!token) return;:没登录就不发请求,避免一进首页就报 401。
2. let cancelled = false;:组件卸载时 cleanup 函数把 cancelled 设为 true。如果请求回来时组件已经卸载(比如用户切了路由),就不再 setState,避免 React 报"组件已卸载还在更新状态"的警告。
3. data = await getRepo():getRepo 内部已经剥过一层 .data,这里 data 就是后端返回的 { code:0, data:'admin' } 里的 data 字段,也就是 'admin' 这个字符串。直接 setRepoUser 显示出来。
整条链路在首页 UI 里被可视化:
- 没登录:显示"未登录",按钮是"去登录体验"。
- 登录后:显示"已登录",列出登录用户名、
/api/repo校验结果。校验过程有 loading 中态、error 错误态、成功态三种 UI 切换。
首页还把整条鉴权流程画成了三步:
- 登录获取 Token:提交用户名密码,服务端校验后签发 JWT。
- 请求携带 Token:axios 拦截器自动附加 Authorization 请求头。
- 服务端验签放行:解密令牌,验证身份后返回受保护数据。
这三步就是这一课的全部主线。
八、Nav 导航栏:根据 token 显示登录/退出
components/Nav.jsx 演示了"全局状态如何驱动 UI":
function Nav() {
const token = useAuthStore((state) => state.token);
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
};
注意 Nav 没 props,它跟父组件 App 没有任何数据传递,直接从 store 拿 token、user、logout 三个东西。这就是 Zustand 的好处——任何组件只要 import 这个 hook,就能订阅到同一份全局状态。不像 Context 还得在父组件包一层 Provider。
UI 切换逻辑也很直接:
{!token && (
<NavLink to="/login" ...>登录</NavLink>
)}
// ...
{token ? (
<>
<span className="user-chip">
<span className="user-dot" />
{user?.username}
</span>
<button type="button" className="btn btn-ghost nav-logout" onClick={handleLogout}>
退出登录
</button>
</>
) : (
<Link to="/login" className="btn btn-primary">
去登录
</Link>
)}
没登录显示"去登录"按钮,登录了显示用户名+退出按钮。状态一变 UI 自动切——这就是 Zustand 响应式更新的实际效果。
九、Pay 页:受保护页面的样子
pages/Pay.jsx 本身逻辑很轻,只是把当前用户的 token 截一段显示出来证明"你登录了":
<code className="pay-value pay-token">
{token ? `${token.slice(0, 44)}…` : '-'}
</code>
token.slice(0, 44) 取前 44 个字符再补省略号,因为完整 token 太长不好看。这一页的真正门面是 RequireAuth——没登录访问 /pay 会直接被踢去 /login,根本进不到这里。Pay 页能展示出来,本身就已经证明鉴权生效了。
十、vite-plugin-mock:让前端自己跑通整条链
最后一块拼图是 vite 配置。代码在 vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'
// https://vite.dev/config/
viteMockServe({
mockPath: 'mock',
localEnabled: true,
}),
vite-plugin-mock:Vite 的 mock 插件。配置
mockPath: 'mock'后,Vite 会自动加载项目根目录下mock/文件夹里的所有 mock 文件,把它们注册成对应的 HTTP 接口。localEnabled: true表示本地开发环境启用。这样前端写 axios 请求/api/login,根本不需要真实后端,mock 文件就把它接住了。
老师把这种做法叫"大前端实现鉴权校验"——意思是鉴权这套流程不用等后端,前端自己用 mock 把整条链路跑通。签名、验签、拦截器、路由守卫、状态管理,每一环都是真实代码,只是"后端"这一环用 mock 顶替。等真后端就绪,把 mock 配置关掉、把 baseURL 指到真后端就行,前端代码一行都不用改。
笔记里写的是 axios baseURL + vite mockjs 插件 + /api/ 路径请求,三件事组合起来就是这套大前端鉴权方案。
我现在的理解
这一课的收获不是某个 API 怎么用,而是把"鉴权"这件事拆成了一个有 5 个齿轮的机械:
- JWT:服务端签发 + 验签的身份证本身。
- localStorage:身份证在客户端的家,刷新不丢。
- Zustand store:把 localStorage 里的身份证读进 React 内存,整站可订阅。
- axios 拦截器:每次请求自动把身份证塞进 Authorization 头。
- 路由守卫:页面级别检查身份证,没证不准进。
HTTP 本身确实无状态。但"登录态保住"这件事,从来不指望 HTTP 帮我记——是浏览器(localStorage)+ 前端代码(store + 拦截器 + 守卫)一起把我"是谁"这件事持续地、自动地、每次请求都重新声明一遍。服务器只要每次验签就行,它不需要记我。
这套思路放到 AI 编程时代特别值得想清楚:以后用 AI 写鉴权代码,AI 大概率会一把梭把这套都生成出来。但如果我不知道这 5 个齿轮各自干嘛、为什么是这种分工,AI 改错一个地方我都不会发现。工具会替我写代码,但替不了我知道"这套东西为什么是这样"。这是这一课真正的价值。
术语速查
- Stateless(无状态协议):HTTP 协议本身不记录请求来源,每个请求对服务器都是新的。
- JWT(JSON Web Token):以 JSON 为 payload 的签名 token 标准。客户端持有,服务器用 secret 验签。
- sign / verify:JWT 的两个动作。sign 是用 secret 给 payload 签名生成 token,verify 是用同一个 secret 验证 token 是否被篡改并取出 payload。
- 加盐(secret):JWT 签名时混入的密钥,只有服务器知道。任何篡改 payload 的尝试都会让签名对不上。
- Bearer Token:RFC 6750 定义的 token 类型,请求头格式为
Authorization: Bearer <token>。"持票人即合法身份"。 - localStorage:浏览器本地存储,关页面刷新不丢,按域名隔离。这一课用来持久化 token 和 user。
- Zustand:React 轻量级状态管理库。一个
create函数生成一个自定义 hook,组件通过 hook 订阅状态。 - Store(状态仓库):Zustand 里由
create生成的状态容器,React App = UI Component + Store。 - 拦截器(interceptor):axios 的钩子机制。request 拦截器在请求发出前修改 config,response 拦截器在响应回来后处理数据。
- 单例模式:一个类只允许有一个实例。这里
axios.create生成的 instance 全项目共用。 - 路由守卫:进入路由前检查权限的组件。这里
RequireAuth检查 token,没 token 重定向到登录页。 - lazy + Suspense:React 路由级代码分割。
lazy(() => import(...))把页面打成单独 chunk 按需加载,Suspense提供加载中占位 UI。 - 高阶函数:接收函数作为参数或返回函数的函数。Zustand 的
create接收一个返回状态对象的函数,返回一个 hook,是高阶函数。 - vite-plugin-mock:Vite 的 mock 插件,加载
mock/目录文件并注册成 HTTP 接口,让前端不用真实后端就能跑通接口。 - cancelled 标志位:React useEffect 里防止组件卸载后还 setState 的写法。cleanup 函数把标志位设为 true,请求回来时先检查标志位再更新状态。