背景
上一篇文章二十行代码实现了一个 HashRouter:一个路由表对象,一个 hashchange 监听器,一个 DOM 挂载点。三样东西拼在一起,SPA 的路由骨架就立起来了。
手写路由能跑,但离实际项目还有一段距离。一个页面稍微复杂些的应用,会陆续撞上这些问题:
- 路由表是平面键值对,页面嵌套关系表达不了
- URL 参数(如
/user/123)需要手动解析 - 导航链接仍然用
<a>标签,样式和激活态要自己管 - 所有页面代码打包在一个文件里,首页加载越来越慢
- 404 兜底、重定向、权限守卫这些通用需求,每次都要重新写
这些问题不是 Hash 路由特有的------换成 History API 实现也一样会撞上。React Router 做的事情,是用组件化的方式把这些通用需求封装成一套可组合的 API。
本文基于 react-router-demo 项目,用 React Router v7 逐层拆解一个组件化路由方案是怎样组织起来的。
路由配置:把路由表写成组件树
手写 HashRouter 时,路由表是一个对象:
kotlin
this.routers = {
'/page1': callback1,
'/page2': callback2,
'/page3': callback3,
};
React Router 把路由表变成了组件树。打开 App.jsx,路由配置部分长这样:
xml
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="/products" element={<Products />}>
<Route path=":productsId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
<Routes> 是路由表的容器,<Route> 是单条路由配置。写法上有几个值得注意的设计选择:
- 声明式 :路由结构直接写在 JSX 里,和组件树共享同一套嵌套关系。路径的层级(
/products→:productsId)对应<Route>的嵌套层级,不需要额外维护一份路由映射表。 element属性接收 JSX:每个路径匹配后直接渲染对应的 React 组件,路由和视图之间的映射关系一目了然。:id语法标记动态参数 :路径中以:开头的片段是动态参数,React Router 会自动解析并暴露给对应组件。
从「对象键值对」变成「组件树」,核心变化是路由配置从数据 变成了结构------嵌套关系、参数约定、渲染目标都写在同一个声明式结构里,不再散落在路由表、参数解析器和渲染入口三个地方。
导航链接:用组件替代 标签
手写方案里,导航链接直接用的 <a href="#/page1">。这能工作,但 <a> 标签的默认行为是触发完整的页面导航,React Router 需要在点击时拦截这个行为,改为客户端路由切换。
Navigation.jsx 中使用了 <Link> 组件:
javascript
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/123456">商品详情</Link></li>
<li><Link to="/products/new">新增商品</Link></li>
</ul>
</nav>
);
}
<Link> 组件在底层做了三件事:
- 渲染一个
<a>标签,保证语义正确、右键打开新标签页等浏览器默认行为不受影响 - 拦截点击事件,阻止浏览器发起 HTTP 请求
- 调用 History API 更新地址栏,触发 React Router 的内部路由匹配逻辑
to 属性的值和 <Route path> 使用同一套路径规则,不需要像手写方案那样在每个 href 里手动拼 #/ 前缀。
动态路由参数:useParams 取出 URL 中的变量
/user/123 和 /user/456 应该渲染同一个 UserProfile 组件,只是显示的数据不同。这需要把 URL 中的 123 提取出来传给组件。
UserProfile/index.jsx 的做法:
javascript
import { useParams } from 'react-router-dom';
function UserProfile() {
let { id } = useParams();
return <h2>UserProfile: {id}</h2>;
}
路径 /user/:id 中的 :id 和 useParams() 返回的 id 字段通过参数名自动对应。同样,ProductDetail 中 /products/:productsId 的参数也通过同一个 hook 取出:
scss
const { productsId } = useParams();
这比手写方案中手动 split('/') 再按索引取值要直接------参数名就是变量名,路由配置和组件取值之间靠命名约定对齐,不需要额外的解析步骤。
嵌套路由:
标记内容出口
/products 是一个产品列表页,/products/123 是某个产品的详情页。详情页的布局是「列表页内容 + 详情内容」,而不是一个全新的页面。
Products/index.jsx 中,<Outlet> 组件标记了子路由的渲染位置:
javascript
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>产品列表</h1>
<Outlet />
</>
);
};
当 URL 匹配到 /products 时,<Outlet /> 不渲染任何内容(没有匹配的子路由)。当 URL 匹配到 /products/123 时,<Outlet /> 渲染 <ProductDetail />。当 URL 匹配到 /products/new 时,<Outlet /> 渲染 <NewProduct />。
嵌套路由解决的是一个具体问题:页面有层级关系,但不希望每个层级都重复写父级布局 。<Outlet> 让父路由组件成为子路由组件的「容器」,父组件负责公共部分(标题、侧边栏),子组件负责差异化内容。
对应回 App.jsx 中的路由配置:
xml
<Route path="/products" element={<Products />}>
<Route path=":productsId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
子 <Route> 的 path 会自动拼接父路径,/products + :productsId 得到完整路径 /products/:productsId。嵌套层级和渲染层级同步,路径结构和组件树结构保持一致。
路由懒加载:页面按需下载
手写 HashRouter 不存在打包问题------每个 HTML 页面本来就是独立的文件。SPA 的情况相反:所有页面代码默认打包进一个 JS bundle,用户访问首页时要下载全部页面代码。
React 提供了 lazy 和 Suspense 来做代码分割,React Router 不干预这个过程------它只关心路由匹配,不关心组件是怎么加载的。两者组合使用:
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/Detial'));
const NewProduct = lazy(() => import('./pages/Products/New'));
lazy(() => import('./pages/Home')) 告诉打包工具(Vite / Webpack)把 Home 组件的代码拆成独立的 chunk。只有当路由匹配到 / 时,这个 chunk 才会被下载和执行。
<Suspense fallback={<div>Loading...</div>}> 包裹整个路由区域,在懒加载组件的代码还在网络传输时显示加载状态:
xml
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
{/* ... */}
</Routes>
</Suspense>
</Router>
这样做的好处是路由配置本身不需要改动------<Route element={<Home />}> 的写法不变,懒加载是组件加载方式的变更,对路由层透明。
编程式导航和兜底处理
除了点击 <Link>,有时需要在代码逻辑中触发跳转------比如表单提交成功后跳转到列表页,或者 3 秒后自动跳回首页。
NotFound/index.jsx 使用了 useNavigate:
javascript
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate('/');
}, 3000);
}, []);
return <>404 Not Found</>;
};
useNavigate() 返回一个函数,调用它触发客户端路由跳转,效果等同于点击了一个 <Link to="/">。和直接操作 window.location.href 的区别在于:后者会触发完整的页面重新加载,navigate() 只在客户端切换路由。
404 兜底路由用 path="*" 实现------* 是通配符,匹配所有未被前面路由匹配的路径。把它放在 <Routes> 的最末尾,保证只有其他路由全部未命中时才进入兜底。
回看:从手写路由到组件化路由
把「其一」的 HashRouter 和本文的 React Router 放在一起对比,核心差异不在功能------手写版本也能做参数解析、嵌套、懒加载------而在于组织方式:
维度
手写 HashRouter
React Router
路由表
对象键值对
组件树
参数提取
手动 split
useParams() hook
导航链接
<a href="#/...">
<Link to="/...">
嵌套路由
自行管理父子关系
<Route> 嵌套 + <Outlet>
懒加载
天然按文件拆分(MPA)
lazy() + Suspense
跳转
location.hash = '...'
navigate()
手写方案中,路由控制逻辑是命令式 的------你告诉浏览器每一步怎么做:监听这个事件、取这个 hash、查这个对象、调这个回调。React Router 把控制逻辑转成了声明式------你描述路由长什么样:这个路径对应这个组件、这个参数叫这个名字、这个位置是子路由出口。
两种方式各有适用场景。手写路由适合理解原理、轻量 demo、或者框架无关的场景。实际项目中,组件化路由省掉的是大量重复的边界处理------参数解析、激活态管理、嵌套层级维护、代码分割策略------这些事情手写一次不复杂,但在每个页面都写一遍,累积的工作量会超过路由库本身的接入成本。
小结
React Router 的路由方案可以拆成三层来看:
- 声明层 :
<Routes>和<Route>描述路径到组件的映射关系,配置即文档 - 导航层 :
<Link>和useNavigate提供两种触发路由切换的方式------声明式和编程式 - 数据层 :
useParams和<Outlet>负责把 URL 中的信息传递给组件,把嵌套结构映射为渲染层级