前言:为什么需要前端路由?
回想一下传统网站是怎么工作的:
arduino
你点击"关于我们" → 浏览器向服务器发请求 → 服务器返回完整的 about.html → 页面白一下 → 显示新页面
每一次点击,都是一次完整的 HTTP 请求-响应周期。页面会整体刷新,出现短暂的白屏,体验就像是在翻阅一本很重的书------每次翻页都要用力。
而用了前端路由之后:
arduino
你点击"关于我们" → JS 拦截点击 → 浏览器地址栏 URL 变化 → 只替换页面的一部分内容
没有白屏,没有整体刷新,就像在使用一个原生 App。
这就是 SPA(Single Page Application,单页应用)的核心体验。整个应用从头到尾只加载一个 HTML 文件,所有"页面切换"都是在这个文件里完成的。
一、SPA 的渲染链路
先搞清楚 SPA 是怎么跑起来的。以 React 项目为例,整个渲染链路只有三步:
less
index.html ← ① 浏览器访问的唯一 HTML 页面
└── <div id="root"></div> ← 一个空的 div,React 的挂载点
└── <script src="/src/main.jsx"> ← 加载 React 入口脚本
│
└── main.jsx ← ② createRoot(document.getElementById('root'))
└── .render(<App />) ← ③ 将整个 React 组件树渲染进 #root
关键点在于:#root 是唯一的挂载点。之后所有"页面"的切换,其实都是在这个 div 内部替换不同的组件,浏览器根本没有刷新过。
这就是前端路由存在的意义------让 URL 变化时,不去找服务器要新页面,而是在本地匹配并渲染对应的组件。
二、前端路由的两种实现原理
前端路由有两种实现方式,分别对应两种 URL 格式。
2.1 HashRouter --- hash 模式
URL 长这样: http://localhost:5173/#/user/123
核心原理就一句话:监听 hashchange 事件 。URL 中 # 及其之后的部分叫 hash(锚点),它的变化不会触发页面刷新,也不会发送给服务器。
python
用户点击链接 → URL 从 /#/home 变为 /#/user/123
↓
浏览器触发 hashchange 事件
↓
JS 读取 window.location.hash → "#/user/123"
↓
去掉 # → "/user/123" → 匹配路由表 → 渲染 UserProfile 组件
服务器在这个过程中完全不知情------它只收到了 GET /,返回了 index.html,后面的事全是浏览器干的。
原理简化代码:
js
window.addEventListener('hashchange', () => {
const path = window.location.hash.slice(1) // "#/user/123" → "/user/123"
renderComponent(path)
})
2.2 BrowserRouter --- history 模式
URL 长这样: http://localhost:5173/user/123
这是现在的主流方式,URL 看起来和普通网页一模一样。它依赖 HTML5 引入的三个 History API:
| API | 作用 | React Router 中的对应 |
|---|---|---|
history.pushState(null, '', '/new') |
改变 URL,向历史栈追加一条记录 | <Link> 点击跳转 |
history.replaceState(null, '', '/new') |
改变 URL,替换当前记录 | <Navigate replace> |
window.addEventListener('popstate', ...) |
监听浏览器前进/后退按钮 | 路由的响应式更新 |
一次完整的点击跳转流程:
xml
用户点击 <Link to="/about">
│
├─ ① <Link> 拦截 <a> 标签的默认行为
│ 调用 history.pushState(null, '', '/about')
│ 浏览器地址栏变成 /about,但页面不刷新
│
├─ ② BrowserRouter 检测到 URL 变化
│ 更新内部 location 状态,通知所有子组件
│
└─ ③ <Routes> 重新匹配
/about → <About /> → 渲染 About 组件
原理简化代码:
js
// 1. 拦截导航点击
function handleNavigation(path) {
window.history.pushState(null, '', path) // 改 URL,不刷新
renderComponent(path) // 渲染对应组件
}
// 2. 响应前进/后退
window.addEventListener('popstate', () => {
renderComponent(window.location.pathname)
})
2.3 一张表看懂区别
| 维度 | HashRouter | BrowserRouter |
|---|---|---|
| URL 外观 | /#/user/123 |
/user/123 |
| 底层原理 | hashchange 事件 |
History API |
# 后内容发往服务器? |
❌ 不会 | ✅ 会(是正常路径) |
| 服务器需配置? | 不需要 | 必须(见下篇) |
| SEO | 差 | 好(配合 SSR) |
| 适用场景 | 后台系统、内部工具 | 面向用户的 C 端站点 |
三、React Router 的核心组件
理解了原理,我们来看看 React Router 给我们提供了哪些组件来使用路由。
3.1 BrowserRouter --- 路由容器
jsx
import { BrowserRouter as Router } from 'react-router-dom'
function App() {
return (
<Router> {/* 包裹整个应用,提供路由上下文 */}
<Navigation />
<RouterConfig />
</Router>
)
}
BrowserRouter 是所有路由组件的"地基"。它在内部做了两件事:
- 监听 URL 变化(通过 History API)
- 向下传递路由上下文 (所有子组件才能使用
useNavigate、useParams等 Hook)
如果你忘了包裹它,会直接报错:
scss
❌ useLocation() may be used only in the context of a <Router> component.
3.2 Routes + Route --- 路由定义
jsx
import { Routes, Route } from 'react-router-dom'
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Routes>:路由组的容器,内部<Route>的集合。它会遍历子路由,渲染第一个匹配的<Route>:单条路由规则,path定义匹配 URL,element指定渲染哪个组件
3.3 Link --- 导航链接
jsx
import { Link } from 'react-router-dom'
<Link to="/about">关于我们</Link>
<Link> 渲染后仍然是一个 <a> 标签,但它拦截了默认的点击行为 ,用 history.pushState 代替了浏览器的整页跳转。
| 组件 | 点击后 |
|---|---|
<a href="/about"> |
整页刷新 ❌ |
<Link to="/about"> |
局部更新 ✅ |
在 SPA 中,导航请用 <Link>,不要用 <a>。
3.4 Navigate --- 声明式重定向
jsx
import { Navigate } from 'react-router-dom'
// 只要这个组件被渲染,就会立即重定向
<Navigate to="/login" />
<Navigate replace to="/new-path" /> {/* replace 模式 */}
它的特点是声明式------写在 JSX 里,渲染即跳转。适合用在路由配置中做 URL 迁移或鉴权拦截。
3.5 Outlet --- 嵌套路由的插槽
jsx
import { Outlet } from 'react-router-dom'
function Product() {
return (
<>
<h1>产品列表</h1> {/* 父组件自己的内容 */}
<Outlet /> {/* 子路由的内容渲染在这里 */}
</>
)
}
<Outlet /> 就像一个"插槽"------父路由组件渲染时,匹配到的子路由组件会插入到这个位置。
四、6 种路由模式逐一拆解
理解了核心组件之后,我们来看看实际开发中会用到的所有路由模式。
模式 1:普通路由
最基础的用法------一个路径对应一个组件:
jsx
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
模式 2:动态路由
路径中用 :参数名 作为占位符,匹配任何值:
jsx
<Route path="/user/:id" element={<UserProfile />} />
bash
/user/12345 → id = "12345"
/user/abc → id = "abc"
/user → 不匹配(缺少参数)
在组件中通过 useParams() 获取参数值:
jsx
function UserProfile() {
const { id } = useParams() // 访问 /user/123 → id = "123"
return <h1>用户 {id}</h1>
}
URL 的结构回顾:
bash
https://www.example.com/user/123?tab=posts#comment
协议 域名 路径 动态参数 查询字符串 hash
模式 3:通配路由(404 页面)
* 匹配所有未被上面路由捕获的路径:
jsx
<Route path="*" element={<NotFound />} />
必须放在所有路由的最后,否则会"吞噬"所有正常路由。
一个实用的 404 组件:显示提示后自动跳回首页:
jsx
function NotFound() {
const navigate = useNavigate()
useEffect(() => {
setTimeout(() => navigate('/'), 3000) // 3 秒后自动回首页
}, [])
return <h1>404 Not Found</h1>
}
模式 4:嵌套路由
当多个页面共享同一个布局(侧边栏、标题等),用嵌套路由:
jsx
// 路由定义
<Route path="/products" element={<Product />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
// Product 父组件
function Product() {
return (
<>
<h1>产品列表</h1>
<nav>
<Link to="/products/new">新增</Link>
<Link to="/products/123">详情</Link>
</nav>
<Outlet /> {/* 子路由内容在这里渲染 */}
</>
)
}
访问 /products/123 时,渲染结果是:
html
<h1>产品列表</h1>
<nav>...</nav>
<ProductDetail /> <!-- 通过 Outlet 插入 -->
子路由的 path 不需要写完整路径,只写相对于父路由的部分即可。
模式 5:鉴权路由(路由守卫)
有的页面需要登录后才能访问。我们可以创建一个"守卫组件"包裹受保护页面:
jsx
// 路由定义
<Route path="/pay" element={
<ProtectRoute>
<Pay /> {/* 只有登录用户才能看到 */}
</ProtectRoute>
} />
// ProtectRoute 组件
function ProtectRoute({ children }) {
const isLogin = localStorage.getItem('isLogin') === 'true'
if (!isLogin) {
return <Navigate to="/login" /> // 未登录 → 跳转到登录页
}
return <>{children}</> // 已登录 → 渲染子组件
}
这里的 children 是 React 的插槽机制------夹在 <ProtectRoute> 标签中间的内容会自动作为 children prop 传入。下面两种写法完全等价:
jsx
<ProtectRoute>
<Pay />
</ProtectRoute>
// 等同于
<ProtectRoute children={<Pay />} />
执行流程:
bash
用户访问 /pay
→ ProtectRoute 检查 localStorage.isLogin
├─ true → 渲染 <Pay />
└─ false → <Navigate to="/login" />,URL 跳转到 /login
⚠️ 这是教学简化版(用 localStorage 模拟登录)。生产环境应使用 JWT 等真实认证方案。
模式 6:重定向路由
用于旧 URL 迁移或条件跳转:
jsx
// 方式 1:声明式(路由配置中用)
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
// 方式 2:命令式(组件逻辑中用)
const navigate = useNavigate()
navigate('/new-path', { replace: true })
| 方式 | 类型 | 适用场景 |
|---|---|---|
<Navigate> |
声明式(组件) | 路由配置中固定重定向 |
useNavigate() |
命令式(Hook) | 在事件回调或逻辑中触发 |
五、这些模式如何串联在一起?
一个完整的路由配置大概是这样的:
jsx
<BrowserRouter>
<Navigation /> {/* 全局导航栏 */}
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* 普通路由 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* 动态路由 */}
<Route path="/user/:id" element={<UserProfile />} />
{/* 嵌套路由 */}
<Route path="/products" element={<Product />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
{/* 鉴权路由 */}
<Route path="/pay" element={
<ProtectRoute><Pay /></ProtectRoute>
} />
{/* 重定向 */}
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
<Route path="/new-path" element={<NewPath />} />
{/* 通配路由(必须放最后) */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
几个关键规则:
<Routes>从上到下匹配,命中即停- 通配路由
path="*"必须放最后 Suspense包裹<Routes>即可实现全局路由懒加载
总结
这篇我们搞清楚了三个核心问题:
- 前端路由解决什么问题? --- 实现 SPA,让页面切换不刷新
- 底层怎么实现? --- HashRouter 靠
hashchange,BrowserRouter 靠 History API - 有哪些路由模式? --- 普通、动态、通配、嵌套、鉴权、重定向,共 6 种