从多页到单页:前端路由的演进与 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 的两个传统用途:
- 锚点链接 :
<a href="#bottom">跳转到页面上name="bottom"的位置 - 监听变化 :
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>
这段代码的核心理念:
- 路由注册 :
register方法将 hash 路径与回调函数绑定 - 事件监听 :
hashchange事件触发load方法 - 路由匹配 :
load方法从location.hash中提取路径,找到对应的回调执行 - 动态渲染 :回调函数修改
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 的工作机制:
lazy(() => import('./pages/Home')):返回一个动态加载的组件。Webpack 或 Vite 会将该组件单独打包成一个 chunkSuspense:包裹懒加载组件,在组件加载期间显示fallback内容- 按需加载:只有当路由匹配时,对应的 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 就停止。如果 * 放在前面,它会匹配所有路径,后面的路由永远不会被匹配到。
💬 useParams 和 useNavigate 能用在普通组件里吗?
可以,但前提是这些组件必须在 Router 的上下文中(即被 <Router> 包裹)。如果组件在 Router 外部使用这两个 Hook,会报错。
💬 嵌套路由的深度应该控制在多少?
建议控制在 3 层以内。过深的嵌套会增加理解和维护的复杂度。如果发现嵌套超过 3 层,考虑将部分子路由提升为独立路由,或者重构页面结构。