前端路由(二):React Router 组件化路由实战

背景

上一篇文章二十行代码实现了一个 HashRouter:一个路由表对象,一个 hashchange 监听器,一个 DOM 挂载点。三样东西拼在一起,SPA 的路由骨架就立起来了。

手写路由能跑,但离实际项目还有一段距离。一个页面稍微复杂些的应用,会陆续撞上这些问题:

  1. 路由表是平面键值对,页面嵌套关系表达不了
  2. URL 参数(如 /user/123)需要手动解析
  3. 导航链接仍然用 <a> 标签,样式和激活态要自己管
  4. 所有页面代码打包在一个文件里,首页加载越来越慢
  5. 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> 组件在底层做了三件事:

  1. 渲染一个 <a> 标签,保证语义正确、右键打开新标签页等浏览器默认行为不受影响
  2. 拦截点击事件,阻止浏览器发起 HTTP 请求
  3. 调用 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 中的 :iduseParams() 返回的 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 提供了 lazySuspense 来做代码分割,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 的路由方案可以拆成三层来看:

  1. 声明层<Routes><Route> 描述路径到组件的映射关系,配置即文档
  2. 导航层<Link>useNavigate 提供两种触发路由切换的方式------声明式和编程式
  3. 数据层useParams<Outlet> 负责把 URL 中的信息传递给组件,把嵌套结构映射为渲染层级
相关推荐
程序员黑豆1 小时前
Java入门第一步:从零开始编写你的第一个Hello World程序
java·前端·ai编程
鸽鸽1 小时前
别急着装第三方库,这些事浏览器原生就能做到——Web Native API 实战指南
前端
做前端的娜娜子1 小时前
从 0 到 1 打造一个配置可视化后台:一次前端配置中心化改造的实战复盘
前端·设计模式·数据可视化
南雨北斗1 小时前
vue3 RouterLink链接颜色修改的方法
前端
Richard.Wong2 小时前
Windows IIS 服务器部署 Vue3 前端项目详细流程
服务器·前端·windows
数据知道2 小时前
XSS 攻防全解:反射型、存储型、DOM 型实战演示
前端·安全·web安全·网络安全·xss
Nemo_XP2 小时前
C# gridlookupedit选中内容重复还原操作
服务器·前端·c#
大家的林语冰2 小时前
🎉 Vercel 官宣 Next 16.3 正式发布,GitHub 第一全栈框架再次进化!
前端·javascript·前端框架
不爱说话郭德纲3 小时前
我只给了 TRAE Work 一张差评截图,它最后却把自己的 P0 结论推翻了?
前端·后端·架构