点击导航切换页面、刷新后保持当前视图、用浏览器前进后退键跳转...... 这些看似简单的操作背后,其实藏着不少设计巧思。今天就从传统开发的痛点说起,聊聊 React Router 是如何用不刷新页面的方式实现路由跳转的,以及它底层那套机制到底有多精妙。
为什么 a 标签会让用户抓狂?
刚学前端时,我总觉得用 <a>
标签跳转页面很方便 ------ 写个 href 就能跳转到新页面,多简单。但真正做项目时才发现,这种方式简直是用户体验的"隐形杀手"。
比如你在逛一个电商网站,点了「商品详情」的 a 标签后:
- 浏览器会把当前页面整个丢掉,向服务器发请求要新 HTML
- 等待服务器响应的过程中,屏幕一片空白(白屏时间往往长达几百毫秒)
- 拿到新页面后,浏览器又得重新解析 CSS、执行 JS、渲染 DOM,整个过程像「重启」一样
更要命的是,重复请求了很多不变的资源 ------ 导航栏、页脚这些全局组件,明明每个页面都有,却要跟着每次跳转重新加载。这种「全量刷新」的模式,在用户眼里就是「卡」和「慢」的代名词。
一个页面如何装下整个应用?
单页面应用(SPA)的出现,彻底改变了这种局面。它的核心思路特别有意思:整个应用从头到尾只有一个 HTML 页面,所有页面切换都是前端组件的替换。
在 React 里,这事儿是这么实现的:
- 把「首页」「关于我们」这些页面做成独立的组件(页面级组件)
- 用
<Routes>
和<Route>
定义URL 路径 和组件 的对应关系(比如/about
对应<About>
组件) - 当 URL 变化时,只替换页面中间的内容区组件,导航栏、侧边栏这些不变的部分完全不动
你可以理解成「舞台换景」------ 整个剧场(HTML 页面)不变,只根据剧情(URL)换背景板(组件)。这种局部更新的方式,白屏没了,加载速度快了,用户体验直接上了一个台阶。
URL 变了,页面怎么做到不刷新?
这是路由最核心的技术难点。传统 a 标签跳转时,浏览器会默认触发「页面刷新」,而我们要的是「URL 变了,但浏览器假装没看见,继续用当前页面处理」。
React Router 用了两种方案解决这个问题,各自有巧妙之处。
方案一:用 hash 做路由
你肯定见过带 #
的 URL,比如 https://xxx.com/#/about
。这个 #
后面的部分(hash),原本是用来定位页面锚点的(比如点击回到顶部按钮跳转到页面指定位置),但它有个被前端开发者「盯上」的特性:hash 变化时,浏览器不会发送请求,也不会刷新页面。
更妙的是,hash 变化时会触发 hashchange
事件 ------ 这意味着我们可以用 JS 监听这个事件,手动决定该显示哪个组件。
举个极简的例子,自己实现一个 hash 路由:
html
<!-- HTML -->
<ul>
<li><a href="#home">首页</a></li>
<li><a href="#about">关于我们</a></li>
</ul>
<div id="content"></div>
<script>
// 监听 hash 变化
window.addEventListener('hashchange', () => {
const hash = window.location.hash; // 比如 "#about"
const content = document.getElementById('content');
// 根据 hash 显示不同内容
switch(window.location.hash){
case '#home':
content.innerHTML='<h2>Home</h2><p>欢迎来到首页</p>';
break;
case '#about':
content.innerHTML='<h2>About</h2><p>关于我们页面</p>';
break;
default: content.innerHTML='not found 404';
break
}
});
</script>
React Router 里的 <HashRouter>
本质上就是这么玩的,只不过把「手动改 innerHTML」换成了「React 组件渲染」。
但这种方案有个明显的缺点:URL 里带个 #
显得不够正规,而且有些场景下会和真正的锚点功能冲突。于是就有了第二种更优雅的方案。
方案二:HTML5 History API
HTML5 新增的 history
对象,提供了两个「逆天」方法:pushState
和 replaceState
。它们能直接修改浏览器的地址栏 URL,而且完全不会触发页面刷新。
比如执行这行代码:
js
history.pushState(null, null, '/about');
地址栏会瞬间变成 https://xxx.com/about
,但页面一点都不刷新 ------ 这简直是为前端路由量身定做的!
不过它也有个小麻烦:pushState
不会触发任何事件,我们没法直接监听 URL 变化。怎么办?React Router 用了个组合拳:
- 拦截所有点击事件(比如
<Link>
组件的点击),用pushState
改 URL,同时手动触发组件更新 - 监听浏览器的
popstate
事件(用户点前进 / 后退按钮时触发),此时再根据新 URL 切换组件
看个简化版的实现思路:
jsx
// 拦截 a 标签点击
document.querySelectorAll('a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault(); // 阻止 a 标签默认跳转
const url = link.getAttribute('href');
history.pushState(null, null, url); // 改 URL
renderComponentByUrl(url); // 手动渲染对应组件
});
});
// 监听前进/后退按钮
window.addEventListener('popstate', () => {
renderComponentByUrl(window.location.pathname);
});
// 根据 URL 渲染组件
function renderComponentByUrl(url) {
// 这里就是 React 渲染对应组件的逻辑
}
React Router 的 <BrowserRouter>
就是基于这套逻辑,所以它的 URL 看起来特别干净(比如 /about
),和后端路由的 URL 几乎没区别。
React Router 是怎么把这些串起来的?
理解了 hash 和 History API 的底层原理,再看 React Router 的核心组件就很清晰了:
<BrowserRouter>
和<HashRouter>
:二选一,决定用 History API 还是 hash 初始化路由环境,作为路由的根组件,还维护当前 URL 状态。<Routes>
与<Route>
:<Routes>
作为路由容器,负责根据当前 URL 匹配最符合的<Route>
;<Route>
定义path
与element
的映射关系。<Link>
:替代原生<a>
标签,点击时通过 History API 或 hash 修改 URL,避免页面刷新。
举个最常见的用法:
jsx
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
function App() {
return (
<Router>
{/* 导航栏 */}
<nav>
<Link to="/">首页</Link>
<Link to="/about">关于我们</Link>
</nav>
{/* 内容区:根据 URL 显示不同组件 */}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Router>
);
}
当你点击 <Link to="/about">
时:
<Link>
阻止默认跳转,调用history.pushState
把 URL 改成/about
<BrowserRouter>
检测到 URL 变化,告诉<Routes>
该换组件了<Routes>
匹配到path="/about"
,把内容区换成<About>
组件
两个细节
用 <BrowserRouter>
时,有个坑很容易踩:本地开发好好的,部署到服务器后,直接访问 /about
会报 404 错误。
这是因为:
- 当你在应用内部点击
<Link>
跳转时,是前端 JS 用pushState
改的 URL,服务器根本不知道 - 但直接在地址栏输
/about
回车时,浏览器会真的向服务器发请求要/about
这个资源 - 如果服务器没配置「所有路径都返回 index.html」,就会返回 404
解决办法也简单:让服务器把所有请求(比如 /about
、/user/123
)都指向同一个 index.html
,剩下的路由逻辑交给前端处理。而 <HashRouter>
因为 hash 不会发给服务器,就没这个问题(这也是它兼容性好的原因之一)。
另一个容易被忽略的细节是路由匹配的优先级 。在 React Router v6 之后,<Routes>
会自动选择最具体的路由规则,而不是按定义顺序匹配。比如同时定义:
jsx
<Route path="/user" element={<UserList />} />
<Route path="/user/:id" element={<UserDetail />} />
当访问 /user/123
时,会优先匹配 path="/user/:id"
而不是 /user
------ 因为前者的路径更具体。但如果把顺序反过来写,结果依然如此,这和 v5 版本的「按顺序匹配」逻辑完全不同。
所以有时候出现了明明先定义了 /user
,为什么访问 /user/123
没跳转到列表页这种情况?其实就是没理解这种精准匹配优先 的机制。解决办法也很直接:不用刻意调整顺序,确保路由路径设计符合具体路径在前、模糊路径在后 的逻辑即可(比如先定义 /user/:id
再定义 /user
也能正常工作)。
最后
刚开始用 React Router 时,我总觉得它的组件设计很抽象 ------ 为什么要包个 <Router>
?<Routes>
和 <Route>
到底起啥作用?
直到自己用原生 JS 实现了一遍简易路由才明白,那些组件本质上是把监听 URL 变化、匹配组件、渲染更新这些重复工作封装起来了。
知道了它用 hash 或 History API 做底层,知道了 <Link>
其实是拦截点击事件,做到了不仅会用,还知道为什么要这么用。