跪拜 Guibai
← Back to the summary

Frontend Routing from Scratch: Hash, History, and the MPA-to-SPA Shift

This article takes you from the underlying principles of the browser, step by step, to understand the pain points of traditional multi-page applications, the working mechanism of Hash routing, and then build a complete single-page application with React Router.


1. How Traditional Multi-Page Applications (MPA) Work

1.1 A Complete HTTP Request Cycle

Open the browser, enter a URL, press Enter—what happens behind the scenes?

User enters http://example.com/about in the address bar
        │
        ▼
Browser parses the URL and initiates an HTTP GET request to the server
        │
        ▼
Server receives the request and returns the complete HTML document for about.html
        │
        ▼
Browser receives the response, clears the current page, re-parses and re-renders the entire DOM
        │
        ▼
Browser inserts a new record into the history

Every click on a link, every page jump, this entire process runs in full.

1.2 The Pain Points of MPA

Let's look at the simplest multi-page application example. Suppose we have two pages—Home and About:

index.html

<!DOCTYPE html>
<html>
<head><title>Home</title></head>
<body>
    <header>
        <nav>
            <ul>
                <li><a href="index.html">Home</a></li>
                <li><a href="about.html">About Us</a></li>
            </ul>
        </nav>
    </header>
    <main>
        <h1>Home</h1>
    </main>
    <footer>© 2026</footer>
</body>
</html>

about.html

<!DOCTYPE html>
<html>
<head><title>About</title></head>
<body>
    <header>
        <nav>  <!-- Exactly the same navigation bar as index.html -->
            <ul>
                <li><a href="index.html">Home</a></li>
                <li><a href="about.html">About Us</a></li>
            </ul>
        </nav>
    </header>
    <main>
        <h1>About Us</h1>  <!-- Only this line is different -->
    </main>
    <footer>© 2026</footer>
</body>
</html>

Notice something? 90% of the content in the two files is identical<header>, <nav>, <footer> are all duplicated, only the content inside <main> differs.

But the problem goes beyond that. The more serious issue is the disjointed user experience:

  1. Click a link → HTTP request sent
  2. Wait for server response (network latency)
  3. Page goes blank → entire DOM re-renders
  4. User sees the new page

This "flash then appear" experience is especially jarring on mobile. Page transitions in native apps are smooth—we've grown accustomed to that feeling.


2. The Core Idea of Single-Page Applications (SPA)

2.1 Only Replace What Needs to Change

Since only the <main> section's content changes with each jump, why re-render the entire page?

The SPA concept is remarkably simple: the entire application has only one HTML page, and all "page switches" are essentially injecting different content into the same mount point.

Before switch:                   After switch:
┌──────────────────┐            ┌──────────────────┐
│   header         │ ← stays    │   header         │
│   nav            │ ← stays    │   nav            │
├──────────────────┤            ├──────────────────┤
│  Home content    │ ← only this changes → │  About content    │
├──────────────────┤            ├──────────────────┤
│   footer         │ ← stays    │   footer         │
└──────────────────┘            └──────────────────┘

No white flash, no full HTTP request, no re-parsing CSS and JS—the user experience is immediately elevated.

2.2 The Key Challenge: How to "Change the URL Without Making a Request"

SPA faces a fundamental contradiction:

The solution hides in an inconspicuous corner of the URL: the Hash (#)


3. Hash Routing: The Cornerstone of SPA

3.1 What is a Hash

A complete URL structure:

https://example.com/page?key=value#section2
  └──┬──┘ └──┬──┘ └─┬─┘ └──┬──┘ └──┬──┘
   Protocol  Host   Path   Query    Hash

The special thing about the Hash part (the # and everything after it): changing it does not trigger the browser to send a request to the server.

This feature was originally designed for in-page anchor navigation—clicking <a href="#section2"> jumps to the position of name="section2" on the page. But frontend engineers discovered a more ingenious use: using the Hash to simulate URL changes, enabling page switching without triggering server requests.

3.2 Manually Implementing a HashRouter

Understanding the principle, let's write our own Hash router:

class HashRouter {
    constructor() {
        // Routing table: stores hashes and corresponding callback functions
        this.routers = {};

        // Listen for hash changes
        window.addEventListener('hashchange', this.load.bind(this));
    }

    // Register a route: specify what operation corresponds to a certain hash
    register(hash, callback) {
        this.routers[hash] = callback;
    }

    // Triggered when hash changes: find the corresponding function from the routing table and execute it
    load() {
        let hash = location.hash;          // Get "#/about"
        let callback = this.routers[hash]; // Find the corresponding function
        if (callback) {
            callback();                    // Execute it
        }
    }
}

Using it is simple:

let router = new HashRouter();
let container = document.getElementById('container');

// Register routes: hash → what to do
router.register('#/home',  () => container.innerHTML = '<h1>Home</h1>');
router.register('#/about', () => container.innerHTML = '<h1>About Us</h1>');
router.register('#/contact', () => container.innerHTML = '<h1>Contact Us</h1>');

Links in the page are written like this:

<a href="#/home">Home</a>
<a href="#/about">About Us</a>
<a href="#/contact">Contact Us</a>

Click a link → hash changes → hashchange event fires → load() executes → finds the corresponding callback from the routing table → replaces the content of #container. Not a single HTTP request in the entire process.

3.3 A Key JS Knowledge Point: this Binding

Notice the line this.load.bind(this)? The problem it solves is a classic one.

Inside an addEventListener callback, this by default points to the element that triggered the event (in this case, window). But our load() method needs to access this.routers—it must be the router instance.

// Without bind:
window.addEventListener('hashchange', this.load);
// this inside load → window (❌ cannot access routers)

// With bind:
window.addEventListener('hashchange', this.load.bind(this));
// this inside load → router instance (✅ can access routers)

bind() does not execute the function; it returns a new function where this is permanently locked to the value you specify. This perfectly meets addEventListener's need for a "function reference waiting to be called."


4. Building a Modern SPA with React Router

Once you understand the underlying principles of Hash routing, React Router will feel like a natural progression—it simply wraps the same mechanism in a declarative way.

4.1 Core Components: HashRouter + Routes + Route

import { HashRouter, Routes, Route } from 'react-router-dom';

function App() {
    return (
        <HashRouter>
            <Navigation />
            <Routes>
                <Route path='/' element={<Home />} />
                <Route path='/about' element={<About />} />
            </Routes>
        </HashRouter>
    );
}

4.2 The Link Component: Replacing <a> Tags

Traditional <a href="/about"> triggers a full page navigation, which is disastrous in an SPA. React Router provides Link:

import { Link } from 'react-router-dom';

function Navigation() {
    return (
        <nav>
            <Link to="/">Home</Link>
            <Link to="/about">About</Link>
            <Link to="/user/123">User 123</Link>
        </nav>
    );
}

Link intercepts the click event, changing the URL by manipulating the hash (or the History API) without sending a request to the server. The generated HTML is still an <a> tag, ensuring accessibility and SEO.

4.3 Dynamic Route Parameters: useParams

Routes aren't limited to matching fixed paths; they can also capture dynamic values:

{/* Route definition */}
<Route path='/user/:id' element={<UserProfile />} />

{/* Inside the UserProfile component */}
import { useParams } from 'react-router-dom';

function UserProfile() {
    let { id } = useParams();  // Extract the value of :id from the URL
    return <h1>User ID: {id}</h1>;
}

Visiting /#/user/123 → the value of id is "123".

4.4 Nested Routes: Outlet

Real applications often have hierarchical relationships—for example, a product list page with product details nested underneath:

{/* Route definition: supports nesting */}
<Route path="/products" element={<Products />}>
    <Route path=":productId" element={<ProductDetail />} />
</Route>

{/* Products component: use Outlet to reserve a spot for child routes */}
import { Outlet } from 'react-router-dom';

function Products() {
    return (
        <div>
            <h1>Product List</h1>
            <Outlet />  {/* The matched child route component renders here */}
        </div>
    );
}

4.5 Route Lazy Loading: lazy + Suspense

SPA bundles all pages together, which can slow down the initial screen load. lazy makes each page download only when accessed for the first time:

import { lazy, Suspense } from 'react';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/about'));
const UserProfile = lazy(() => import('./pages/UserProfile'));

function App() {
    return (
        <HashRouter>
            {/* Suspense is required: shows a loading indicator while the lazy-loaded component downloads */}
            <Suspense fallback={<div>Loading...</div>}>
                <Navigation />
                <Routes>
                    <Route path='/' element={<Home />} />
                    <Route path='/about' element={<About />} />
                    <Route path='/user/:id' element={<UserProfile />} />
                </Routes>
            </Suspense>
        </HashRouter>
    );
}

Each page component is split into a separate JS file (handled automatically by Vite/Webpack), loaded only when the user actually visits it.

4.6 404 Fallback and Programmatic Navigation

{/* The wildcard * matches all paths not matched by preceding Routes */}
<Route path="*" element={<NotFound />} />

You can also implement programmatic navigation within a component using useNavigate:

import { useNavigate } from 'react-router-dom';

function NotFound() {
    let navigate = useNavigate();

    useEffect(() => {
        setTimeout(() => navigate('/'), 3000);
    }, []);

    return <h1>404 - Redirecting to Home in 3 seconds</h1>;
}

5. MPA vs SPA Comparison Summary

Traditional MPA SPA
Page Files Each page corresponds to one HTML file Only one HTML file
Page Switching Initiates HTTP request, full page refresh JS intercepts, partial DOM replacement
User Experience White flash, disjointed feeling Smooth switching, like a native App
Server Load Returns complete HTML for every request Only provides data APIs, doesn't assemble pages
Initial Load Speed Fast (only loads the current page) Slow (needs to load JS framework + lazy loading optimization)
SEO Naturally friendly Needs SSR/pre-rendering assistance
Frontend Routing None (routing controlled by server) Present (frontend independently manages routing table)

6. A Complete Learning Path from Zero to One

Reviewing the entire learning process, the logical chain is as follows:

  1. Understand the HTTP request cycle — Traditional MPA goes through the complete request-response flow on every click, leading to a disjointed experience
  2. Identify the problem — 90% of page content is duplicated, re-rendering the entire page for only a 10% change each time
  3. Find the breakthrough — Changing the URL Hash (#) does not trigger an HTTP request
  4. Master the underlying API — The hashchange event + location.hash are the cornerstones for implementing frontend routing
  5. Hand-write a HashRouter — Use native JS classes and event listeners to implement a minimal viable router
  6. Understand this binding.bind(), .call(), .apply() are essential tools in event callbacks
  7. Migrate to React RouterHashRouter, Routes, Route, Link are the standard declarative frontend routing solutions
  8. Advanced optimizationlazy + Suspense for on-demand loading, useParams for dynamic routing, Outlet for nested routing

Each step solves the pain point of the previous one, with no leaps and no magic. Once you thoroughly grasp the underlying principles, using a framework is no longer about "memorizing APIs" but about understanding "of course it was designed this way."


The complete example code for this article can be found in ai/fe/html/history/ (native implementation) and ai/fe/react/router/react-router/ (React implementation).