从零理解 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">,浏览器会执行一次完整的页面导航:
- 解析 href
- 发起 HTTP GET 请求
- 接收服务器返回的 HTML 文档
- 销毁当前页面的所有 JS 状态(变量、事件监听、React 组件树全部消失)
- 解析新 HTML,重新初始化整个 React 应用
对于 SPA 来说,第 4 步是灾难性的------你的应用状态、Redux store、组件内部状态全部归零。
Link 组件做了什么呢?它在底层仍然渲染一个 <a> 标签,但做的事情完全不同:
- 拦截
<a>的click事件,preventDefault()阻止浏览器默认跳转 - 调用
history.pushState()或hashChange更新浏览器地址栏 URL - 通知 React Router 内部的路由匹配器:URL 变了,重新匹配一下
- 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 内部使用了 Context :Routes 组件在匹配成功后,把参数写入 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 vs useNavigate
<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 的匹配顺序:
- 遍历所有 Route
- 用当前 URL 逐一匹配
- 第一个匹配成功的就渲染,后面的不再检查
- 所以
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 生态一以贯之的设计理念。