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

0 阅读8分钟

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/sessionJWT
身份存哪服务器内存客户端(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——要放进去的"身份信息"。这里放了 userrole
  • 第二个参数 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:

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;
}

注意这里有个细节我自己也卡了一下:getReporeturn 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

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,用户访问到对应路由才加载。Suspensefallback 是加载过程中显示的占位 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. 登录成功后调 setUsersetUser(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 切换。

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

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

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

八、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 个齿轮的机械:

  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,请求回来时先检查标志位再更新状态。