从手写 HashRouter 到 React Router:逐层拆解 SPA 前端路由的完整实现

一、起点:手写的 HashRouter 已经做了什么

在打开 react-router-demo 之前,我们先回顾已有的认知。在 demo2/index.html 中,我们用不到 60 行代码手写了一个 SPA 路由:

javascript 复制代码
class HashRouter {
  constructor() {
    this.routers = {}                              // 路由表
    window.addEventListener('hashchange',          // 监听 hash
      this.load.bind(this))
  }
  register(hash, callback) { this.routers[hash] = callback }  // 注册
  load() { /* 查表 → 执行 callback → 替换 DOM */ }
}

三件套:hash 链接(<a href="#/xxx">)+ 挂载点(<div id="container">)+ hashchange 监听。每次点击链接触发 hash 变化,load()this.routers 查出对应 callback 执行,用 container.innerHTML 替换内容。

这套代码解决了 SPA 的核心问题:改 URL 但不发请求、不刷新页面 。但它的局限性也很明显------路由规则用 register() 调用来注册,不够直观;DOM 更新用 innerHTML,React 项目里没法用;没有嵌套路由、没有懒加载、没有重定向。

React Router 就是把手写 HashRouter 的每一个概念都做成了声明式的 React 组件和 Hook。今天的学习就是逐一建立这个对应关系。


二、项目结构:一个文件夹一件事

bash 复制代码
src/
├── main.jsx                   ← 入口
├── App.jsx                    ← 路由核心(管理员)
├── components/
│   └── Navigation.jsx         ← 导航栏
└── pages/
    ├── Home/index.jsx         ← 首页
    ├── About/index.jsx        ← 关于(含模块级 console.log)
    ├── UserProfile/index.jsx  ← 用户(useParams)
    ├── NotFound/index.jsx     ← 404 兜底(useNavigate + useEffect)
    └── Products/
        ├── index.jsx          ← 商品列表父级(Outlet)
        ├── detail/index.jsx   ← 商品详情(子路由 + useParams)
        └── New/index.jsx      ← 新增商品(子路由)

每个组件一个文件夹,放置逻辑文件 index.jsx 加上专属样式、测试和图片。import Home from './pages/Home' 自动解析到 pages/Home/index.jsx,路径简洁。

学到这会想问 : 为什么每个组件要单独建一个文件夹而不是直接放一个 .jsx 文件?

解答 :因为一个组件不止一个文件------它有样式(.css)、测试(.test.jsx)、图片等资源。如果所有组件平铺在 pages 目录下,文件数量爆炸。用文件夹 + index.jsx 的方式,每个组件自成一格,删或挪都是一锅端。实际开发中,Home 文件夹可能包含 index.jsxindex.cssindex.test.jsxhero.png 和其他子组件。


三、入口和外壳:main.jsx 与 App.jsx 的宏观结构

3.1 main.jsx ------ 纯入口

javascript 复制代码
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)

和所有 React 项目一样,纯粹的挂载入口,把 <App /> 渲染到 index.html<div id="root"> 上。

3.2 App.jsx ------ 路由管理员

这是整个项目的核心文件。打开 App.jsx,先从上到下扫一遍结构:

javascript 复制代码
// 1. React 基础
import { lazy, Suspense } from 'react'

// 2. React Router
import {
  HashRouter as Router,   // 底层就是 hashchange
  Routes, Route, Navigate
} from 'react-router-dom'

// 3. 导航栏(同步加载)
import Navigation from './components/Navigation'

// 4. 页面组件(懒加载)
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 NewProduct = lazy(() => import('./pages/Products/New'))

注释里解释了为什么用 lazy

arduino 复制代码
// spa 动态的切换多个页面
// 下载执行,影响首页的加载速度
// 只需要加载当前页面就好,路由懒加载

传统 import Home from './pages/Home' 是同步加载------页面一打开,七个组件文件全部下载并执行。这些文件对于首屏来说大多是多余的:用户只看首页,About 和 UserProfile 的代码不该成为首屏开销。

lazy(() => import('./pages/Home')) 把加载推迟到真正需要的时候。注意 () => import(...) 是个箭头函数,现在不调用,只有当 <Home /> 要被渲染时 React 才去执行它,触发文件下载。

python 复制代码
// 注释掉的同步 import,作为懒加载的对照
// import Home from './pages/Home'
// import About from './pages/About'

写完懒加载版本保留了原来的同步 import 作为注释,是为了直观对比。

学到这会想问lazy 里的箭头函数什么时候真正执行?不用 lazy 首页会慢多少?

解答 :只有当 <Home /> 在渲染流程中被实际需要时------也就是用户首次访问 / 路径时,React 才会调用那个箭头函数触发 import()。在此之前它只是一个"promise 生成器"存在变量里,不会发起网络请求。不用 lazy 的话,首屏会把 Home + About + UserProfile + NotFound + Products + ProductDetail + NewProduct 七个文件全部下载执行,即使 99% 的用户可能只看首页就走了。用了 lazy 后首屏只下载必需的 Navigation 和 Home,随着用户点击逐步加载其余组件。


四、逐层拆解 App.jsx 的 JSX 结构

javascript 复制代码
const App = () => {
  return (
    <>
      {/* 前端路由接管一切 */}
      <Router>
        {/* 导航栏组件 */}
        <Navigation />
        {/* 动态页面切换部分 */}
        <div id="container">
          {/* 即是配置,也是出现的地方 */}
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              {/* 有且仅有一个route显示 当前location.hash
                  对应页面级别组件 */}
              <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={<NewProduct />} />
              </Route>
              <Route path="/old-path" element={<Navigate to="/new-path" replace />} />
              {/* 404 Not Found */}
              {/* *贪婪匹配所有,最后404兜底 */}
              <Route path="*" element={<NotFound />} />
            </Routes>
          </Suspense>
        </div>
      </Router>
    </>
  )
}

逐层拆解,每层都有和手写版的对应关系。

4.1 <Router> --- HashRouter 的别名

javascript 复制代码
import { HashRouter as Router } from 'react-router-dom'
// 注释: // location.hash
// 另一个注释: // 前端路由 #/ hashchange

<Router> 就是 <HashRouter> 的别名。注释写了两件事:它的数据来源是 location.hash(URL 中 # 后面的部分),它的底层机制是 hashchange 事件。这和手写版 window.addEventListener('hashchange', ...) 是同一套东西,只是 React Router 团队把监听、匹配、渲染整条链路封装好了。

为什么要起别名?以后如果换成 BrowserRouter(服务器配合的路径路由),只需要改 import 这一行,组件树里的 <Router> 不用动。

arduino 复制代码
{/* 前端路由接管一切 */}

这句注释是 App.jsx 的核心宣言。传统多页应用中,路由规则在服务端(不同的 URL 路径对应不同的服务器文件)。现在 <Router> 内部的所有跳转、匹配、渲染全部由前端 JavaScript 接管,不再依赖服务器。

学到这会想问<HashRouter> 导入之后内部到底包含了什么?它是"自动有"的吗?

解答import { HashRouter } from 'react-router-dom' 导入的就是 react-router-dom 包中写好的组件。它内部封装了:(1) hashchange 事件的监听(等于手写版的 addEventListener),(2) location.hash 的读取和解析,(3) 通过 React Context 向下传递路由信息给所有子组件,(4) 浏览历史的 push/replace/goBack 管理,(5) hash 变化后触发 Routes 重新匹配的整条链路。它不是你手写版的替代魔法,而是把你写的那 ~30 行代码扩充到了几百行,加上了 Context、历史管理、React 深度整合。底层 hashchange 原理一模一样。

4.2 <Navigation /> --- 导航栏始终显示

放在 <Router> 内、<Suspense> 外。导航栏不需要懒加载------每个页面都要用它,同步 import 直接渲染。

4.3 <div id="container"> --- 和手写版同样的挂载点

和 demo2/index.html 的 <div id="container"></div> 作用完全一样------壳(header、footer、导航栏)不动,只有这个区域的内容发生变化。React 版不再用 innerHTML 进行 DOM 操作,而是在内部通过 React 组件系统更新。

4.4 <Suspense> --- 懒加载的过渡层

xml 复制代码
<Suspense fallback={<div>Loading...</div>}>

因为所有页面组件都是 lazy 加载的,首次切换到某个页面时文件还没下载好,React 会先渲染 fallback 中的内容作为占位。文件下载完毕后再替换为真正的组件。

<Suspense> 只包裹 <Routes> 而不包裹 <Navigation> 的原因:导航栏不需要懒加载,包进去反而会让导航栏在懒组件加载期间也受到影响。

4.5 <Routes> --- 既是路由配置,也是组件出现的位置

arduino 复制代码
{/* 即是配置,也是出现的地方 */}
<Routes>

两个角色合二为一:

  • 配置 :里面写的 <Route path="..." element={...} /> 定义了路由规则
  • 出现的地方 :匹配到的组件最终渲染在 <Routes> 这个位置

这和你手写版的 this.routers 表 + container.innerHTML 替换是同一套思想的 React 化表达。

<Routes> 内部会自动按书写顺序遍历 所有子 <Route>,找到第一个匹配当前 hash 的就渲染其 element,后面的不再看。

arduino 复制代码
{/* 有且仅有一个route显示 当前location.hash 对应页面级别组件 */}

"有且仅有一个" 是 Routes 的核心约束------一次只渲染一个路由。"页面级别组件" 指的是 Home、About 这种整页组件,不是导航栏这种小片段。


五、六条 Route,六个知识点

5.1 / --- 首页,最简单的匹配

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

path="/" 是钥匙,element={<Home />} 是匹配到了该调用并渲染的组件。首次访问时 Home 文件还没下载,<Suspense> 显示 Loading 直到 import 完成。

Home 组件里只有一句:

javascript 复制代码
// 页面级别组件,比单纯的构成页面的组件
// /view/pages目录下
function Home() {
  return (<>Home</>)
}

第一条注释区分了"页面级别组件"(一整页)和"构成页面的组件"(如导航栏、按钮)。第二条注释说明这类组件在工程中的标准路径是 /view/pages 目录下。

5.2 /about --- 模块级代码的陷阱

ini 复制代码
<Route path="/about" element={<About />} />

About 组件中有一个值得注意的细节:

javascript 复制代码
function About() {
  return (<>About</>)
}

console.log('About')    // 这行在函数外面!

export default About

这里的 console.log('About') 写在了 function About() 的外部,属于模块级别代码。当 import('./pages/About') 执行时,整个文件从头到尾运行一遍------定义函数、执行 console.log、导出。console.log 是在 import 那一刻 立即执行的,和用户什么时候点击 About 链接、组件是否被渲染无关。如果写在 return 前面,那才是每次渲染时打印。

这就解释了为什么 App.jsx 的注释强调"只需要加载当前页面就好"------每个被 import 的文件都会立即执行其模块级代码,import 越多,首屏要跑的代码越多。

5.3 /user/:id --- 动态参数

ini 复制代码
<Route path="/user/:id" element={<UserProfile />} />

:id 中的冒号表示这是一个动态参数,匹配 /user/ 后面跟着的任何值:/user/123/user/abc/user/999 全部命中同一条 Route。

组件里用 useParams 取值:

javascript 复制代码
import { useParams } from 'react-router-dom'

function UserProfile() {
  const { id } = useParams()    // 对应的 Route 里写了 :id
  return <h2>用户 {id}</h2>
}

path=":id" 定义了参数名为 iduseParams() 返回 { id: '实际值' },两边名字要一致。如果用 path=":userId"useParams() 里解构的键就得改为 userId

这和你手写版的 window.location.hash.slice(1) 从 URL 取参数是同一回事,只是 useParams 基于 Route path 中的占位符自动解析。

5.4 /products 的嵌套路由 --- 父级 + Outlet + 子级

xml 复制代码
{/* 多级路由,嵌套路由 */}
<Route path="/products" element={<Products />}>
  {/* 二级路由 */}
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

和前面自闭合 <Route /> 不同,带子路由的 <Route> 用开始和结束标签包裹子路由。子路由的 path 不带前导 /,会自动拼接父级的 /products 前缀,形成完整的 /products/:productId/products/new

Products 父组件通过 <Outlet /> 为子路由留坑:

javascript 复制代码
import { Outlet } from 'react-router-dom'

const Products = () => {
  return (
    <>
      <h1>商品列表</h1>
      <Outlet />      // 注释: // 二级路由出口
    </>
  )
}

学到这会想问<Outlet /> 到底是什么?它和 #container 是什么关系?没有 Outlet 会怎样?

解答<Outlet /> 就是嵌套路由里的子级挂载点。和手写版 <div id="container"> 的角色完全一样------它是一个坑,子路由匹配到的组件就渲染在这个坑里。访问 /products/123 时,父级 <h1>商品列表</h1> 不动,<Outlet /> 位置渲染 <ProductDetail /> 的内容 "商品详情123"。切换到 /products/new,Outlets 里替换为 <NewProduct />,父级纹丝不动。这就是嵌套路由和普通路由的关键区别:普通路由切换整个组件销毁重建,嵌套路由切换只换子级。如果父组件没写 <Outlet />,子路由匹配到了但没地方渲染,等于白配。

子路由 ProductDetail 同样使用 useParams 取值:

javascript 复制代码
const { productId } = useParams()    // 对应 path=":productId"
return <h3>商品详情{productId}</h3>

UserProfileuseParams 原理完全一致,只是参数名从 id 换成了 productId

学到这会想问 : 为什么 :productId 前有冒号表示动态参数,而 new 没有?

解答path=":productId" 的冒号意味着它是通配符,/products/123/products/abc/products/999 全部匹配,冒号后面的名字就是 useParams 里的 key。而 path="new" 没有冒号,只有精确等于 "new" 时才匹配。这样设计的原因是:商品有无数个,不可能给每个 ID 写一条 Route,所以用动态参数一行搞定;而 "新增商品" 是固定的一个页面,直接写死 new 即可。Routes 遍历时先匹配到哪条算哪条,所以 /products/new 会优先命中 path="new" 而不是 path=":productId"

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

<Navigate /> 是重定向组件。当用户访问 /old-path,匹配到这条 Route,Navigate 组件挂载后立即改 hash 跳向 /new-path

Navigate 比你手写版更精准------手写版没有现成的重定向机制,只能手动改 window.location.hash

5.6 * --- 404 兜底

javascript 复制代码
{/* 404 Not Found */}
{/* *贪婪匹配所有,最后404兜底 */}
<Route path="*" element={<NotFound />} />

* 是通配符,匹配任何路径。两条注释写明了它的两个特性:贪婪匹配所有 (任何没被上面路由命中的 hash 都会被它兜住)和最后放置(必须放在所有 Route 的最后,否则前面的 Route 全部被它抢先匹配到而失效)。

NotFound 组件使用了 useNavigate + useEffect 实现自动跳回:

javascript 复制代码
import { useNavigate } from 'react-router-dom'
// 注释: // 路由跳转

const NotFound = () => {
  let navigate = useNavigate()    // 拿到 navigate 函数
  useEffect(() => {
    setTimeout(() => {
      navigate('/')               // 3 秒后跳回首页
      // window.location.href = '/'   // 旧写法,会刷新页面,淘汰了
    }, 3000)
  }, [])                          // 空数组 = 只在挂载时执行一次
  return (<>404 Not Found</>)
}

useNavigate 是编程式路由跳转。和 <Link> 的区别在于:<Link> 依赖用户点击(声明式),navigate 是 JS 代码主动调用(命令式)。useNavigate() 返回一个函数,调用它传入路径就能跳转。

注释掉的 window.location.href = '/' 是旧写法------它会触发完整的页面刷新,发 HTTP 请求,破坏 SPA 体验。navigate('/') 走的是 HashRouter 内部逻辑,只改 hash,不刷新,全程 SPA。

useEffect[] 表示依赖为空,只在组件挂载时执行一次。每次 NotFound 被渲染(即每次用户闯入不存在的路径),都会重新挂载,重新设定时器,重新倒计时 3 秒。

这里还有一个细节值得注意:当前代码在组件卸载时没有清除定时器。如果用户在 3 秒内离开 404 页面,定时器仍在后台继续倒数,到期后依然会执行 navigate('/')------虽然跳的是同一路径影响不大,但严格来说应该 return () => clearTimeout(timer) 来清理。这是一个可以优化的点。


javascript 复制代码
// a 标签点击后会跳转,一般不直接用a标签
// react-router-dom 提供的 Link 组件,点击后不会刷新页面
// 适合SPA 路由跳转的组件功能
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">商品列表</Link></li>
        <li><Link to="/products/123">商品详情</Link></li>
        <li><Link to="/products/New">新增商品</Link></li>
      </ul>
    </nav>
  )
}

三行注释已经把 Link 存在的理由说透了:<a> 标签点击会触发浏览器默认跳转(发请求、刷新),不适合 SPA。<Link> 内部拦截了 onClick,用 e.preventDefault() 阻止默认行为,然后手动修改 window.location.hash,整个过程不刷新。

Link 最终渲染到页面上就是一个 <a href="#/xxx">,和你手写版的 <a href="#/page1"> 是同一个东西,只是封装了拦截逻辑。所以用户右键在新标签页打开、悬停时看到链接地址,这些 <a> 的原生体验全部保留。

学到这会想问<Link> 到底是什么?点击 Link 到底是不是浏览器的点击?

解答 :点击 Link 当然是浏览器的真实点击------click 事件照常触发。区别在于 Link 内部在 onClick 中调用了 e.preventDefault(),"伸腿绊了浏览器一脚",阻止了后续的 HTTP 请求和页面跳转。然后它自己接管:读取 to 属性 → 拼成 '#' + to → 赋值给 window.location.hash → hashchange 触发 → HashRouter 通知 Routes 重新匹配 → 渲染新组件。浏览器以为自己只是改了个锚点,实际上整个页面的内容都被换了。


七、完整流程模拟:从点击到渲染

假设用户第一次打开 http://localhost:5174/,然后点击 "商品详情":

bash 复制代码
1. 应用启动
   main.jsx → render(<App />) → App 组件执行
   → Navigation 同步 Import 并渲染(导航栏出现)
   → Routes 遍历:hash 为空 → 只有 "*" 匹配 → NotFound
   → NotFound 是 lazy 的,第一次被用到 → Suspense 显示 Loading
   → import('./pages/NotFound') → 下载文件 → 渲染 "404 Not Found"
   → NotFound 挂载 → useEffect 执行 → 3 秒后 navigate('/')
   → hash 变为 '#/' → Routes 重新匹配 → path="/" 命中
   → Home 是 lazy 的 → 下载 → 渲染 "Home"

2. 用户点击 <Link to="/products/123">商品详情</Link>
   → Link 内部 e.preventDefault() → window.location.hash = '/products/123'
   → URL 变成 #/products/123 → 没有 HTTP 请求
   → hashchange 触发 → HashRouter 通知 Routes
   → Routes 遍历:
       "/"             ≠ "/products/123"  ❌
       "/about"        ≠ "/products/123"  ❌
       "/user/:id"     ≠ "/products/123"  ❌
       "/products"     → 前缀匹配 ✅
         进入子路由:
         ":productId"  → "123" 匹配 ✅ → productId = '123'
   → Products 和 ProductDetail 都是 lazy → Suspense 显示 Loading
   → 下载 Products/index.jsx 和 Products/detail/index.jsx
   → Products 渲染:
       <h1>商品列表</h1>
       <Outlet /> → ProductDetail 渲染在这里 → <h3>商品详情123</h3>
   → 页面最终输出:
       导航栏(Home About 小家 商品列表 商品详情 新增商品)
       ──────────────────────────────
       商品列表
       商品详情123

整个过程从浏览器角度看:URL 从 localhost:5174 变成了 localhost:5174/#/products/123没有发出一条 HTTP 请求,页面没有闪一下白屏,导航栏没有任何重新渲染的痕迹。 这就是 SPA。


八、概念对照:手写版 vs React Router

每个 React Router 的 API,心里都要能对应到手写版的概念:

React Router demo2 手写版 本质
<HashRouter> window.addEventListener('hashchange', ...) 监听 hash 变化
<Link to="/about"> <a href="#/about"> 改 hash 不发请求
<Routes> + <Route> this.routers = {} 路由表
<Route path="..." element={...} /> register(key, callback) 注册路由规则
useParams() window.location.hash.slice(1) 从 URL 拿参数
useNavigate()('/') window.location.hash = '/' 编程式跳转
<Outlet /> #container(子级版本) 嵌套挂载点
lazy(() => import(...)) register 的延迟调用 按需加载
<Navigate to="..." replace /> 手动改 hash + 不留历史 声明式重定向
path="*" 路由表最后的兜底匹配 404 通配

九、回顾检查清单

把这些问题口头回答一遍,能说清楚就说明 React Router 这部分掌握了:

  1. 从点击导航栏 Link 到页面切换,React Router 内部发生了什么?
  2. <Link> 为什么不刷新页面?它和 <a> 的区别是什么?
  3. HashRouter 内部封装了什么?它对应手写版的哪些代码?
  4. lazy + Suspense 解决了什么问题?没有它们会怎样?
  5. useParams 的参数名从哪里来?为什么 UserProfile 用的是 id,ProductDetail 用的是 productId
  6. path="*" 为什么要放在所有 Route 的最后?
  7. <Outlet> 在嵌套路由里扮演什么角色?和手写版 #container 有什么关系?
  8. useNavigate()window.location.href 有什么区别?为什么前者更好?
  9. :productId 的冒号表示什么?和 new 有什么设计上的不同?
  10. Navigatereplace 属性不加会有什么后果?

#React #ReactRouter #SPA #前端路由

相关推荐
我叫蒙奇2 小时前
JS中的隐式类型转换
前端
他们叫我秃子2 小时前
前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer
前端·后端·go
生戎马 平安京策2 小时前
只学一点点:我的技术学习策略
前端·javascript·react.js
大鹏说大话2 小时前
HTML5 地理定位 Geolocation:获取用户位置的“红线”与最佳实践
前端·html·html5
Csvn2 小时前
content-visibility: auto —— 让浏览器跳过离屏渲染的性能黑科技
前端
小鹰信息技术服务部2 小时前
Edge安装包MicrosoftEdgeSetup.exe无法运行,点击没反应
前端·edge
__zRainy__3 小时前
React之reduce实现compose和中间件
javascript·react.js·ecmascript
webkubor3 小时前
一次前端生产白屏复盘:旧 HTML、Hash 资源和 MIME type text/html
前端·前端工程化
咩咩啃树皮3 小时前
第63篇【终极整合巨作】从零手写原生中后台管理系统|整合62篇全部前端知识点|从头到尾完整架构+全功能逻辑
前端·架构