从多页到单页:前端路由的演进与 React Router

从多页到单页:前端路由的演进与 React Router

摘要:从传统多页应用的整页刷新,到现代 SPA 的丝滑切换,前端路由经历了怎样的演变?本文从最朴素的 HTML 页面出发,手写 HashRouter 理解原理,再深入 React Router v6 的 Routes、懒加载、动态路由、嵌套路由等核心能力,带你彻底掌握前端路由的本质。

📑 目录

  • 多页应用时代:每一次跳转都是一次"重生"
  • SPA 的雏形:用 Hash 实现无刷新切换
  • 手写 HashRouter:理解前端路由的本质
  • React Router 登场:声明式路由的集大成者
  • 路由懒加载:让首屏加载"飞"起来
  • 动态路由:从 URL 中读取参数
  • 嵌套路由:页面级组件的层级结构
  • 404 与重定向:路由的"兜底"策略
  • 编程式导航:useNavigate 的灵活跳转
  • React 全家桶:路由在架构中的位置
  • 互动讨论

多页应用时代:每一次跳转都是一次"重生"

在早期 Web 开发中,每个页面都是一个独立的 HTML 文件。用户点击链接,浏览器向服务器请求新的 HTML,服务器返回完整页面,浏览器重新渲染------整个过程伴随着整页刷新白屏等待

来看一个最朴素的多页应用:

html

xml 复制代码
<!-- index.html -->
<header>
    <nav>
        <ul>
            <li><a href="index.html">首页</a></li>
            <li><a href="about.html">关于我们</a></li>
        </ul>
    </nav>
</header>
<main><h1>首页</h1></main>

html

xml 复制代码
<!-- about.html -->
<header>
    <nav>
        <ul>
            <li><a href="index.html">首页</a></li>
            <li><a href="about.html">关于我们</a></li>
        </ul>
    </nav>
</header>
<main><h1>关于我们</h1></main>

这两个页面中,<header><nav> 的代码是完全重复的。多页应用的痛点显而易见:

痛点 表现
整页刷新 每次跳转都重新加载整个页面,包括 header、footer 等不变的公共部分
白屏等待 页面切换时有一段空白期,用户体验差
代码冗余 公共部分在每个 HTML 中重复书写,维护成本高
资源浪费 相同的 CSS、JS 在每个页面中重复下载

在移动端兴起、用户体验要求越来越高的背景下,SPA(单页应用) 应运而生。SPA 的核心思想是:只加载一个 HTML 页面,后续所有"页面切换"都由 JavaScript 在客户端完成,不刷新浏览器

SPA 的雏形:用 Hash 实现无刷新切换

SPA 的关键问题是:如何在不刷新页面的情况下,改变 URL 并让浏览器"记住"当前状态?

答案是 URL 的 Hash(哈希)部分

在 URL https://example.com/index.html#/page1 中,#/page1 就是 hash 部分。改变 hash 有一个关键特性:改变 URL 的 hash 不会触发页面刷新

这个特性恰好满足 SPA 的需求------改变 URL 但不刷新页面。同时,浏览器提供了 hashchange 事件,可以监听 hash 的变化。

下面这段代码演示了 hash 的基本特性:

html

xml 复制代码
<!-- demo.html -->
<a name="top"></a>
<a href="#bottom">回到底部</a>
<div style="height: 200vh;background-color: yellow;"></div>
<a href="#top">回到顶部</a>
<div style="height: 200vh;background-color: red;"></div>
<a name="bottom"></a>

<script>
    window.addEventListener('hashchange', function(event) {
        console.log('hash改变');
        console.log(event.newURL);
        console.log(event.oldURL);
    });
</script>

这个例子展示了 hash 的两个传统用途:

  1. 锚点链接<a href="#bottom"> 跳转到页面上 name="bottom" 的位置
  2. 监听变化hashchange 事件可以捕获 hash 的变化

hash 最初的设计是为了页面内导航,但聪明的开发者发现它的特性正好可以用来做前端路由------改变 hash 不刷新页面,且能通过事件监听到变化。

手写 HashRouter:理解前端路由的本质

理解了 hash 的原理,我们完全可以手写一个 HashRouter。这正是理解前端路由本质的最好方式。

html

xml 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>SPA</title>
</head>
<body>
    <header>
        <nav>
            <ul>
                <li><a href="#/page1">页面一</a></li>
                <li><a href="#/page2">页面二</a></li>
                <li><a href="#/page3">页面三</a></li>
            </ul>
        </nav>
    </header>
    <div id="container"></div>
    <script>
        class HashRouter {
            constructor() {
                this.routers = {};
                window.addEventListener('hashchange', this.load.bind(this));
            }
            register(hash, callback) {
                this.routers[hash] = callback;
            }
            load() {
                let hash = location.hash.slice(1);
                let handler = this.routers[hash];
                if (handler) {
                    handler.call(this);
                }
            }
        }

        let router = new HashRouter();
        let container = document.getElementById('container');
        router.register('/page1', () => container.innerHTML = '页面一');
        router.register('/page2', () => container.innerHTML = '页面二');
        router.register('/page3', () => container.innerHTML = '页面三');
    </script>
</body>
</html>

这段代码的核心理念:

  1. 路由注册register 方法将 hash 路径与回调函数绑定
  2. 事件监听hashchange 事件触发 load 方法
  3. 路由匹配load 方法从 location.hash 中提取路径,找到对应的回调执行
  4. 动态渲染 :回调函数修改 container 的 innerHTML,实现"页面切换"

这个过程揭示了前端路由的本质:监听 URL 变化 → 匹配路由规则 → 渲染对应的组件/内容。所有现代前端路由框架------无论是 React Router、Vue Router 还是 Angular Router------都是这个模式的封装和增强。

React Router 登场:声明式路由的集大成者

手写 HashRouter 让我们理解了原理,但在实际项目中,我们使用 React Router------React 生态中最成熟的前端路由解决方案。

React Router 的核心设计思想是: "页面上用到的,全部都是组件" 。路由配置本身就是组件树的一部分。

jsx

javascript 复制代码
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';

const App = () => {
    return (
        <Router>
            <Navigation />
            <div id="container">
                <Routes>
                    <Route path='/' element={<Home />} />
                    <Route path='/about' element={<About />} />
                    <Route path='/user/:id' element={<UserProfile />} />
                    <Route path='*' element={<NotFound />} />
                </Routes>
            </div>
        </Router>
    );
};

核心组件解析:

组件 职责
Router 路由容器,提供路由上下文,监听 URL 变化
Routes 路由配置数组,负责匹配当前 URL 并渲染对应的 Route
Route 单项路由配置,path 定义匹配规则,element 指定渲染的组件
Navigate 重定向组件,渲染时自动跳转到指定路径

HashRouter vs BrowserRouter:

模式 URL 示例 原理 适用场景
HashRouter /#/about 监听 hashchange 事件 静态部署、无需服务器配置
BrowserRouter /about 使用 History API,需服务器配置 fallback 生产环境、需要美观 URL

HashRouter 无需服务器额外配置,适合学习、演示和纯静态部署。真实生产项目如果能配置服务端回退,BrowserRouter 是更好的选择。

路由懒加载:让首屏加载"飞"起来

在大型 SPA 中,所有页面组件如果一次性加载,会导致首屏加载缓慢。路由懒加载 正是为了解决这个问题------只加载当前需要的页面,非当前页面不加载

在 React 中,懒加载通过 React.lazy + Suspense 实现:

jsx

javascript 复制代码
import { lazy, Suspense } from 'react';

// ❌ 新手写法:所有页面一加载就下载
// import Home from './pages/Home';
// import About from './pages/About';

// ✅ 懒加载:只在需要时才下载
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const UserProfile = lazy(() => import('./pages/UserProfile'));
const NotFound = lazy(() => import('./pages/NotFound'));

const App = () => {
    return (
        <Router>
            <Suspense fallback={<div>Loading......</div>}>
                <Routes>
                    <Route path='/' element={<Home />} />
                    <Route path='/about' element={<About />} />
                    <Route path='/user/:id' element={<UserProfile />} />
                    <Route path='*' element={<NotFound />} />
                </Routes>
            </Suspense>
        </Router>
    );
};

lazy + Suspense 的工作机制:

  1. lazy(() => import('./pages/Home')) :返回一个动态加载的组件。Webpack 或 Vite 会将该组件单独打包成一个 chunk
  2. Suspense :包裹懒加载组件,在组件加载期间显示 fallback 内容
  3. 按需加载:只有当路由匹配时,对应的 chunk 才会被下载和执行

两种懒加载方式对比:

方式 优点 缺点
React.lazy + Suspense 可配置 fallback,用户体验好 需要 Suspense 包裹
Route.lazy(React Router 自带) 配置更简洁 不支持 fallback,可能有白屏

在实际项目中,推荐使用 React.lazy + Suspense 的方式,因为 fallback 能让用户感知到"正在加载",而不是面对白屏。

动态路由:从 URL 中读取参数

动态路由允许 URL 中包含可变参数,例如 /user/123 中的 123 就是用户 ID。在 React Router 中,动态参数用 :paramName 语法定义:

jsx

ini 复制代码
// 定义动态路由
<Route path='/user/:id' element={<UserProfile />} />

在组件中,通过 useParams Hook 获取参数:

jsx

javascript 复制代码
import { useParams } from 'react-router-dom';

const UserProfile = () => {
    const { id } = useParams();  // 从 URL 中提取 :id 的值
    return <h3>用户 ID: {id}</h3>;
};

useParams 返回一个对象,键是路由中定义的参数名,值是 URL 中对应的实际值。当访问 /user/123 时,id 的值为 "123"

动态路由的典型应用场景:

  • 用户个人主页:/user/:userId
  • 商品详情页:/product/:productId
  • 文章详情页:/post/:postId

嵌套路由:页面级组件的层级结构

嵌套路由是 React Router v6 中最强大的特性之一。它允许在父路由中定义子路由,形成页面组件的层级结构。

jsx

xml 复制代码
<Route path='/products' element={<Products />}>
    <Route path=':productId' element={<Detail />} />
    <Route path='new' element={<NewProduct />} />
</Route>

在父组件中,子路由的内容通过 Outlet 组件渲染(React Router v6 中不再需要显式使用 Outlet,子路由会在父组件的 element 位置自动渲染):

jsx

javascript 复制代码
// Products 组件
const Products = () => {
    return (
        <div>
            <h2>产品列表</h2>
            {/* 子路由(Detail 或 NewProduct)会在这里渲染 */}
        </div>
    );
};

嵌套路由的层级结构清晰反映了页面的 UI 层级。最佳实践是嵌套深度控制在 3 层以内,过深的嵌套会增加理解和维护的复杂度。

jsx

javascript 复制代码
// 子路由组件可以继续嵌套
const Detail = () => {
    const { productId } = useParams();
    return (
        <>
            <h3>产品详情</h3>
            <span>{productId}</span>
        </>
    );
};

404 与重定向:路由的"兜底"策略

404 Not Found

当用户访问一个不存在的路径时,应该展示 404 页面。在 React Router 中,使用 path='*' 作为通配符匹配所有未定义的路径:

jsx

ini 复制代码
<Route path='*' element={<NotFound />} />

重要: path='*' 路由必须放在所有 Route最后一项 ,因为 Routes 会从上到下依次匹配 ,匹配到第一个符合条件的 Route 就停止。如果 * 放在前面,它会拦截所有请求,后面的路由永远不会被匹配。

jsx

ini 复制代码
const NotFound = () => {
    const navigate = useNavigate();
    useEffect(() => {
        setTimeout(() => {
            navigate('/');  // 3秒后自动跳转回首页
        }, 3000);
    }, []);
    return <>Not Found</>;
};

重定向(Redirect)

使用 Navigate 组件实现声明式重定向:

jsx

ini 复制代码
{/* 处理旧域名/旧路径,跳转到新地址 */}
<Route path='/old-path' element={<Navigate replace to='/' />} />

replace 属性表示替换当前历史记录,而不是新增一条。这对于处理旧地址迁移非常有用------用户访问旧链接时自动跳转到新页面,且不会在浏览器历史中留下旧地址的记录。

编程式导航:useNavigate 的灵活跳转

除了声明式的 <Link><Navigate>,React Router 还提供了编程式导航 的 Hook------useNavigate

jsx

javascript 复制代码
import { useNavigate } from 'react-router-dom';

const NotFound = () => {
    const navigate = useNavigate();
    useEffect(() => {
        setTimeout(() => {
            navigate('/');  // 3秒后跳转回首页
        }, 3000);
    }, []);
    return <>Not Found</>;
};

useNavigate 返回一个函数,调用时传入目标路径即可跳转。它常用于:

  • 表单提交成功后跳转
  • 定时跳转(如 404 页面自动返回)
  • 权限校验失败后跳转到登录页
  • 复杂的业务逻辑中动态决定跳转目标

useNavigate 是 v6 中替代 v5 的 useHistory 的新 API。

React 全家桶:路由在架构中的位置

在一个完整的 React 项目中,路由是前端架构的核心基础设施之一,与状态管理、UI 组件库共同构成"React 全家桶":

工具 职责 类比(Vue 生态)
React 编写组件、搭建界面、响应式 UI Vue
react-router-dom 前端路由、SPA 页面切换 Vue Router
Zustand 状态管理 Pinia

页面级组件 vs 普通组件的目录划分:

在实际项目中,组件通常按级别分目录存放:

text

bash 复制代码
src/
├── components/          # 普通组件(可复用 UI 单元)
│   └── Navigation/      # 导航栏组件
├── pages/               # 页面级组件(与路由直接打交道)[reference:53]
│   ├── Home/
│   ├── About/
│   ├── UserProfile/
│   ├── NotFound/
│   └── Products/
│       ├── Detail/
│       └── New/
└── App.jsx

页面级组件(pages/)与路由直接打交道,是路由配置中 element 指向的组件。普通组件(components/)则被页面级组件引用,不直接与路由交互。

互动讨论

💬 HashRouter 和 BrowserRouter 怎么选?

HashRouter 无需服务器配置,适合学习演示和静态部署。BrowserRouter 提供更美观的 URL,但需要服务器配置 fallback(所有路径都指向 index.html)。生产环境首选 BrowserRouter。

💬 lazy(() => import('./pages/Home')) 的原理是什么?

import() 是动态导入语法,Webpack 或 Vite 会将其打包成独立的 chunk。React.lazy 包装后,该组件只在首次渲染时才加载对应的 chunk。这大大减少了首屏的 JS 体积。

💬 path='*' 为什么必须放在最后?

Routes 会从上到下依次匹配,匹配到第一个符合条件的 Route 就停止。如果 * 放在前面,它会匹配所有路径,后面的路由永远不会被匹配到。

💬 useParamsuseNavigate 能用在普通组件里吗?

可以,但前提是这些组件必须在 Router 的上下文中(即被 <Router> 包裹)。如果组件在 Router 外部使用这两个 Hook,会报错。

💬 嵌套路由的深度应该控制在多少?

建议控制在 3 层以内。过深的嵌套会增加理解和维护的复杂度。如果发现嵌套超过 3 层,考虑将部分子路由提升为独立路由,或者重构页面结构。

相关推荐
北墨NoLimit1 小时前
TRAE Work实战:把办公Agent竞品调研从2-3天压到32分钟,有完整指令模板
前端·人工智能·数据可视化
颜进强1 小时前
Calude Code - 25 CodeGraph:让 AI 真正读懂你的代码库
前端·后端·ai编程
七牛开发者1 小时前
Agent 小知识|长任务不重来:Agent 状态保存的工程设计
前端·javascript·后端
weixin_431600442 小时前
做 Agent 会用到的 Node API(3):异步与流
前端·学习·ai·agent·ai编程
程序员黑豆2 小时前
鸿蒙应用开发之生命周期方法完全指南
前端·harmonyos
一点一木2 小时前
第 23 届 ChinaJoy 刚结束,我把 965 个展台做成了 3D 云展馆
前端·人工智能
wordbaby2 小时前
前端请求缓存写了,同一个接口为什么还是打了十几次?
前端·promise
不可能掉发2 小时前
Env Guard:让浏览器一眼分清生产、测试和开发环境
前端·javascript·chrome·测试工具·html·开源软件·个人开发
饼干哥哥2 小时前
Codex 必改的8 个基础配置
前端·人工智能·后端