2.0 React Router 前端路由深度解析:从传统后端路由到现代 SPA 的完整演进

基于上一篇文章续写,对应源码全更新在上一篇文章

新增

  • 鉴权路由

    • http 无状态的
    • 有状态?
      • 请求头 token Authorization
      • Cookie 小饼干
      • localStorage 本地存储 login 状态 user admin password 123456
    • 组件内部的子组件 props.children 拿到组件申明的内部所有的子节点。 model 弹窗组件 mask 蒙层 窗体 头部, 尾部 主题部分children传入。 定制性

    {children 定制}

    路由对象

    • SPA 需要前端路由
    • url改变 , 对应不同的资源 resetful 设计理念 hash #/pay browserRouter History
    • navigator 导航栏
    • location 地址栏
    • history 历史对象
    • Link 组件 to replace

    路由两种选型

    • hashRouter url 局部改变 hash 部分 url 有点为了前端路由 , url 有点丑, 后端路由不太一样 /pay #/pay
    • browserRouter 不是hash 方案实现SPA

4.9 路由重定向的三大实战场景(金字塔塔尖------核心结论)

路由重定向的本质是"URL 的永久/临时迁移"------当某个 URL 不再指向有效内容时,通过 <Navigate> 组件或 useNavigate Hook 将用户无缝引导到正确的目标页面。它不是简单的"跳转",而是一个包含历史记录管理、状态传递、用户体验考量在内的完整设计模式。

在真实业务中,路由重定向主要有三大场景:活动下线迁移 (营销活动结束后跳转到结果页)、路径重构 (旧 URL 永久迁移到新 URL)、条件重定向(未登录用户访问受保护页面时跳转到登录页)。下面逐一拆解。

4.9.1 场景一:营销活动下线 ------ /game/result

这是最经典的重定向场景。假设运营团队搞了一个拉新活动,URL 是 /#/game,投入了 100 万推广预算。活动结束后,大量用户仍会通过之前保存的书签、分享链接、短信链接访问 /game。如果直接返回 404,这 100 万的推广效果就打了折扣。

正确的做法是将 /game 重定向到 /result(活动结果页),告诉用户"活动已结束,这是最终中奖名单"。

jsx 复制代码
// App.jsx 中的配置
<Route path="/game" element={
  <Navigate replace to="/result" />
} />

为什么必须使用 replace

bash 复制代码
用户从短信点击链接进入 /game:

不使用 replace(push 模式):
  历史栈:[短信链接] → [/game] → [/result]
  用户点后退 → 回到 /game → 立即又被重定向到 /result
  → 用户被困在死循环中,永远退不回短信来源页面

使用 replace:
  历史栈:[短信链接] → [/result]
  用户点后退 → 回到短信来源页面 ✔

进一步优化:带参数的重定向

jsx 复制代码
// 更完善的写法:携带活动 ID,方便数据统计
<Route path="/game/:activityId" element={
  <Navigate replace to="/result" />
} />

// 在 Result 页面中,还可以通过更高级的方式获取原活动 ID
// 例如在 Navigate 中携带 state:
<Route path="/game/:activityId" element={
  <GameRedirect />
} />

// GameRedirect 组件内部:
function GameRedirect() {
  const { activityId } = useParams();
  return <Navigate replace to="/result" state={{ fromActivity: activityId }} />;
}

4.9.2 场景二:首页路径归一化 ------ /home/

很多网站在演进过程中,首页路径可能变更多次:/home/index/main 等。为了保证 SEO 和用户体验的一致性,应该将所有旧首页路径重定向到统一的 /

jsx 复制代码
// App.jsx 中的配置
<Route path="/home" element={
  <Navigate replace to="/" />
} />
<Route path="/index" element={
  <Navigate replace to="/" />
} />

深层原理 :搜索引擎爬虫会认为 /home/ 是两个不同的页面,导致内容重复惩罚。通过 301(服务端)或客户端重定向,告知搜索引擎"这些 URL 都指向同一个页面"。虽然 HashRouter 的 # 后面内容不发往服务器(搜索引擎通常不索引 hash 路由的路径),但在 BrowserRouter 模式下这至关重要。

4.9.3 场景三:条件重定向 ------ 未登录用户访问 /user/:id/login

这是最复杂但最常见的场景。用户在未登录状态下访问需要登录才能看的页面(如 /user/123 个人中心),应当被引导到登录页,登录成功后自动返回之前想访问的页面

bash 复制代码
用户访问 /user/123(未登录)
       │
       ▼
检测到未登录 → 重定向到 /login,并记住"用户想来 /user/123"
       │
       ▼
用户在 /login 输入用户名密码 → 登录成功
       │
       ▼
自动跳回 /user/123(而非首页!这才是符合用户预期的体验)

实现这个闭环需要三个组件配合ProtectRoute(路由守卫)、Login(登录页)、Navigate(重定向指令)。其中的核心机制是 location.state 传递来源信息------这将在下面的 4.10 和 4.11 节详细展开。


4.10 路由守卫(ProtectRoute):权限控制的"门禁系统"

4.10.1 核心结论(金字塔塔尖)

路由守卫是一个包裹在目标页面外层的"安检组件"------它不修改 URL 结构,而是在渲染目标页面之前插入一个鉴权判断:已登录则放行(渲染 children),未登录则拦截并重定向到登录页,同时通过 location.state 记住"用户从哪来",为登录后的回跳埋下伏笔。

ini 复制代码
用户访问 /pay(受保护页面)
       │
       ▼
ProtectRoute 组件渲染
       │
       ├── isLogin === true  → 渲染 {children}(即 <Pay />)
       │                       用户正常使用支付功能
       │
       └── isLogin === false → 返回 <Navigate to="/login" state={{ from: location }} />
                                用户被送到登录页,location 信息被保存在 state 中

4.10.2 完整源码及逐行解析

jsx 复制代码
// src/ProtectRoute.jsx(共 27 行)
import {
    Navigate,     // 重定向组件
    useLocation   // 获取当前路由位置信息
} from 'react-router-dom';

const ProtectRoute = ({ children }) => {
    console.log(children, '-----');
    // ↑ children 是 <ProtectRoute> 标签内部包裹的 JSX 元素
    // 在 App.jsx 中:<ProtectRoute><Pay /></ProtectRoute>
    // children 就是 <Pay /> 这个 React 元素

    const location = useLocation();
    // ↑ useLocation() 返回当前路由的完整位置对象:
    // {
    //   pathname: "/pay",          ← 当前路径
    //   search: "",                ← 查询字符串
    //   hash: "",                  ← hash 中的子 hash
    //   state: null,               ← 路由状态(通过 navigate 或 Link 的 state 传递)
    //   key: "default"             ← 唯一标识
    // }

    // html5 本地存储 ------ 域名的沙盒
    // localStorage 是浏览器提供的键值存储,数据在同一个域名下共享
    // 每个域名有独立的 localStorage 沙盒(约 5MB),不同域名互不可见
    const isLogin = localStorage.getItem('isLogin') === 'true';
    console.log(isLogin, 'isLogin');

    if (!isLogin) {
        // 未登录 → 重定向到登录页
        // ★★★ 关键设计:state={{ from: location }} ★★★
        // 将当前 location 对象(包含 pathname="/pay")作为 state 传递
        // 登录成功后,Login 组件可以从 state 中读取 from,实现回跳
        return <Navigate to="/login" replace state={{ from: location }} />;
    }

    return (
        <>
            ProtectRoute:
            {children}  {/* 已登录,放行!渲染被保护的页面组件 */}
        </>
    );
};
export default ProtectRoute;

4.10.3 children 模式的设计智慧

ProtectRoute 使用了 React 的 children prop 模式,而非直接在路由配置中写判断逻辑:

jsx 复制代码
// ✔ 当前设计(children 模式)------ ProtectRoute 是通用组件
<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
} />

// 可以轻松复用到任何需要保护的页面:
<Route path="/settings" element={
  <ProtectRoute>
    <Settings />
  </ProtectRoute>
} />
<Route path="/orders" element={
  <ProtectRoute>
    <Orders />
  </ProtectRoute>
} />

为什么这比"在每个页面组件里写鉴权逻辑"更好?

javascript 复制代码
❌ 在每个页面组件中写鉴权(代码重复、容易遗漏):
function Pay() {
  const isLogin = localStorage.getItem('isLogin') === 'true';
  if (!isLogin) return <Navigate to="/login" />;
  // ... 业务逻辑
}

function Settings() {
  const isLogin = localStorage.getItem('isLogin') === 'true';
  if (!isLogin) return <Navigate to="/login" />;
  // ... 业务逻辑
}
// 每个页面都要写一遍鉴权,万一忘记写了就是安全漏洞

✔ 使用 ProtectRoute 包裹(关注点分离、不会遗漏):
// 鉴权逻辑集中在 ProtectRoute 一个组件中
// 页面组件只关心自己的业务逻辑
// 新增受保护页面时,只需要在路由配置中包裹一层即可

4.10.4 state={{ from: location }} 的深度解析

这是整个登录回跳机制最精妙的一行代码:

jsx 复制代码
return <Navigate to="/login" replace state={{ from: location }} />;

state 是什么?

state 是 React Router 在 URL 之外传递数据的"隐式通道"。它不显示在 URL 中,不会出现在浏览器地址栏,也不会被发送到服务器。它通过浏览器的 History API 的 history.state 机制存储,仅在客户端可见。

为什么要传整个 location 对象而不是只传 pathname

jsx 复制代码
// ❌ 只传 pathname ------ 丢失了查询参数和 hash
state={{ from: location.pathname }}   // 只拿到 "/pay",丢失了 ?coupon=123

// ✔ 传整个 location ------ 保留了完整信息
state={{ from: location }}            // 拿到 { pathname: "/pay", search: "?coupon=123", ... }

真实场景举例:用户通过一个带优惠券参数的链接访问支付页 /pay?coupon=SUMMER50,被拦截到登录页。登录成功后,应当回到 /pay?coupon=SUMMER50 而不是 /pay。传整个 location 对象保证了查询参数不丢失。

replace 的作用再次体现

在 ProtectRoute 中使用 replace,意味着"用 /login 替换掉 /pay 在历史栈中的位置":

bash 复制代码
用户直接访问 /pay(未登录):
  历史栈:[/pay]  →  ProtectRoute 执行 replace →  [/login]
  
用户登录成功后 navigate('/pay'):
  历史栈:[/pay]
  
此时用户点后退 → 回到登录前所在页面(可能是首页)
而不是回到 /login(再次被 ProtectRoute 拦截 → 又送回 /login → 死循环)

4.11 登录认证与回跳机制:从拦截到放行的完整闭环

4.11.1 核心结论(金字塔塔尖)

登录不是孤立的页面跳转,而是一个"拦截 → 暂存意图 → 认证 → 恢复意图"的完整状态机。location.state 是这个状态机的"记忆载体"------ProtectRoute 在拦截时通过它记住"用户想去哪",Login 组件在登录成功后通过它恢复"用户原本的目的地"。

php 复制代码
完整闭环(总览):

  用户访问 /pay(未登录)
       │
       ▼
  ProtectRoute 检测 !isLogin
       │
       ├── 保存 location = { pathname: "/pay", search: "" } 到 state.from
       └── <Navigate replace to="/login" state={{ from: location }} />
       │
       ▼
  Login 组件渲染
       │
       ├── useLocation().state?.from?.pathname → "/pay"
       │   (从 state 中读出用户原本想去哪)
       │
       ├── 用户输入用户名密码 → 验证 → localStorage.setItem('isLogin', 'true')
       │
       └── navigate("/pay", { replace: true })
       │
       ▼
  URL 变为 /pay → Routes 重新匹配 → ProtectRoute 再次执行
       │
       ├── isLogin === true → 放行!
       └── 渲染 <Pay />

4.11.2 Login 组件完整源码及逐行解析

jsx 复制代码
// src/pages/Login/index.jsx(共 57 行)
import {
    useNavigate,   // 命令式导航 Hook
    useLocation,   // 读取当前路由位置(含 state)
} from 'react-router-dom';

const Login = () => {
    const navigate = useNavigate();
    const location = useLocation();

    // ★★★ 核心:从 state 中提取"用户原本想去哪" ★★★
    // location.state 可能有三种情况:
    // 1. 从 ProtectRoute 拦截来的:location.state = { from: { pathname: "/pay", ... } }
    // 2. 从 Link state 传来的:location.state = { from: { pathname: "/user/123", ... } }
    // 3. 直接访问 /login:location.state = null(无 state)
    //
    // 可选链操作符 ?. 是 ES11 语法:
    // location.state?.from?.pathname 等价于:
    //   location.state && location.state.from && location.state.from.pathname
    // 如果链上任何一环是 null/undefined,整个表达式返回 undefined(不会报错)
    const from = location.state?.from?.pathname || '/';
    //                              默认回首页 ↑
    // 如果用户直接访问 /login(没有来源信息),登录后回首页

    console.log(from, 'from');

    function handleSubmit(e) {
        e.preventDefault();  // 阻止表单的默认提交行为(页面刷新)

        // 原生表单数据对象 ------ 不需要 useState 管理每个字段
        const formData = new FormData(e.currentTarget);
        // FormData 是 HTML5 标准 API,e.currentTarget 指向 <form> DOM 元素
        const username = formData.get("username");
        const password = formData.get("password");

        if (!username || !password) {
            alert("请输入用户名和密码");
            return;
        }

        // 硬编码的账号密码验证(生产环境应调用后端 API)
        if (username === "admin" && password === "123456") {
            // ★ 写入登录状态到 localStorage
            localStorage.setItem('isLogin', 'true');

            // ★★★ 关键:replace: true ★★★
            // 将目标页面的历史记录替换掉 /login 的记录
            //
            // 为什么要 replace?
            // 如果不用 replace,历史栈变成:[/pay拦截前] → [/login] → [/pay]
            // 用户在 /pay 点后退 → 回到 /login → 发现已登录 → 又自动跳到 /pay → 死循环
            //
            // 使用 replace 后,历史栈变成:[/pay拦截前] → [/pay]
            // 用户在 /pay 点后退 → 回到拦截前的页面 ✔
            navigate(from, { replace: true });
        } else {
            alert("用户名或密码错误");
        }
    }

    return (
        <form onSubmit={handleSubmit}>
            <h1>登录</h1>
            <input
                name="username"
                placeholder="请输入用户名"
                required
            />
            <input
                name="password"
                placeholder="请输入密码"
                type="password"
                required
            />
            <button type="submit">登录</button>
            <p>这是登录页面</p>
        </form>
    );
};
export default Login;

4.11.3 from 变量的三种取值场景

javascript 复制代码
const from = location.state?.from?.pathname || '/';
用户操作 location.state from 的值 登录后跳转
访问 /pay 被 ProtectRoute 拦截 { from: { pathname: "/pay", ... } } "/pay" 回到支付页
访问 /user/123 被条件重定向拦截 { from: { pathname: "/user/123", ... } } "/user/123" 回到用户详情页
直接访问 /login(比如点了导航栏的"登录") nullundefined "/"(默认值) 回到首页

4.11.4 为什么用 FormData 而不是受控组件?

jsx 复制代码
// ✔ 当前写法:非受控(uncontrolled)------ 用 FormData 原生 API
function handleSubmit(e) {
    const formData = new FormData(e.currentTarget);
    const username = formData.get("username");
}

// ❌ 另一种写法:受控(controlled)------ 每个字段一个 useState
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// 2 个字段需要 2 个 state + 2 个 onChange,10 个字段呢?

选择 FormData 的理由

  • 登录表单字段少但不需要实时验证(不像搜索框需要防抖),FormData 足够
  • 避免不必要的重渲染(每次按键都更新 state → 整个组件重渲染)
  • 代码更简洁,字段越多优势越明显

4.11.5 replace: true 在登录场景的再次强调

这是登录流程中最容易被忽略的细节,但直接影响用户体验:

bash 复制代码
login 登录后跳转:

不使用 replace(push 模式):
  历史栈:[首页] → [/pay被拦截] → [/login] → [/pay]
                              ↑ 这条被替换掉了(ProtectRoute 的 replace)
                                        ↑ 这条是 push 进来的(navigate 默认 push)
  用户在 /pay 点后退:
    第一次后退 → /login → isLogin=true → 检测到已登录 → 自动跳回 /pay
    第二次后退 → /login → 又跳回 /pay
    第三次后退 → ... ← 用户被困!永远退不回去!

使用 replace:
  历史栈:[首页] → [/pay]
  用户在 /pay 点后退:
    直接回到首页 ✔ 用户体验完美

4.11.6 localStorage 作为登录状态存储的考量

javascript 复制代码
// 写入
localStorage.setItem('isLogin', 'true');

// 读取
const isLogin = localStorage.getItem('isLogin') === 'true';

// 清除(退出登录)
localStorage.removeItem('isLogin');

为什么用 localStorage 而不是 sessionStorage?

存储方式 生命周期 适用场景
localStorage 永久(除非手动清除) "记住我"------关闭浏览器后再次打开仍保持登录
sessionStorage 关闭标签页即清除 敏感操作------关闭标签页自动退出

生产环境中的重要警告 :本项目使用 localStorage 存储登录状态仅用于教学演示。生产环境中绝不应仅依赖 localStorage 做权限控制,因为:

  1. 用户可以手动修改 localStorage(在 DevTools 中执行 localStorage.setItem('isLogin', 'true') 即可绕过)
  2. localStorage 不随 HTTP 请求自动发送,后端无法验证
  3. 真正的权限控制必须由后端通过 token(JWT)/ session 验证,前端路由守卫只是"第一道防线"(提升用户体验),真正的安全在后端

4.12 支付页面(Pay):受保护资源的完整示例

4.12.1 核心结论(金字塔塔尖)

Pay 组件是路由守卫的"被保护对象"------它不包含任何鉴权逻辑,只专注于业务功能(选择支付方式、模拟支付、退出登录)。它的安全性完全由外层的 <ProtectRoute> 保证。这种"关注点分离"的设计,使得业务组件和权限逻辑各自独立、可测试、可复用。

4.12.2 完整源码及关键逻辑

jsx 复制代码
// src/pages/Pay/index.jsx(共 113 行)
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

const Pay = () => {
    const navigate = useNavigate();
    const [payMethod, setPayMethod] = useState('wechat');   // 支付方式
    const [paying, setPaying] = useState(false);            // 支付中状态
    const [paySuccess, setPaySuccess] = useState(false);    // 支付成功状态

    // 模拟订单数据
    const order = {
        id: 'ORD' + Date.now(),
        name: '天龙八部 RAG 助手 - 年度会员',
        price: 99.00,
    };

    // 模拟支付流程
    const handlePay = () => {
        setPaying(true);         // 进入支付中状态 → 按钮变灰、显示"支付中..."
        setTimeout(() => {
            setPaying(false);    // 支付完成
            setPaySuccess(true); // 切换到成功页
            setTimeout(() => {
                navigate('/', { replace: true });  // 2 秒后自动跳回首页
            }, 2000);
        }, 1500);  // 模拟 1.5 秒支付处理
    };

    // 退出登录
    const handleLogout = () => {
        localStorage.removeItem('isLogin');  // 清除登录标记
        navigate('/login');                   // 跳转到登录页
    };

    // 支付成功后的 UI
    if (paySuccess) {
        return (
            <div style={{ textAlign: 'center', padding: 40 }}>
                <h2>支付成功</h2>
                <p>订单号:{order.id}</p>
                <p>金额:¥{order.price}</p>
                <p>即将跳转首页...</p>
            </div>
        );
    }

    // 正常支付 UI
    return (
        <div style={{ padding: 20, maxWidth: 480, margin: '0 auto' }}>
            <h2>支付页面</h2>
            {/* 订单信息 */}
            <div style={{ border: '1px solid #ddd', padding: 16, borderRadius: 8, marginBottom: 16 }}>
                <h3>{order.name}</h3>
                <p>订单号:{order.id}</p>
                <p style={{ fontSize: 24, color: '#e4393c', fontWeight: 'bold' }}>
                    ¥{order.price.toFixed(2)}
                </p>
            </div>
            {/* 支付方式选择 */}
            <div style={{ marginBottom: 16 }}>
                <h3>选择支付方式</h3>
                <label><input type="radio" name="payMethod" value="wechat"
                    checked={payMethod === 'wechat'}
                    onChange={(e) => setPayMethod(e.target.value)} /> 微信支付</label>
                <label><input type="radio" name="payMethod" value="alipay"
                    checked={payMethod === 'alipay'}
                    onChange={(e) => setPayMethod(e.target.value)} /> 支付宝</label>
            </div>
            {/* 支付按钮 */}
            <button onClick={handlePay} disabled={paying}
                style={{ backgroundColor: paying ? '#ccc' : '#e4393c', /* ... */ }}>
                {paying ? '支付中...' : `确认支付 ¥${order.price.toFixed(2)}`}
            </button>
            {/* 退出登录按钮 */}
            <button onClick={handleLogout} style={{ /* ... */ }}>
                退出登录
            </button>
        </div>
    );
};
export default Pay;

4.12.3 组件状态机设计

Pay 组件内部有三个状态,构成了一个简单的状态机:

javascript 复制代码
初始状态(paying=false, paySuccess=false)
  │
  ├── 用户点击"确认支付"
  │     │
  │     ▼
  │   支付中(paying=true, paySuccess=false)
  │   UI:按钮变灰显示"支付中...",不可再次点击(disabled)
  │     │
  │     │ setTimeout 1500ms
  │     │
  │     ▼
  │   支付成功(paying=false, paySuccess=true)
  │   UI:显示成功信息 + "即将跳转首页..."
  │     │
  │     │ setTimeout 2000ms
  │     │
  │     ▼
  │   navigate('/') → 回到首页

  ├── 用户点击"退出登录"
  │     │
  │     ▼
  │   localStorage.removeItem('isLogin')
  │   navigate('/login')
  │     │
  │     ▼
  │   ProtectRoute 检测 !isLogin → 再次拦截 → 送回登录页

4.12.4 退出登录后的路由行为

javascript 复制代码
const handleLogout = () => {
    localStorage.removeItem('isLogin');
    navigate('/login');
};

退出登录后导航到 /login,由于 localStorage 中的 isLogin 已被清除,Login 页面正常显示登录表单。此时:

  • 如果用户重新登录 → from 为默认值 / → 跳回首页
  • 如果用户直接关闭标签页 → session 级别无影响(若用 sessionStorage 则自动清除)

4.13 完整业务流程串联:从支付意图到支付成功的全链路追踪

4.13.1 核心结论(金字塔塔尖)

前端路由的权限控制不是一个孤立的功能点,而是 ProtectRoute(门禁)、Login(认证)、Pay(业务)、Navigate(调度)四个组件协同完成的完整闭环。理解这个闭环的关键在于追踪 location.statelocalStorage 这两个"隐式数据通道"在各个环节的流转。

4.13.2 时间线追踪

ini 复制代码
T=0ms    用户状态:未登录,localStorage 中 isLogin 不存在
         用户在浏览器输入 URL:http://localhost:5173/#/pay

T=1ms    HashRouter 初始化,pathname = "/pay"

T=2ms    Routes 开始匹配:
         遍历 Route:
           path="/"         → 不匹配
           path="/about"    → 不匹配
           path="/user/:id" → 不匹配
           path="/products" → 不匹配
           path="/old-path" → 不匹配
           path="/login"    → 不匹配
           path="/pay"      → ✔ 匹配!
         
         渲染 element={<ProtectRoute><Pay /></ProtectRoute>}

T=3ms    ProtectRoute 组件执行:
           useLocation() → { pathname: "/pay", search: "", state: null, ... }
           localStorage.getItem('isLogin') → null → isLogin = false
           
           !isLogin === true → 进入拦截分支
           return <Navigate replace to="/login"
                           state={{ from: { pathname: "/pay", ... } }} />

T=4ms    Navigate 组件渲染 → 触发导航:
           hash 变为 '#/login'
           history.replaceState(replace 模式)
           将 state 写入历史记录

T=5ms    hashchange 触发 → HashRouter 更新 → React 重渲染

T=6ms    Routes 重新匹配:
           path="/login" → ✔ 匹配!
           渲染 <Login />

T=7ms    Login 组件执行:
           location = useLocation()
           location.state = { from: { pathname: "/pay", ... } }
           from = location.state?.from?.pathname || '/'
           from = "/pay"
           console.log("/pay", 'from')  // DevTools 可见

T=8ms    用户看到登录表单,输入用户名和密码

T=5000ms 用户点击"登录"按钮
         
T=5001ms handleSubmit 执行:
           e.preventDefault()
           formData.get("username") → "admin"
           formData.get("password") → "123456"
           验证通过!
           localStorage.setItem('isLogin', 'true')
           navigate("/pay", { replace: true })

T=5002ms hash 变为 '#/pay'(replace 模式,将 /login 从历史栈中替换掉)
         历史栈当前状态:[首页] → [/pay]

T=5003ms hashchange 触发 → HashRouter 更新 → React 重渲染

T=5004ms Routes 重新匹配 /pay → 再次渲染 ProtectRoute

T=5005ms ProtectRoute 再次执行:
           localStorage.getItem('isLogin') → 'true' → isLogin = true
           
           isLogin === true → 走正常分支!
           return <>ProtectRoute: {children}</>
           children = <Pay />
           渲染 <Pay />

T=5006ms 用户看到支付页面:
           "支付页面"
           "天龙八部 RAG 助手 - 年度会员"
           "¥99.00"
           [微信支付] [支付宝]
           [确认支付 ¥99.00]
           [退出登录]

T=8000ms 用户选择"支付宝",点击"确认支付"

T=8001ms handlePay 执行:
           setPaying(true) → 按钮变灰,显示"支付中..."
           setTimeout 1500ms

T=9500ms setTimeout 回调执行:
           setPaying(false)
           setPaySuccess(true)

T=9501ms 组件重新渲染,paySuccess === true:
           显示"支付成功"
           "订单号:ORD1691..."
           "金额:¥99.00"
           "即将跳转首页..."
           setTimeout 2000ms

T=11500ms setTimeout 回调执行:
            navigate('/', { replace: true })

T=11501ms hash 变为 '#/' → React 重渲染 → 匹配到 path="/" → 渲染 <Home />
          用户回到首页,整个流程结束。

4.13.3 关键数据流总结

javascript 复制代码
                    localStorage               location.state
                    ────────────               ──────────────
ProtectRoute 拦截: 读取 isLogin               写入 { from: location }
                    (判断是否放行)             (记住"用户从哪来")

Login 登录成功:    写入 isLogin='true'         读取 from.pathname
                    (标记已登录)               (知道"该回哪去")

Pay 退出登录:      删除 isLogin               (不涉及)
                    (清除登录状态)

全程数据的两个"隐式通道":
  - localStorage:浏览器存储,跨页面/跨刷新持久化,用于判断登录状态
  - location.state:路由状态,仅存在于当前导航会话的历史栈中,不持久化

相关推荐
badhope3384 小时前
当所有AI都在抄同一份作业:前端模板的趋同、困局与破局
前端·ai·ai编程·前端开发·tailwind·ui设计
光影少年4 小时前
RN的Fabric 渲染流程
运维·前端·javascript·react native·react.js·fabric
__zRainy__5 小时前
React开始:直接在网站中使用React.js
前端·react.js·前端框架
用户059540174466 小时前
大模型长记忆评测踩坑实录:2000条错位记忆,让我排查了整整3小时
前端·css
码林鼠6 小时前
webpack的基本配置
前端·webpack·node.js
用户938515635076 小时前
React Router 进阶:路由守卫、登录鉴权与状态传递
前端·javascript·全栈
kyriewen6 小时前
我重写了自己用了两年的防抖节流Hook——发现里面藏着3个隐藏bug
前端·javascript·面试
阿祖zu6 小时前
芝士就是力量!开源私有化部署与 GitHub 双向同步的个人知识笔记 App
前端·后端·ios
漂流瓶jz7 小时前
Webpack开发环境:观察模式/webpack-dev-server/HMR热更新
前端·javascript·webpack