🌃 HTTP 不认识你:JWT 登录鉴权的完整"酒店入住"指南

写在前面: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 });
    }
}))

三个关键设计:

  1. 初始化读 localStorage:页面刷新后 store 会重新创建,但 token 还在 localStorage 里。所以初始化时先从 localStorage 读------相当于酒店每天开门营业,先查昨天的入住记录。
  2. setAuth 双写:同时更新 store(内存)和 localStorage(持久化)。内存给 React 用,localStorage 给刷新后的下次启动用。
  3. 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;

逻辑只有三步:

  1. 从 Zustand store 取 token
  2. 没 token → 重定向到 /login
  3. 有 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('登录失败');
    }
};

登录成功的流程:

  1. 调 login(formData) 发 POST 请求
  2. 后端返回 { code: 0, token, user }
  3. 调 setAuth 存 token 和 user(同时写 Zustand + localStorage)
  4. 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 记得你。下次有人问"前端鉴权怎么做",把这篇文章甩给他。

相关推荐
前端双越老师28 分钟前
程序员 1v1 访谈系列第二期:很焦虑但又不知道干啥
前端·ai编程·全栈
广州华水科技42 分钟前
北斗形变监测传感器在大坝安全监测中的应用与优势分析
前端
孔令飞1 小时前
如何学习Go
前端
HUIBUR科技1 小时前
AI 中枢,面向企业场景构建的 AI 智能体
前端·数据库·人工智能
怕浪猫1 小时前
给AI配一台电脑,开源版Muse来了,OpenMuse赶紧去二次开发吧
javascript·面试·github
随便写个软件1 小时前
【新版本】EasyExplorer资源管理器 3.12.0——地址栏重要更新
运维·服务器·前端·everything·资源管理器
Jack N1 小时前
【Strapi 系列之二】Collection / Single / Component 怎么选?Strapi 字段大全一篇讲透
前端·javascript·strapi
hiahiahia1231 小时前
Agent Tool 执行失败怎么办
服务器·前端
Java后端的Ai之路1 小时前
09-React组件生命周期
前端·react.js·前端框架
IT_陈寒1 小时前
SpringBoot开发实战:从入门到精通
前端·人工智能·后端