🌃 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.jsuser.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)

HomeLoginPay 三个页面都是 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.jsbaseURL: '/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 里的 formDataerrorsuseState------表单提交完就扔了,不需要全局共享。

tokenuser 用 Zustand------多个组件都要用(Nav、RequireAuth、Login),必须全局共享。

tokenuser 同时写 localStorage------刷新页面后 Zustand store 会重建,从 localStorage 恢复数据。

三层各司其职,不越界。表单数据不会进 store,store 数据不会只存内存。什么数据放什么层,这是架构设计的基本功。


PS:HTTP 不认识你,但 JWT 记得你。下次有人问"前端鉴权怎么做",把这篇文章甩给他。

相关推荐
Profile排查笔记27 分钟前
JavaScript 实现浏览器指纹生成:基础字段、Canvas 采样与 SHA-256 摘要
前端·人工智能·后端·自动化
不可能片场28 分钟前
Electron 子进程退化成 node 的 env 陷阱
前端·electron
奋斗吧程序媛30 分钟前
CSV文件导入功能
开发语言·javascript·ecmascript
搬砖记录员32 分钟前
录屏总黑屏?90% 的人没关这个开关——浏览器硬件加速冲突排查指南
前端·音视频开发
promiseThen36 分钟前
我用 7 条铁律管住 Cursor:让 AI 写代码不再「自由发挥」
前端·ai编程
梨想橙汁1 小时前
JS 核心语法:运算符流程控制、函数、数组与对象实战详解
前端·javascript
七牛开发者1 小时前
拆解 dsh:Turn 与 Step 如何组织 Agent 主循环
前端·javascript·人工智能
空想兔1 小时前
前端网络请求的前世今生:从 Ajax、XHR 到 Fetch 和 Axios
javascript
梨想橙汁1 小时前
JavaScript 零基础入门:引入方式、变量与数据类型,吃透原始与引用类型
前端·javascript