从零理解 React Router v6:每一个 API 都是怎么工作的

从零理解 React Router v6:每一个 API 都是怎么工作的

前言:为什么需要前端路由?

打开一个传统网站,点击导航链接 → 浏览器向服务器发起完整请求 → 服务器返回一整页 HTML → 页面"白一下"再渲染。每次点击都是一次完整的 HTTP 往返

而 SPA(Single Page Application,单页应用)的理念是:HTML/CSS/JS 只加载一次 ,之后所有的"页面切换"都在浏览器本地完成------URL 变了,页面内容变了,但没有发生真正的服务器请求。这就是前端路由要解决的问题。

React Router 是目前 React 生态里最主流的前端路由方案。本文以 v6 版本为例,结合一个完整的 Demo 项目,从最底层的原理到每一行代码,逐一拆解。


一、Link:为什么不能用 <a> 标签?

javascript 复制代码
// Navigation.jsx
import { Link } from 'react-router-dom';

function Navigation() {
  return (
    <nav>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
        <li><Link to="/user/123">小家</Link></li>
        <li><Link to="/products/123">商品详情</Link></li>
        <li><Link to="/products/new">商品新增</Link></li>
      </ul>
    </nav>
  );
}

底层机制

如果你直接用 <a href="/about">,浏览器会执行一次完整的页面导航

  1. 解析 href
  2. 发起 HTTP GET 请求
  3. 接收服务器返回的 HTML 文档
  4. 销毁当前页面的所有 JS 状态(变量、事件监听、React 组件树全部消失)
  5. 解析新 HTML,重新初始化整个 React 应用

对于 SPA 来说,第 4 步是灾难性的------你的应用状态、Redux store、组件内部状态全部归零。

Link 组件做了什么呢?它在底层仍然渲染一个 <a> 标签,但做的事情完全不同:

  1. 拦截 <a>click 事件,preventDefault() 阻止浏览器默认跳转
  2. 调用 history.pushState()hashChange 更新浏览器地址栏 URL
  3. 通知 React Router 内部的路由匹配器:URL 变了,重新匹配一下
  4. React Router 根据新 URL 决定渲染哪个组件,页面不刷新
scss 复制代码
┌──────────────┐    click     ┌──────────────────┐
│  <a href>    │ ──────────▶  │ 浏览器全量刷新     │  状态全丢
└──────────────┘              └──────────────────┘

┌──────────────┐    click     ┌──────────────────┐
│  <Link to>   │ ──────────▶  │ preventDefault()  │
│              │              │ → pushState()    │  状态保留
│              │              │ → 重新匹配 Route  │
└──────────────┘              └──────────────────┘

这就是前端路由的第一块基石:用 JS 接管浏览器的导航行为


二、HashRouter# 后面的世界归前端管

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

为什么选 HashRouter 而不是 BrowserRouter?

URL 里 # 及其之后的部分叫 hash (片段标识符)。浏览器有一个关键特性:hash 的变化不会触发页面请求

bash 复制代码
https://example.com/#/about
                      └────────── hash 部分

当你从 #/home 改到 #/about

  • 浏览器不会向服务器发请求
  • 但会触发 hashchange 事件
  • JS 可以监听这个事件,根据 hash 值切换组件

React Router 的 HashRouter 内部就是封装了 window.addEventListener('hashchange', ...)

BrowserRouter 依赖的是 HTML5 History API(pushState + popstate),它需要服务器配合 :当用户直接访问 /about 或刷新页面时,服务器必须返回 index.html 而不是 404。HashRouter 没有这个限制------# 前面的路径不变,服务器永远只看到 /

对于 Demo、静态部署、GitHub Pages,HashRouter 是最省心的选择。


三、Routes + Route:路由匹配的核心

xml 复制代码
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
  <Route path="/user/:id" element={<UserProfile />} />
  <Route path="/products" element={<Products />}>
    <Route path=":productId" element={<ProductDetail />} />
    <Route path="new" element={<NewProductDetail />} />
  </Route>
  <Route path="/old-path" element={<Navigate replace to="/new-path" />} />
  <Route path="*" element={<NotFound />} />
</Routes>

Routes 是"容器",Route 是"规则"

可以把 Routes 理解成一个路由匹配引擎 。每当 URL 变化时,它遍历内部所有的 Route,找到第一个 path 匹配的,只渲染那一个组件。

注意一个非常关键的语义:有且只有一个 Route 被渲染 。v5 时代的 Switch 组件在 v6 中被 Routes 取代,并且匹配逻辑更加严格------路径匹配失败就是失败,不会 fallback 到其他 Route(除非你显式写了 path="*")。

path 的匹配规则

path 写法 匹配的 URL 说明
"/" #/ 根路径,精确匹配
"/about" #/about 精确匹配
"/user/:id" #/user/123#/user/abc :id 是动态参数
"/products" #/products 作为父路由,不会自动匹配子路径
"*" 任何未被上面匹配的路径 通配符,兜底 404

element 属性接收的是 JSX 元素<Home />),而不是组件引用(Home)。这意味着你可以在 element 中直接写 JSX 表达式、传 props,非常灵活。


四、路由懒加载:按需下载,首屏提速

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

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 Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/Products/Detail'));
const NewProductDetail = lazy(() => import('./pages/Products/New'));

正常 import 的问题

javascript 复制代码
// 静态 import------打包工具会把所有页面打进一个 bundle
import Home from './pages/Home';
import About from './pages/About';

用户访问首页时,About、UserProfile、Products 等所有页面的代码都通过网络下载了,尽管用户可能根本不会点进去。首页加载速度 = 所有页面代码的总下载时间,这显然不合理。

lazy + 动态 import 如何解决

ini 复制代码
const About = lazy(() => import('./pages/About'));

import() 是 ES2020 的动态导入语法,它返回一个 Promise。打包工具(Vite/Webpack)看到 import('./pages/About') 时,会把 About 页面的代码单独拆成一个 chunk 文件,不和主 bundle 打包在一起。

lazy() 是 React 提供的包装器:

scss 复制代码
组件渲染时 → 发现是 lazy 组件 → 触发 import() → 发起网络请求下载 chunk
                                                         │
                                            ┌───────────┘
                                            ▼
                                       下载完成 → React 渲染组件
                                            │
                                            ▼
                                       下载失败 → 抛出错误(需 ErrorBoundary 捕获)

Suspense:加载中的过渡态

xml 复制代码
<Suspense fallback={<div>Loading...</div>}>
  <Navigation />
  <div id="container">
    <Routes>
      {/* ... */}
    </Routes>
  </div>
</Suspense>

在 chunk 文件下载期间,lazy 组件会"挂起"(suspend),React 向上找最近的 <Suspense> 边界,渲染它的 fallback 内容。下载完成后,自动替换为真正的组件。

fallback 可以是任何 React 元素------loading 动画、骨架屏、进度条,完全由你控制。


五、动态路由参数:useParams 的 Hooks 哲学

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

function UserProfile() {
  let { id } = useParams();
  console.log(id);
  return (
    <h2>User Profile: {id}</h2>
  );
}

URL 参数如何变成 JS 变量

路由配置中 path="/user/:id":id 是一个动态段(dynamic segment),冒号开头表示"这里可以是任何值"。

当用户访问 #/user/123

bash 复制代码
URL:    /user/123
Route:  /user/:id
               └── 匹配 "123"

useParams() 返回: { id: "123" }

模板匹配 → 提取变量 → Hooks 注入,整个链路干净利落。不需要手动解析 window.location,不需要写正则,不需要声明参数类型------URL 里有什么,useParams() 就给你什么。

"召之即来"的 Hooks 思想

注意 useParams() 不需要传任何参数,它自动感知"当前匹配的路由是哪个"。这是因为 React Router 内部使用了 ContextRoutes 组件在匹配成功后,把参数写入 Context,useParams 只是从 Context 中读取。

scss 复制代码
Routes 匹配成功 → 提取 params → 写入 RouterContext
                                        │
                          useParams() ← 读取 ← 返回 { id, productId, ... }

这避免了 Props 层层传递(prop drilling)------只要组件在 <Router> 子树内,随时 useParams() 就能拿到参数。


六、嵌套路由:父路由 + <Outlet> 出口

javascript 复制代码
// Products/index.jsx ------ 父级页面
import { Outlet } from 'react-router-dom';

const Products = () => {
  return (
    <>
      <h1>产品列表</h1>
      <Outlet />
    </>
  );
};

嵌套路由的结构

xml 复制代码
// App.jsx 中的配置
<Route path="/products" element={<Products />}>
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProductDetail />} />
</Route>

这里 "/products" 是父路由,":productId""new" 是两个子路由。关键区别:

  • 子路由的 path 是相对路径 ,不需要写 /products/:productId,直接写 :productId 即可
  • 子路由组件渲染在 <Outlet /> 的位置,而不是替换整个页面

实际渲染效果

xml 复制代码
URL: #/products/123

<Products>               ← 父路由组件
  <h1>产品列表</h1>       ← 父组件自己的内容(始终显示)
  <ProductDetail />      ← 子路由组件,渲染在 <Outlet /> 位置
</Products>
xml 复制代码
URL: #/products/new

<Products>
  <h1>产品列表</h1>       ← 同样始终显示
  <NewProductDetail />   ← 子路由组件,替换了上一个
</Products>

嵌套路由的价值在于:共享布局。父组件定义好标题、侧边栏、底部导航,子路由只需要关心自己的差异内容。这在后台管理系统中极其常见------左侧菜单不变,右侧内容区根据路由切换。

子组件同样用 useParams

javascript 复制代码
// Products/Detail/index.jsx
const ProductDetail = () => {
  const { productId } = useParams();
  return <h3>产品详情 {productId}</h3>;
};

父路由定义 path="/products",子路由定义 path=":productId" ------ 最终匹配的是 /products/:productId,参数名 productId 直接透传到 useParams() 中。


七、Navigate:声明式重定向

ini 复制代码
<Route path="/old-path" element={
  <Navigate replace to="/new-path" />
} />

Navigate 是一个组件形态的重定向指令。它一被渲染,就立即执行导航跳转。

其中 replace 属性的含义:

javascript 复制代码
有 replace:
  历史栈: [... , /new-path]          ← 旧路径被替换,回退跳过它

无 replace (push):
  历史栈: [... , /old-path, /new-path]  ← 旧路径保留,回退会经过它

这对应浏览器的 history.replaceState() vs history.pushState()。对于重定向场景,几乎总是用 replace------你不希望用户点了"返回"又跳回旧的废弃路径。


八、useNavigate:编程式导航

javascript 复制代码
// NotFound.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const NotFound = () => {
  let navigate = useNavigate();

  useEffect(() => {
    setTimeout(() => {
      navigate('/');
    }, 3000);
  }, []);

  return <>Not Found</>;
};
<Link> useNavigate()
触发方式 用户点击 JS 逻辑控制
使用场景 导航栏、菜单、按钮 表单提交后跳转、超时跳转、条件跳转
本质 声明式 命令式

navigate() 函数行为等价于 <Link> 被点击后的效果------同样是调用 History API + 触发路由重匹配,不会刷新页面。

为什么不用 window.location.href

ini 复制代码
// ❌ SPA 中大忌
window.location.href = '/';

// ✅ 正确做法
navigate('/');

window.location.href 赋值会触发浏览器级别的页面跳转------完整的 HTTP 请求、页面销毁、重新初始化、React 重新挂载。你的所有状态、事件监听、定时器全部丢失。它绕过了 React Router 的整个路由系统,等同于在一个 SPA 里开了一个"后门"回到传统多页模式。


九、path="*":404 兜底

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

* 是通配符,匹配所有 URL 。把它放在 <Routes> 的最后一条,前面的 Route 都没匹配上时,* 兜底匹配,渲染 404 页面。

这里注意 React Router v6 的匹配顺序:

  1. 遍历所有 Route
  2. 用当前 URL 逐一匹配
  3. 第一个匹配成功的就渲染,后面的不再检查
  4. 所以 path="*" 必须在最后------放前面就永远轮不到其他 Route 了

一条实用的经验法则:把 path="*" 永远作为 <Routes> 的最后一项。


十、完整的数据流总结

把一个 Demo 项目中的所有零件串起来,用户点击 "商品详情" 链接时发生了什么:

bash 复制代码
1. 用户点击 <Link to="/products/123">
        │
2. Link 拦截 click,preventDefault()
        │
3. 更新 URL hash → #/products/123
   触发 hashchange 事件
        │
4. HashRouter 感知 hash 变化
   通知内部路由匹配器
        │
5. Routes 遍历 Route 列表
   匹配到 path="/products"(父路由)
   继续匹配子路由 → ":productId" 匹配 "123"
        │
6. Suspense 检查 ProductDetail chunk 是否已下载
   ├─ 未下载 → 渲染 fallback(Loading...)
   └─ 已下载 → 渲染 <Products> + <ProductDetail>
                  │
7. ProductDetail 调用 useParams()
   从 Context 拿到 { productId: "123" }
        │
8. 渲染 "产品详情 123"

整个过程没有一次 HTTP 页面请求,JS 状态完整保留,URL 正确更新------这就是一个 SPA 路由系统的完整闭环。


关键要点速查

概念 一句话解释
Link 替代 <a>,阻止跳转,用 JS 改 URL + 切换组件
HashRouter 用 URL # 后面的部分做路由,不触发服务器请求
Routes / Route 路由匹配引擎,匹配第一个符合的 Route 并渲染
lazy() + Suspense 按需下载页面 chunk,首屏只加载当前页面
:id 动态参数 URL 中的变量占位符,冒号开头
useParams() Hook,从 Context 取当前路由的 URL 参数
嵌套路由 + Outlet 父组件定义布局,子组件渲染在 <Outlet /> 位置
Navigate 声明式重定向,渲染即跳转
useNavigate() 命令式跳转,在 JS 逻辑中控制导航
path="*" 通配兜底,永远放在最后做 404

React Router v6 的设计哲学可以归结为一句话:用 React 组件表达路由逻辑。Routes 是组件,Route 是组件,Link 是组件,Navigate 是组件------路由配置不再是一个独立的 JSON 配置文件,而是自然地融入你的 JSX 组件树中。这种"组件即配置"的思想,正是 React 生态一以贯之的设计理念。

相关推荐
用户059540174463 小时前
Redis缓存回滚踩坑实录:一个配置失误,让我在凌晨3点丢了3000条数据
前端·css
kyriewen4 小时前
别再这样写条件渲染了——你的React组件里藏着这5种定时炸弹
前端·javascript·react.js
IT_陈寒4 小时前
Redis缓存击穿把我坑惨了,原来这样设过期时间才靠谱
前端·人工智能·后端
用户938515635075 小时前
从"坐电梯"到"前端路由"——深入理解 Hash 路由原理
前端·typescript·全栈
梦想CAD控件5 小时前
网页端CAD的图形选择、编辑与夹点操作教程
前端·javascript·node.js
妙码生花5 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十二):管理员权限检查中间件,AI 随意放行预闯大祸
前端·后端·go
月月大王的3D日记5 小时前
Three.js 入门系列(8):六种光源全解析 —— 关灯了,开光!
前端·javascript
属于自己的天空6 小时前
每天省去重复 Prompt:我用 Skills 把 CRUD 生成标准化了
前端·后端