HTTP 明明无状态,登录态怎么就保住了——React + Zustand + JWT 鉴权全流程拆解

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 那条路为什么被冷落?我整理了一下笔记里的两条理由:

  1. 内存压力:用户一多,服务器内存里要存一大堆 session 对象,每个 sessionId 都得对得上号。
  2. 不适合分布式: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:

javascript 复制代码
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 验签,验过了就把用户名还给前端":

javascript 复制代码
    {
        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
            }
        }
    }
]

这行值得停下来看一眼:

javascript 复制代码
const token = headers['authorization'].split(' ')[1];

为什么是 .split(' ')[1]?因为前端发过来的 Authorization 头长这样:

makefile 复制代码
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:

use.js 是这一课的重点:

javascript 复制代码
// 全局负责 提供用户身份状态,存储用户身份状态
// 创建 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

笔记里有一段很诚实的注释:

javascript 复制代码
// 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:

javascript 复制代码
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 就是一个登录接口:

javascript 复制代码
import instance from './config';

export const login = async (data) => {
    const res = await instance.post('/login', data);
    return res;
};

api/repo.js 就是一个受保护接口:

javascript 复制代码
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",还有"页面级别不让你进"。这就靠路由守卫。

components/RequireAuth.jsx:

javascript 复制代码
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 里看就懂了:

javascript 复制代码
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:

javascript 复制代码
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 文本:

javascript 复制代码
<p className={styles.hint}>
  演示账号:<code>admin</code> · 密码 <code>123456</code>
</p>

七、首页:调用 /api/repo 验证 token

首页做的事是把整条鉴权链路串起来给用户看。代码在 pages/Home.jsx:

javascript 复制代码
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 切换。

首页还把整条鉴权流程画成了三步:

  1. 登录获取 Token:提交用户名密码,服务端校验后签发 JWT。
  2. 请求携带 Token:axios 拦截器自动附加 Authorization 请求头。
  3. 服务端验签放行:解密令牌,验证身份后返回受保护数据。

这三步就是这一课的全部主线。

八、Nav 导航栏:根据 token 显示登录/退出

components/Nav.jsx 演示了"全局状态如何驱动 UI":

javascript 复制代码
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 切换逻辑也很直接:

javascript 复制代码
{!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 截一段显示出来证明"你登录了":

javascript 复制代码
<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:

javascript 复制代码
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 个齿轮的机械:

  1. JWT:服务端签发 + 验签的身份证本身。
  2. localStorage:身份证在客户端的家,刷新不丢。
  3. Zustand store:把 localStorage 里的身份证读进 React 内存,整站可订阅。
  4. axios 拦截器:每次请求自动把身份证塞进 Authorization 头。
  5. 路由守卫:页面级别检查身份证,没证不准进。

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,请求回来时先检查标志位再更新状态。
相关推荐
卸任3 小时前
Vibe coding工具使用心得
前端·ai编程
brien93 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance4 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M4 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写4 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李4 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易4 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding4 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马4 小时前
前沿视界
前端
kyriewen4 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员