React Router 路由机制不难:一篇搞懂 URL 跳转背后的原理

点击导航切换页面、刷新后保持当前视图、用浏览器前进后退键跳转...... 这些看似简单的操作背后,其实藏着不少设计巧思。今天就从传统开发的痛点说起,聊聊 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 对象,提供了两个「逆天」方法:pushStatereplaceState。它们能直接修改浏览器的地址栏 URL,而且完全不会触发页面刷新

比如执行这行代码:

js 复制代码
history.pushState(null, null, '/about');

地址栏会瞬间变成 https://xxx.com/about,但页面一点都不刷新 ------ 这简直是为前端路由量身定做的!

不过它也有个小麻烦:pushState 不会触发任何事件,我们没法直接监听 URL 变化。怎么办?React Router 用了个组合拳:

  1. 拦截所有点击事件(比如 <Link> 组件的点击),用 pushState 改 URL,同时手动触发组件更新
  2. 监听浏览器的 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 的核心组件就很清晰了:

  1. <BrowserRouter><HashRouter>:二选一,决定用 History API 还是 hash 初始化路由环境,作为路由的根组件,还维护当前 URL 状态。
  2. <Routes><Route><Routes> 作为路由容器,负责根据当前 URL 匹配最符合的 <Route><Route> 定义 pathelement 的映射关系。
  3. <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"> 时:

  1. <Link> 阻止默认跳转,调用 history.pushState 把 URL 改成 /about
  2. <BrowserRouter> 检测到 URL 变化,告诉 <Routes> 该换组件了
  3. <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> 其实是拦截点击事件,做到了不仅会用,还知道为什么要这么用。

相关推荐
程序视点10 分钟前
已成绝版!8月5日即将下线!b站国际版
前端
遂心_12 分钟前
React初学者必备:用“状态管家”Reducer轻松管理复杂状态!
前端·javascript·react.js
老神在在00115 分钟前
SpringMVC2
java·前端·学习·spring·java-ee
老神在在00116 分钟前
SpringMVC3
java·前端·学习·spring·java-ee
李明卫杭州20 分钟前
前端实现多标签页通讯
前端·javascript
前端领航者20 分钟前
国际化LTR&RTL布局实战
前端·css
FanetheDivine22 分钟前
解决@ant-design/icons导致的样式异常
react.js·ant design
贝加尔湖Pan22 分钟前
图片预加载和懒加载
前端
在钱塘江25 分钟前
《你不知道的JavaScript-上卷》第二部分-this和对象原型-笔记-6-行为委托
前端·javascript
Point25 分钟前
[ahooks] useControllableValue源码阅读
前端·javascript