前端路由(二):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 中的信息传递给组件,把嵌套结构映射为渲染层级
相关推荐
mldong8 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排8 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60619 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角10 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!10 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰11 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi12 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen13 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马13 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒14 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端