从零看懂 JWT 登录鉴权:一个 React Demo 的完整拆解

从零看懂 JWT 登录鉴权:一个 React Demo 的完整拆解

这篇文章基于一个 login-demo 项目,带你走通"登录 → 颁发 token → 携带 token → 鉴权 → 访问受保护页面"的完整链路。项目技术栈是 React + react-router-dom + zustand + axios + mockjs + jsonwebtoken


一、整体思路:为什么需要 JWT

HTTP 是无状态的(Stateless),所以服务器不知道"你是谁"。

既然服务器记不住身份,就需要一种方式让用户自己"证明自己是谁"。JWT(JSON Web Token)的思路是:

  1. 登录时,后端校验账号密码(admin / 123456);
  2. 校验通过后,把用户的身份信息做成一个 JSON 对象 (比如 { id: 1, username: 'admin', role: 'admin' });
  3. sign 把这个 JSON 对象加密成一串 token(这个操作是单向的,只能签发不能反推);
  4. 前端每次请求都把 token 放进请求头的 Authorization 字段(格式 Bearer <token>);
  5. 后端拿到 token 后用 verify 解码,还原出 JSON 对象,就知道"你是谁"了。

对比传统的 cookie/session 方案:session 会把会话对象存在服务器内存里,请求通过携带的 sessionId 去查。这种方案不太适合分布式 ------因为 A 服务器的内存里不一定有 B 服务器签发的 session。而 JWT 没有这个问题:任何一台服务器签发的 token,都可以在任何一台自己的其他服务器上解码出来,因为它不依赖服务器本地存储,信息都在 token 自己身上。


二、Mock 后端:签发和校验 token

项目用 vite-plugin-mock 模拟后端接口:

arduino 复制代码
viteMockServe({
  mockPath: 'mock',      // mock 文件所在目录
  localEnabled: true,    // 本地开发时启用
})

所有 mock 接口定义在 mock/user.js 里,核心是两个动作:sign(签发)verify(校验)

1. 登录接口 /api/login ------ 用 sign 颁发 token

arduino 复制代码
const token = jwt.sign(
  { user: body.username, role: 'admin' },  // 身份信息 JSON 对象
  secret,                                  // 密钥(加盐)
  { expiresIn: 86400 }                     // 有效期
)

三个参数分别对应:要写入 token 的身份信息、密钥(secret819!$)、过期时间。登录成功返回 { code: 0, user, token }

注意这里的 sign单项操作------你能用密钥把 JSON 变成 token,但拿着 token 逆推出密钥很难,这正是"加密"的意义。

2. 数据接口 /api/repo ------ 用 verify 校验 token

ini 复制代码
const authHeader = req.headers['authorization'];   // "Bearer XXXXX"
if (!authHeader) return { code: 401, msg: 'No token' };
const token = authHeader.split(' ')[1];            // 拆出 Bearer 后面的 token
let decoded = jwt.verify(token, secret);           // 解码还原 JSON
return { code: 0, data: decoded.user };

先用 split(' ')"Bearer xxx" 拆成两段,取第二段真正的 token,再用 verify 解码。解不出来就返回 401 Invalid token

这里 401 就是"未授权"的 HTTP 状态码,代表"你没有凭证或凭证无效"。


三、axios 拦截器:让每个请求自动带上 token

如果每次请求都手动加 token,既繁琐又容易漏。拦截器就是干这个的------统一在发请求前、收响应后做处理 。见 api/config.js

php 复制代码
const instance = axios.create({
  baseURL: '/api/',      // 统一前缀
  timeout: 5000,
})

axios.create 创建一个独立的 axios 实例,所有接口都带上 /api/ 前缀。

request 拦截器 ------ 发请求前

ini 复制代码
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;   // 请求配置对象
})
  • 拦截器把每个 axios 请求都拦下来 ,拿到 config(请求配置对象);
  • localStorage 取出 token,写进请求头的 Authorization
  • return config 是关键------必须把配置对象还回去,请求才能继续发出去。

response 拦截器 ------ 收到响应后

kotlin 复制代码
instance.interceptors.response.use(res => {
  return res.data;   // 直接返回服务器数据,省得每次取 .data
})

这里统一把 res.data 解包返回。所以 api/repo.jsapi/user.js 里就不需要再 res.data 双重解包 ,直接 return res 即可。

javascript 复制代码
// api/user.js
export const login = async (data) => {
  const res = await axios.post('/login', data);
  return res;
}

四、zustand:全局状态管理

登录与否、用户信息 是全局状态,需要在跨路由、跨组件的地方共享(比如导航栏和登录页都要读写)。这个项目用 zustand 统一管理。

先理解一个问题:React 里组件通信、状态共享有几种方式?

  • 父子传递:父组件通过 props 把状态传给子组件;
  • createContext + useContext:跨层级共享状态;
  • zustand store:把状态抽到一个"状态仓库"里,任何组件都能读写,尤其适合全局状态。

store/user.js 就是一个 store:

javascript 复制代码
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 });
  }
}))

关键点:

  1. create 是一个高阶函数 :它接受一个函数作为参数,返回的也是一个函数(最终就是一个自定义 hook useAuthStore)。
  2. set 是修改状态的方法,状态和动作(action)都定义在同一个对象里。
  3. 状态持久化tokenuser 的初始值从 localStorage 里读,setAuthlogout 时同步写/删 localStorage。这样刷新页面后登录状态不丢失------这就是 readme 里说的"后端签发的 token 放在 localStorage"。

user 存的是对象,所以存的时候要 JSON.stringify,读的时候要 JSON.parse

为什么说"组件状态几乎都不放在 component"

store/todos.js 展示了第二个 store:

javascript 复制代码
export const useTodosStore = create(set => ({
  todos: [],
  setTodos: ({ todos }) => {
    set({ todos })
  }
}))

注释里也点明了使用场景:大型项目 用子仓(多个 store 拆分),中小型项目 还是用传统状态共享即可。store 的本质就是把"状态 + 修改状态的动作"集中管理,让 React App = UI Component + State 这个等式更清晰。

在组件里怎么用 store

nav.jsx 里读取:

ini 复制代码
const token = useAuthStore(state => state.token);
const user = useAuthStore(state => state.user);
const logout = useAuthStore(state => state.logout);

注意这里的用法:useAuthStore(state => state.token) 是通过**选择器(selector)**精准取出需要的某一块状态,而不是把整个 store 都拿过来。

Login.jsx 里写入:

scss 复制代码
const setAuth = useAuthStore(state => state.setAuth);
// 登录成功:
setAuth({ token: res.token, user: res.user });

五、React Router:路由、懒加载与路由守卫

1. 路由结构

App.jsxBrowserRouter 包住整个应用,Routes + Route 定义路由:

xml 复制代码
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/pay" element={
  <RequireAuth><Pay /></RequireAuth>
} />

三个页面:/(首页,公开)、/login(登录,公开)、/pay受保护,需要登录)。

2. 懒加载

javascript 复制代码
const Home = lazy(() => import('./pages/Home'))

React.lazy 让组件按需加载 (而不是一开始全部加载),配合 <Suspense fallback={<div>Loading...</div>}>,在组件还没加载完时显示 fallback 占位内容。

3. 路由守卫 RequireAuth

RequireAuth.jsx 是保护 /pay 的关键:

javascript 复制代码
function RequireAuth({ children }) {
  const token = useAuthStore(state => state.token);
  if (!token) {
    return <Navigate to="/login" replace />
  }
  return children;
}

逻辑非常直接:如果没有 token,就 <Navigate> 重定向到登录页;有 token 才渲染 children(也就是 Pay 页面)replace 表示用替换而不是压栈的方式跳转。

Nav.jsx 根据状态动态渲染

javascript 复制代码
{!token && <Link to="/login">Login</Link>}   // 未登录才显示 Login
{user && <a>{user.username}</a>}             // 登录后显示用户名
{token && <button onClick={handleLogout}>Logout</button>}  // 登录后显示退出

点击 Logout 调用 logout(),清空 store 和 localStorage,导航栏随之切换回未登录状态。


六、登录页:表单 + 验证 + 跳转

Login.jsx 完整串联了前面的所有知识点:

1. 受控表单

ini 复制代码
const [formData, setFormData] = useState({ username: '', password: '' });
const handleChange = e => {
  const { name, value } = e.target;
  setFormData(prev => ({ ...prev, [name]: value }));
};

输入框的值由 state 控制(value + onChange),用计算属性名 [name] 让两个输入框共用一个处理函数。

2. 表单校验

useEffect 依赖 formData,每当输入变化就重新校验:

  • 用户名:不能为空、至少 3 位;
  • 密码:不能为空、至少 6 位;
  • 通过 isValid 控制按钮 disabled={!isValid},校验不通过按钮不可点。

3. 登录流程与跳转

ini 复制代码
const from = location.state?.from || '/';

const handleLogin = async e => {
  e.preventDefault();
  const res = await login(formData);
  if (res.code === 0) {
    setAuth({ token: res.token, user: res.user });  // 1. 存入全局状态
    navigate(from, { replace: true });              // 2. 跳转
  } else {
    alert(res.message || '登录失败');
  }
};

code === 0 表示"没有错误"(对应 mock 里返回的 code: 0)。成功就 setAuth 保存身份 + navigate 跳转回原页面。

4. CSS Modules

Login.module.css.module.css 结尾,用 import styles from './Login.module.css' 导入,通过 styles.containerstyles.error 这种对象方式引用类名。这样样式是局部作用域的,不会污染全局。


七、知识点总结

  1. JWT 解决了什么:HTTP 无状态,服务器记不住"你是谁",于是用 token 让客户端自己携带身份凭证,每次请求带上,后端解码还原身份。
  2. 两个核心动作sign(用密钥 + 身份 JSON 签发 token,单向加密)和 verify(解码还原 JSON)。对比 session,JWT 不依赖服务器内存,适合分布式。
  3. token 传递约定 :请求头 Authorization: Bearer <token>,后端用 split(' ') 拆出 token;无效或缺失返回 401
  4. axios 拦截器axios.create 建实例(配 baseURL、timeout);request 拦截器 统一从 localStorage 取 token 塞进请求头(必须 return config);response 拦截器 统一解包 res.data,业务代码不再双重解包。
  5. zustandcreate 是高阶函数,返回自定义 hook;状态和 action 同在一个对象里,用 set 修改;通过 selector(state => state.xxx)精准读取;配合 localStorage 实现登录状态持久化(JSON.stringify / JSON.parse)。
  6. React RouterBrowserRouter + Routes + Route 定义路由;React.lazy + Suspense 实现按需加载;RequireAuth 守卫组件用 Navigate 把无 token 用户重定向到登录页;useNavigate / useLocation 实现登录后跳转回原页面。
  7. 受控表单 :输入值由 state 驱动,[name] 计算属性名复用处理函数,useEffect 依赖变化做校验,disabled 控制提交按钮。
  8. mock 开发viteMockServe 插件 + mock 目录,无需真实后端即可联调登录鉴权全流程。
  9. CSS Modules.module.css 文件 + styles.xxx 引用,实现样式局部作用域,避免全局污染。

一句话串起来 :登录页提交表单 → axios(带 request 拦截器)发 /api/login → mock 后端 sign 签发 token 返回 → setAuth 存入 zustand + localStorage → 之后每个请求由 request 拦截器自动带 Authorization → 访问 /payRequireAuth 检查 token,没有就跳登录 → 有 token 时 /api/repoverify 解码校验通过,返回用户数据。整条链路闭环。

相关推荐
clawdashi2 小时前
2026 办公 Agent 选型指南:按场景分五类,一次讲清
前端·人工智能
BreezeJiang2 小时前
JWT 登录不是存个 token:React 鉴权真正要闭合的是一条数据流
javascript·react.js·axios
驳是2 小时前
代码洁癖患者会喜欢的几款 CLI 小工具
前端
Csvn2 小时前
🛋️ requestIdleCallback:把非关键任务塞进浏览器的"空闲时间",告别卡顿
前端
江华森2 小时前
从 fork 到协程:一次完整的操作系统并发编程实操
前端·程序员
四千岁2 小时前
稀疏向量BM25Retriever不支持中文怎么办?jieba来帮忙
前端·javascript·后端
这是个栗子2 小时前
【JS代码分析】前端鉴权基础:Token 的本地存储与状态重置实践
开发语言·前端·javascript
颜进强2 小时前
14 - OpenSpec 老页面改造骨架:定位 + 增量 + 回归三件套
前端·后端·ai编程
做前端的娜娜子2 小时前
async/await 错误处理:try...catch vs .catch() 完全指南
前端·面试·掘金·金石计划