跪拜 Guibai
← Back to the summary

React Router from Scratch: Mapping Every API Back to a 60-Line HashRouter

1. Starting Point: What the Handwritten HashRouter Already Does

Before opening react-router-demo, let's review what we already know. In demo2/index.html, we hand-wrote an SPA router in under 60 lines of code:

class HashRouter {
  constructor() {
    this.routers = {}                              // routing table
    window.addEventListener('hashchange',          // listen for hash changes
      this.load.bind(this))
  }
  register(hash, callback) { this.routers[hash] = callback }  // register
  load() { /* look up table → execute callback → replace DOM */ }
}

Three pieces: hash links (<a href="#/xxx">) + mount point (<div id="container">) + hashchange listener. Each link click triggers a hash change, load() looks up the corresponding callback from this.routers and executes it, replacing content with container.innerHTML.

This code solves the core problem of SPAs: changing the URL without sending a request or refreshing the page. But its limitations are also obvious — routing rules are registered via register() calls, which isn't intuitive; DOM updates use innerHTML, unusable in a React project; no nested routes, no lazy loading, no redirects.

React Router takes every concept from the handwritten HashRouter and turns it into declarative React components and Hooks. Today's learning is about establishing this correspondence one by one.


2. Project Structure: One Folder, One Responsibility

src/
├── main.jsx                   ← entry point
├── App.jsx                    ← routing core (the manager)
├── components/
│   └── Navigation.jsx         ← navigation bar
└── pages/
    ├── Home/index.jsx         ← home page
    ├── About/index.jsx        ← about (includes module-level console.log)
    ├── UserProfile/index.jsx  ← user (useParams)
    ├── NotFound/index.jsx     ← 404 fallback (useNavigate + useEffect)
    └── Products/
        ├── index.jsx          ← product list parent (Outlet)
        ├── detail/index.jsx   ← product detail (child route + useParams)
        └── New/index.jsx      ← new product (child route)

Each component gets its own folder, containing the logic file index.jsx plus dedicated styles, tests, and images. import Home from './pages/Home' automatically resolves to pages/Home/index.jsx, keeping paths clean.

You might ask at this point: Why put each component in its own folder instead of just a .jsx file?

Answer: Because a component is more than one file — it has styles (.css), tests (.test.jsx), images, and other resources. If all components were flat in the pages directory, the file count would explode. Using a folder + index.jsx approach makes each component self-contained; deleting or moving it takes everything at once. In real development, a Home folder might contain index.jsx, index.css, index.test.jsx, hero.png, and other sub-components.


3. Entry Point and Shell: The Macro Structure of main.jsx and App.jsx

3.1 main.jsx — Pure Entry Point

import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)

Like any React project, a pure mount entry point that renders <App /> onto index.html's <div id="root">.

3.2 App.jsx — The Routing Manager

This is the project's core file. Open App.jsx and scan the structure top to bottom:

// 1. React basics
import { lazy, Suspense } from 'react'

// 2. React Router
import {
  HashRouter as Router,   // underlying mechanism is hashchange
  Routes, Route, Navigate
} from 'react-router-dom'

// 3. Navigation bar (synchronous load)
import Navigation from './components/Navigation'

// 4. Page components (lazy loaded)
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'))

The comments explain why lazy is used:

// spa dynamically switches between multiple pages
// downloading and executing affects the initial page load speed
// only need to load the current page, route lazy loading

Traditional import Home from './pages/Home' is synchronous — the moment the page opens, all seven component files are downloaded and executed. Most of these files are unnecessary for the first screen: the user only sees the home page, so About and UserProfile code shouldn't be part of the initial load cost.

lazy(() => import('./pages/Home')) defers loading until it's actually needed. Note that () => import(...) is an arrow function — it isn't called now; React only executes it when <Home /> is about to be rendered, triggering the file download.

// Commented-out synchronous imports, kept as a comparison for lazy loading
// import Home from './pages/Home'
// import About from './pages/About'

After writing the lazy-loaded version, the original synchronous imports are kept as comments for an intuitive side-by-side comparison.

You might ask at this point: When does the arrow function inside lazy actually execute? How much slower is the first screen without lazy?

Answer: Only when <Home /> is actually needed in the render flow — that is, when the user first visits the / path — does React call that arrow function to trigger import(). Before that, it just sits as a "promise generator" stored in a variable, making no network request. Without lazy, the first screen downloads and executes all seven files — Home + About + UserProfile + NotFound + Products + ProductDetail + NewProduct — even though 99% of users might only look at the home page and leave. With lazy, the first screen only downloads the essential Navigation and Home, loading the remaining components gradually as the user clicks around.


4. Layer-by-Layer Breakdown of App.jsx's JSX Structure

const App = () => {
  return (
    <>
      {/* Frontend routing takes over everything */}
      <Router>
        {/* Navigation bar component */}
        <Navigation />
        {/* Dynamic page switching area */}
        <div id="container">
          {/* Both configuration and the place where components appear */}
          <Suspense fallback={<div>Loading...</div>}>
            <Routes>
              {/* One and only one route displays, corresponding to the current location.hash
                  page-level component */}
              <Route path="/" element={<Home />} />
              <Route path="/about" element={<About />} />
              <Route path="/user/:id" element={<UserProfile />} />
              {/* Multi-level routing, nested routes */}
              <Route path="/products" element={<Products />}>
                {/* Second-level routes */}
                <Route path=":productId" element={<ProductDetail />} />
                <Route path="new" element={<NewProduct />} />
              </Route>
              <Route path="/old-path" element={<Navigate to="/new-path" replace />} />
              {/* 404 Not Found */}
              {/* * greedily matches everything, placed last as 404 fallback */}
              <Route path="*" element={<NotFound />} />
            </Routes>
          </Suspense>
        </div>
      </Router>
    </>
  )
}

Break it down layer by layer; each layer has a correspondence to the handwritten version.

4.1 <Router> — An Alias for HashRouter

import { HashRouter as Router } from 'react-router-dom'
// Comment: // location.hash
// Another comment: // frontend routing #/ hashchange

<Router> is just an alias for <HashRouter>. The comments state two things: its data source is location.hash (the part of the URL after #), and its underlying mechanism is the hashchange event. This is the same thing as the handwritten window.addEventListener('hashchange', ...), except the React Router team has packaged the entire chain of listening, matching, and rendering.

Why alias it? If you later switch to BrowserRouter (path-based routing requiring server cooperation), you only need to change the import line; <Router> in the component tree stays untouched.

{/* Frontend routing takes over everything */}

This comment is App.jsx's core declaration. In traditional multi-page applications, routing rules live on the server (different URL paths correspond to different server files). Now, all navigation, matching, and rendering inside <Router> is entirely handled by frontend JavaScript, no longer dependent on the server.

You might ask at this point: What exactly does <HashRouter> contain internally after importing? Is it "automatically there"?

Answer: import { HashRouter } from 'react-router-dom' imports a pre-written component from the react-router-dom package. It internally encapsulates: (1) listening for the hashchange event (equivalent to the handwritten addEventListener), (2) reading and parsing location.hash, (3) passing routing information down to all child components via React Context, (4) managing browser history push/replace/goBack, (5) the entire chain of triggering Routes re-matching after a hash change. It's not magic replacing your handwritten version; it's your ~30 lines of code expanded to hundreds, adding Context, history management, and deep React integration. The underlying hashchange principle is exactly the same.

4.2 <Navigation /> — Navigation Bar Always Visible

Placed inside <Router> but outside <Suspense>. The navigation bar doesn't need lazy loading — every page uses it, so it's synchronously imported and rendered directly.

4.3 <div id="container"> — The Same Mount Point as the Handwritten Version

Exactly the same role as <div id="container"></div> in demo2/index.html — the shell (header, footer, navigation bar) stays put, and only this area's content changes. The React version no longer uses innerHTML for DOM manipulation; instead, it updates through React's component system internally.

4.4 <Suspense> — The Transition Layer for Lazy Loading

<Suspense fallback={<div>Loading...</div>}>

Because all page components are lazy-loaded, the first time you switch to a page, its file hasn't been downloaded yet. React renders the fallback content as a placeholder first, then replaces it with the real component once the file download completes.

<Suspense> only wraps <Routes> and not <Navigation> because the navigation bar doesn't need lazy loading; wrapping it would cause the navigation bar to be affected during lazy component loading as well.

4.5 <Routes> — Both Routing Configuration and Where Components Appear

{/* Both configuration and the place where components appear */}
<Routes>

Two roles in one:

This is the React-ified expression of the same idea as your handwritten this.routers table + container.innerHTML replacement.

<Routes> internally automatically iterates through all child <Route> elements in written order, finds the first one matching the current hash, renders its element, and ignores the rest.

{/* One and only one route displays, corresponding to the current location.hash page-level component */}

"One and only one" is Routes' core constraint — only one route renders at a time. "Page-level component" refers to whole-page components like Home and About, not small fragments like the navigation bar.


5. Six Routes, Six Knowledge Points

5.1 / — Home Page, the Simplest Match

<Route path="/" element={<Home />} />

path="/" is the key, element={<Home />} is the component to call and render when matched. On first visit, the Home file hasn't been downloaded yet; <Suspense> shows Loading until the import completes.

The Home component contains only one line:

// Page-level component, distinct from components that make up a page
// Located under the /view/pages directory
function Home() {
  return (<>Home</>)
}

The first comment distinguishes "page-level components" (a whole page) from "components that make up a page" (like navigation bars, buttons). The second comment notes that such components standardly live under the /view/pages directory in a project.

5.2 /about — The Trap of Module-Level Code

<Route path="/about" element={<About />} />

There's a noteworthy detail in the About component:

function About() {
  return (<>About</>)
}

console.log('About')    // This line is outside the function!

export default About

The console.log('About') here is written outside function About(), making it module-level code. When import('./pages/About') executes, the entire file runs top to bottom — defines the function, executes console.log, exports. The console.log executes at the moment of import, regardless of when the user clicks the About link or whether the component is rendered. If it were written before the return, it would print on every render.

This explains why App.jsx's comment emphasizes "only need to load the current page" — every imported file immediately executes its module-level code; the more imports, the more code runs on the first screen.

5.3 /user/:id — Dynamic Parameters

<Route path="/user/:id" element={<UserProfile />} />

The colon in :id indicates a dynamic parameter, matching any value after /user/: /user/123, /user/abc, /user/999 all hit the same Route.

The component retrieves the value using useParams:

import { useParams } from 'react-router-dom'

function UserProfile() {
  const { id } = useParams()    // corresponds to :id written in the Route
  return <h2>用户 {id}</h2>
}

path=":id" defines the parameter name as id; useParams() returns { id: 'actual value' }. The names on both sides must match. If you used path=":userId", the destructured key in useParams() would need to be userId.

This is the same thing as your handwritten window.location.hash.slice(1) for extracting parameters from the URL, except useParams automatically parses based on the placeholders in the Route path.

5.4 /products Nested Routes — Parent + Outlet + Children

{/* Multi-level routing, nested routes */}
<Route path="/products" element={<Products />}>
  {/* Second-level routes */}
  <Route path=":productId" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

Unlike the previous self-closing <Route />, a Route with child routes uses opening and closing tags to wrap the child routes. Child route paths don't have a leading /; they automatically concatenate with the parent's /products prefix, forming the full /products/:productId and /products/new.

The Products parent component reserves a slot for child routes via <Outlet />:

import { Outlet } from 'react-router-dom'

const Products = () => {
  return (
    <>
      <h1>商品列表</h1>
      <Outlet />      // Comment: // second-level route outlet
    </>
  )
}

You might ask at this point: What exactly is <Outlet />? What's its relationship to #container? What happens without Outlet?

Answer: <Outlet /> is the child mount point in nested routing. Its role is exactly the same as the handwritten <div id="container"> — it's a slot where child route matched components render. When visiting /products/123, the parent's <h1>商品列表</h1> stays put, and the <Outlet /> position renders <ProductDetail />'s content "商品详情123". Switching to /products/new, the Outlet replaces with <NewProduct />, while the parent remains untouched. This is the key difference between nested and regular routing: regular routing destroys and recreates the entire component on switch; nested routing only swaps the child. If the parent component doesn't include <Outlet />, child routes match but have nowhere to render — effectively useless.

The child route ProductDetail also uses useParams to retrieve values:

const { productId } = useParams()    // corresponds to path=":productId"
return <h3>商品详情{productId}</h3>

The principle is exactly the same as UserProfile's useParams, just with the parameter name changed from id to productId.

You might ask at this point: Why does :productId have a colon indicating a dynamic parameter, while new doesn't?

Answer: The colon in path=":productId" means it's a wildcard — /products/123, /products/abc, /products/999 all match, and the name after the colon is the key in useParams. path="new" has no colon and only matches when it's exactly "new". The design reason: there are countless products, so you can't write a Route for each ID; a dynamic parameter handles it in one line. "New product" is a single fixed page, so you hardcode new. When Routes iterates, the first match wins, so /products/new will preferentially hit path="new" rather than path=":productId".

5.5 Navigate — Redirect

<Route path="/old-path" element={<Navigate to="/new-path" replace />} />

<Navigate /> is a redirect component. When a user visits /old-path, this Route matches, the Navigate component mounts, and immediately changes the hash to jump to /new-path.

The replace attribute means replacing the current history entry rather than pushing a new one. Without replace, the user could click the browser back button and return to /old-path, which would immediately redirect again, creating an infinite loop. With replace, /old-path is erased from history; clicking back goes to the page before it.

Navigate is more precise than your handwritten version — the handwritten version had no built-in redirect mechanism and could only manually change window.location.hash.

5.6 * — 404 Fallback

{/* 404 Not Found */}
{/* * greedily matches everything, placed last as 404 fallback */}
<Route path="*" element={<NotFound />} />

* is a wildcard matching any path. The two comments state its two characteristics: greedily matches everything (any hash not caught by the routes above will be caught by it) and must be placed last (otherwise all preceding routes would be preemptively matched by it and become ineffective).

The NotFound component uses useNavigate + useEffect to implement an auto-redirect back:

import { useNavigate } from 'react-router-dom'
// Comment: // route navigation

const NotFound = () => {
  let navigate = useNavigate()    // get the navigate function
  useEffect(() => {
    setTimeout(() => {
      navigate('/')               // jump back to home after 3 seconds
      // window.location.href = '/'   // old way, refreshes the page, deprecated
    }, 3000)
  }, [])                          // empty array = only run once on mount
  return (<>404 Not Found</>)
}

useNavigate is programmatic route navigation. The difference from <Link>: <Link> depends on user clicks (declarative), while navigate is actively called by JS code (imperative). useNavigate() returns a function; calling it with a path triggers navigation.

The commented-out window.location.href = '/' is the old way — it triggers a full page refresh, sends an HTTP request, and breaks the SPA experience. navigate('/') goes through HashRouter's internal logic, only changing the hash, no refresh, fully SPA.

useEffect's [] means empty dependencies, running only once when the component mounts. Every time NotFound is rendered (i.e., every time a user enters a non-existent path), it remounts, resets the timer, and counts down 3 seconds again.

There's another detail worth noting: the current code doesn't clear the timer when the component unmounts. If the user leaves the 404 page within 3 seconds, the timer still runs in the background and will still execute navigate('/') when it expires — though jumping to the same path has little impact, strictly speaking, return () => clearTimeout(timer) should be used to clean up. This is a point that can be optimized.


6. Navigation.jsx — The Essence of Link

// a tag triggers a page jump when clicked, generally not used directly
// The Link component provided by react-router-dom doesn't refresh the page on click
// Suitable for SPA route navigation component functionality
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>
  )
}

The three lines of comments have already explained the reason for Link's existence: an <a> tag click triggers the browser's default navigation (sends a request, refreshes), unsuitable for SPAs. <Link> internally intercepts onClick, uses e.preventDefault() to block the default behavior, then manually modifies window.location.hash — the entire process without a refresh.

Link ultimately renders on the page as an <a href="#/xxx">, the same thing as your handwritten <a href="#/page1">, just with the interception logic packaged inside. So native <a> experiences — right-click to open in a new tab, seeing the link address on hover — are all preserved.

You might ask at this point: What exactly is <Link>? Is clicking a Link actually a browser click?

Answer: Clicking a Link is absolutely a real browser click — the click event fires as usual. The difference is that Link internally calls e.preventDefault() in onClick, "tripping the browser" to stop the subsequent HTTP request and page navigation. Then it takes over: reads the to property → concatenates '#' + to → assigns to window.location.hash → hashchange fires → HashRouter notifies Routes to re-match → renders the new component. The browser thinks it just changed an anchor, but the entire page content has been swapped.


7. Complete Flow Simulation: From Click to Render

Suppose a user first opens http://localhost:5174/, then clicks "商品详情":

1. Application starts
   main.jsx → render(<App />) → App component executes
   → Navigation synchronously imported and rendered (navigation bar appears)
   → Routes iterates: hash is empty → only "*" matches → NotFound
   → NotFound is lazy, used for the first time → Suspense shows Loading
   → import('./pages/NotFound') → downloads file → renders "404 Not Found"
   → NotFound mounts → useEffect executes → after 3 seconds navigate('/')
   → hash becomes '#/' → Routes re-matches → path="/" hits
   → Home is lazy → downloads → renders "Home"

2. User clicks <Link to="/products/123">商品详情</Link>
   → Link internally e.preventDefault() → window.location.hash = '/products/123'
   → URL becomes #/products/123 → no HTTP request
   → hashchange fires → HashRouter notifies Routes
   → Routes iterates:
       "/"             ≠ "/products/123"  ❌
       "/about"        ≠ "/products/123"  ❌
       "/user/:id"     ≠ "/products/123"  ❌
       "/products"     → prefix match ✅
         Enter child routes:
         ":productId"  → "123" matches ✅ → productId = '123'
   → Products and ProductDetail are both lazy → Suspense shows Loading
   → Downloads Products/index.jsx and Products/detail/index.jsx
   → Products renders:
       <h1>商品列表</h1>
       <Outlet /> → ProductDetail renders here → <h3>商品详情123</h3>
   → Final page output:
       Navigation bar (Home About 小家 商品列表 商品详情 新增商品)
       ──────────────────────────────
       商品列表
       商品详情123

From the browser's perspective, the entire process: the URL changed from localhost:5174 to localhost:5174/#/products/123. Not a single HTTP request was sent, the page didn't flash white, and the navigation bar showed no trace of re-rendering. This is SPA.


8. Concept Mapping: Handwritten Version vs React Router

For every React Router API, you should be able to mentally map it to a concept in the handwritten version:

React Router demo2 Handwritten Version Essence
<HashRouter> window.addEventListener('hashchange', ...) Listen for hash changes
<Link to="/about"> <a href="#/about"> Change hash without request
<Routes> + <Route> this.routers = {} Routing table
<Route path="..." element={...} /> register(key, callback) Register routing rule
useParams() window.location.hash.slice(1) Get params from URL
useNavigate()('/') window.location.hash = '/' Programmatic navigation
<Outlet /> #container (child-level version) Nested mount point
lazy(() => import(...)) Deferred call of register On-demand loading
<Navigate to="..." replace /> Manually change hash + don't keep history Declarative redirect
path="*" Final catch-all match in routing table 404 wildcard

9. Review Checklist

Answer these questions verbally; if you can explain them clearly, you've mastered this part of React Router:

  1. From clicking a navigation bar Link to the page switching, what happens inside React Router?
  2. Why doesn't <Link> refresh the page? What's the difference between it and <a>?
  3. What does HashRouter encapsulate internally? Which parts of the handwritten code does it correspond to?
  4. What problem do lazy + Suspense solve? What happens without them?
  5. Where does the parameter name for useParams come from? Why does UserProfile use id while ProductDetail uses productId?
  6. Why must path="*" be placed after all other Routes?
  7. What role does <Outlet> play in nested routing? What's its relationship to the handwritten #container?
  8. What's the difference between useNavigate() and window.location.href? Why is the former better?
  9. What does the colon in :productId signify? What's the design difference from new?
  10. What's the consequence of omitting the replace attribute on Navigate?

#React #ReactRouter #SPA #FrontendRouting