How the Humble URL Hash Fragment Became the Foundation of Single-Page Apps
This article starts from a simple multi-page demo, derives the core idea of Single Page Applications (SPA) step by step, and implements a HashRouter with vanilla JavaScript to help you truly understand what front-end routing actually does.
1. The Traditional Multi-Page Era: Every Navigation is a "Rebirth"
Let's look at a very basic piece of HTML:
<!-- index.html Homepage -->
<header>
<nav>
<ul>
<li><a href="http://127.0.0.1:5501/fe/history/demo/index.html">Home</a></li>
<li><a href="http://127.0.0.1:fe/history/demo/about.html">About Us</a></li>
</ul>
</nav>
</header>
<main>
<h1>Home</h1>
</main>
The structure of about.html is almost identical, only the text inside <h1> is different.
This is the traditional Multi-Page Application (MPA) development model. When a user clicks an <a> tag, the browser goes through this complete process:
- The browser takes the URL and initiates a request to the server via the HTTP protocol.
- The server processes the request and returns the corresponding
text/htmlresponse. - The browser receives the response data and re-renders the entire page.
- A new entry is inserted into the browsing history.
What's the problem? Every navigation causes the entire page to re-render from a blank screen. In the PC era, network speeds were tolerable, but in the mobile era—a flash of white, a flicker—the experience becomes unacceptable.
Even worse: often 90% of the structure (header, nav, footer) between two pages is exactly the same, with only the content inside <main> being different. Re-rendering the entire page is pure waste.
2. The Core Contradiction: The URL Must Change, but the Page Cannot Refresh
We want a mechanism that "allows the URL to change, but does not trigger a full page refresh."
Why must the URL change? Because the URL is the unique identifier for a resource and its state. Users need the URL to share a specific page, use the browser's forward/back buttons, and return to the correct position after a refresh. If the URL doesn't change, all these capabilities are lost.
So, is there a part of the URL that can change without triggering a request?
Yes—the hash.
3. Deconstructing the URL Structure
Let's review the structure of a complete URL:
http(s)://www.baidu.com/u/123?a=1&b=2#/page1
──┬── ────┬───── ─┬─ ───┬─── ──┬───
protocol host path query hash
- protocol: The protocol (http/https)
- host: Hostname + port
- path: The resource path on the server
- query: Query parameters
- hash: The fragment identifier starting with
#
Key point: Changes to the hash part do not trigger an HTTP request. The browser does not send #/page1 to the server.
The original design purpose of the hash was anchor links—marking a position within a long page for direct navigation:
<a name="top"></a>
<a href="#bottom">Go to bottom</a>
<div style="height:200vh;background-color:yellow;"></div>
<a href="#top">Go to top</a>
<div style="height:300vh;background-color:green;"></div>
<a name="bottom"></a>
We can verify what the browser does when the hash changes with a piece of code:
window.addEventListener('hashchange', function(e) {
console.log('hash changed');
console.log(e.newURL); // The complete URL after the change
console.log(e.oldURL); // The complete URL before the change
})
After running this, you'll find: when a link is clicked, the hash part of the URL changes, the hashchange event fires, but the page does not refresh, and no network request is made.
This is the cornerstone of front-end routing.
4. From Anchor to Router: The Birth of SPA
Since changing the hash doesn't refresh the page and can trigger an event, we can do this:
- Use
#/page1,#/page2,#/page3as different "routes" - Listen for the
hashchangeevent - Dynamically replace the content of a specific area on the page based on the current hash
The page structure becomes:
<header>
<nav>
<ul>
<li><a href="#/page1">Page 1</a></li>
<li><a href="#/page2">Page 2</a></li>
<li><a href="#/page3">Page 3</a></li>
</ul>
</nav>
</header>
<!-- Only this container needs dynamic modification -->
<div id="container"></div>
The entire page is just a single HTML file, and #container is the sole "mount point". All page switching is essentially partial DOM replacement.
This is the core idea of a Single Page Application (SPA).
5. Writing a HashRouter by Hand
Now that we understand the principle, let's implement a minimal front-end router using vanilla JavaScript:
class HashRouter {
constructor() {
// Routing table: hash -> callback function
this.routers = {};
// Listen for hash changes, trigger loading
// Note: In event callbacks, 'this' defaults to window, so bind is needed
window.addEventListener('hashchange', this.load.bind(this));
}
// Register a route
register(hash, callback) {
this.routers[hash] = callback;
}
// Execute the corresponding callback based on the current hash
load() {
console.log(this);
// In a real project, you would parse location.hash and look up the routing table here
}
}
// Usage
let router = new HashRouter();
let container = document.getElementById('container');
router.register('/page1', () => container.innerHTML = 'Page One');
router.register('/page2', () => container.innerHTML = 'Page Two');
router.register('/page3', () => container.innerHTML = 'Page Three');
Although simple, this code already contains the three core elements of front-end routing:
| Element | Corresponding Code | Role |
|---|---|---|
| Routing Table | this.routers = {} |
The mapping between URLs and rendering logic |
| Listening Mechanism | hashchange event |
Sensing URL changes |
| Render Outlet | #container |
The mount point for content replacement |
Note the line this.load.bind(this). In addEventListener callbacks, this defaults to the element that triggered the event (here, window), not the HashRouter instance. bind returns a new function with the correct this bound—this is a classic technique for handling the loss of this context in JavaScript.
6. Pros and Cons of Hash Routing
Pros
- Simple to implement: Can be done purely on the front end, no server cooperation needed.
- Good compatibility: The
hashchangeevent is supported in all browsers. - No page refresh: Hash changes only trigger events, no requests are sent.
Cons
- URLs are not elegant: They contain a
#, e.g.,example.com/#/about. - Not SEO-friendly: Search engine crawlers may ignore the hash part.
- Server cannot perceive routes: All routing is handled on the front end.
These shortcomings led to the later, more advanced History routing (based on the pushState / popstate API), but that's a topic for another article.
7. Summary
The core evolution from multi-page applications to single-page applications has a very clear thread:
Multi-Page (Full Page Refresh)
↓ Pain point: White screen, redundant rendering, poor experience
Hash Routing (Partial Replacement)
↓ Principle: Hash changes don't trigger requests + hashchange event
SPA (Single Page Application)
↓ Framework encapsulation: React Router / Vue Router
Modern Front-End Routing
Once you understand hashchange and the core mechanism of "the URL changes but the page doesn't refresh", when you go to learn React Router's <HashRouter> or Vue Router's createWebHashHistory, you'll find that what they do under the hood is exactly the logic we hand-wrote today—just wrapped more elegantly and with more features by the framework.
All complex frameworks, when taken apart, are just plain native APIs. That's the meaning of learning the principles.