Frontend Routing Is Just a Mapping Table and a DOM Swap
Recommended reason: Instead of pasting Vue Router / React Router API docs, go back to 2013 and hand-write a primitive HashRouter to thoroughly explain "why frontend routing uses
#."
You open Taobao, click from the homepage into a product detail page, and the page appears with a "whoosh." No white screen, no flicker, as smooth as a native app.
You open a traditional website, click a navigation link — the page flashes white, the top progress bar spins a couple of times, and then the entire page reappears.
Both are web pages, so why is the experience so different?
Because the former uses frontend routing — the URL changes, but the page does not reload. A new entry appears in the browser history, but no HTTP request is sent.
This article uses vanilla JavaScript to hand-write a HashRouter, thoroughly explaining the underlying principles of frontend routing. After reading, you will know: why does a # in the URL prevent the page from refreshing? What exactly does the hashchange event do? The bottom layer of those "modern routing libraries" is nothing more than a prettier wrapper.
Step 1: The "Pain" of Traditional Multi-Page
First, look at a primitive page navigation code snippet:
<!-- demo/index.html — Traditional multi-page navigation -->
<header>
<nav>
<ul>
<li><a href="http://127.0.0.1:5500/demo/index.html">Home</a></li>
<li><a href="http://127.0.0.1:5500/demo/about.html">About Us</a></li>
</ul>
</nav>
</header>
<main>
<h1>Hello</h1>
</main>
When you click "About Us," what does the browser do?
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
U->>B: Click link
B->>S: GET /about.html
S-->>B: 200 OK + HTML
B->>B: Clear page, re-render entire DOM
Note over B: White screen flicker
Every navigation is a complete HTTP request-response cycle. The server returns an entire HTML document, the browser clears the current page, re-parses, and re-renders.
In the PC era, this was fine — fast network, simple pages.
In the mobile era, problems arise: the network might only be 3G, but pages are increasingly complex. Every link click waits for a server response, and that moment of white screen is enough to make a user close the page.
Core contradiction: The URL must change (different pages need different URLs), but every URL change triggers a full refresh — can we make the URL change without sending an HTTP request?
Step 2: The SPA Idea — Decoupling URL and DOM
The idea behind a Single Page Application (SPA) is simple:
Traditional: URL changes → Send HTTP request → Server returns entire new page → DOM completely rebuilt
SPA: URL changes → JavaScript intercepts → Only replace the DOM block that needs to change
graph LR
A[User Click] --> B{URL Changed}
B -->|Traditional| C[HTTP Request Server]
C --> D[Full Page Refresh]
B -->|SPA| E[JS Intercept]
E --> F[Partial DOM Replacement]
F --> G[Smooth Page Switch]
One-sentence definition: Frontend routing = JavaScript takes over URL changes, turning "URL → New Page" into "URL → DOM Replacement," bypassing the server.
The core idea is there, but how to implement it technically? A URL change usually means the browser wants to navigate — how do you prevent this default behavior?
The answer lies in a special part of the URL: the Hash (#).
Step 3: Why Can the Hash "Change Without Sending"?
First, look at the structure of a URL:
https://www.example.com/path?query=abc#/page1
└─┬──┘ └────┬─────┘ └─┬┘ └──┬──┘ └──┬──┘
protocol host path query hash
The Hash is the segment starting with #. It has a special property:
Hash changes do not trigger the browser to send a request to the server. The page does not refresh.
This is fixed in the browser specification — the Hash was originally designed for "anchor links": jumping to a specific position within a long page (like "back to top"), which naturally shouldn't trigger a full page refresh.
<!-- demo2/demo.html — Original use of Hash: anchors -->
<a name="top"></a>
<a href="#bottom">Go to bottom</a>
<div style="height: 200vh;"></div>
<a href="#top">Back to top</a>
<div style="height: 300vh;"></div>
<a name="bottom"></a>
<script>
// 🔑 Browser natively supports listening for hash changes
window.addEventListener('hashchange', function(event) {
console.log('hash changed');
console.log(event.newURL); // New full URL
console.log(event.oldURL); // Old full URL
})
</script>
Open this page, click "Go to bottom" — #bottom is appended to the URL, the page scrolls to the corresponding position, but the browser sends no HTTP request.
Frontend engineers quickly realized: since changing the Hash doesn't refresh, if I listen for its change and use JavaScript to replace the page content inside that event, isn't that "frontend routing"?
This is the core principle of Hash routing — borrowing the shell of anchors to do the work of routing.
Step 4: Hand-Writing a HashRouter
Now let's implement the simplest Hash routing. The requirements are simple:
- Click
#/page1→ Display "Page One" - Click
#/page2→ Display "Page Two" - Click
#/page3→ Display "Page Three" - The page does not refresh, only the content inside the container is replaced
First, look at the complete code, then break down the key design:
<!-- demo2/index.html — Hand-written HashRouter -->
<header>
<nav>
<ul>
<li><a href="#/page1">Page One</a></li>
<li><a href="#/page2">Page Two</a></li>
<li><a href="#/page3">Page Three</a></li>
</ul>
</nav>
</header>
<div id="container"></div>
<script>
class HashRouter {
constructor() {
// 🔑 Route table: hash path → callback function
// This is the essence of "frontend routing" — a mapping table
this.router = {}
// 🔑 Listen for hashchange, bind the load method
// ⚠️ Must use bind(this), otherwise 'this' inside load points to window
window.addEventListener('hashchange', this.load.bind(this))
}
// Register route: store the path and corresponding render logic
register(hash, callback) {
this.router[hash] = callback
}
// Route matching: find the corresponding callback based on the current hash and execute it
load() {
let hash = location.hash.slice(1) // Remove the leading #
let handler = this.router[hash]
if (handler) {
handler.call(this) // Execute the registered callback
}
}
}
// Using HashRouter
let router = new HashRouter()
let container = document.getElementById('container')
router.register('/page1', function() {
container.innerHTML = '<h1>Page One</h1>'
})
router.register('/page2', function() {
container.innerHTML = '<h1>Page Two</h1>'
})
router.register('/page3', function() {
container.innerHTML = '<h1>Page Three</h1>'
})
</script>
Breaking Down the Key Design Layer by Layer
Layer 1 — Route Table this.router
// 🔑 The essence of frontend routing: an object where key is the path, value is the render function
this.router = {
'/page1': function() { container.innerHTML = '<h1>Page One</h1>' },
'/page2': function() { container.innerHTML = '<h1>Page Two</h1>' },
'/page3': function() { container.innerHTML = '<h1>Page Three</h1>' },
}
This is essentially the same thing as the routes config in Vue Router or <Route path="/page1" component={Page1} /> in React Router. It's just "this URL corresponds to that component," but modern frameworks do more for you: nested routes, lazy loading, navigation guards... but the core data structure is a mapping table.
Layer 2 — The hashchange Event
This is the engine of the whole mechanism. The browser triggers it when the Hash changes, and we hang our route matching logic on it.
window.addEventListener('hashchange', this.load.bind(this))
Why not window.addEventListener('hashchange', this.load)? This leads to the most important pitfall of this article.
⚠️ Key Pitfall: this Binding — A Classic Bug in Frontend Routing
// ❌ Wrong way
window.addEventListener('hashchange', this.load)
// Inside the load method
load() {
console.log(this) // Output: window, not the HashRouter instance!
let hash = location.hash.slice(1)
let handler = this.router[hash] // ❌ window.router is undefined → Error
}
Why does this point to window?
This is a core rule of JavaScript's event mechanism: inside an event handler function, this defaults to the DOM element that triggered the event. The hashchange event is attached to window, so this is window.
What we actually need is this pointing to the HashRouter instance — because the route table this.router exists on the instance.
Three solutions:
| Solution | Code | Principle |
|---|---|---|
bind |
this.load.bind(this) |
Returns a new function, this permanently bound to the specified value |
call |
Wrap a layer: () => this.load.call(this) |
Manually specify this on each invocation |
| Arrow function | window.addEventListener('hashchange', () => this.load()) |
Arrow functions have no own this, inherit from outer scope |
This article's code uses bind because it completes the binding once during the construction phase, and this is already correct on subsequent event triggers, with no extra overhead.
This pitfall is not unique to HashRouter — any scenario where you need to access instance properties inside an event callback will encounter this
thisloss problem. Only by understanding this can you truly grasp JavaScript'sthismechanism.
Step 5: The Complete Lifecycle of Hash Routing
Stringing all the links above together, a complete Hash routing navigation looks like this:
sequenceDiagram
participant U as User
participant B as Browser
participant HR as HashRouter
participant DOM as DOM
U->>B: Click #/page2 link
B->>B: URL hash changes, no HTTP request sent
B->>HR: Trigger hashchange event
HR->>HR: load() is called
HR->>HR: Retrieve callback from this.router
HR->>DOM: container.innerHTML = 'Page Two'
DOM-->>U: Page content switches smoothly
Compare with the traditional multi-page flow:
Traditional: Click → HTTP Request → Wait for Response → Full Page Refresh → White Screen → Render
Hash: Click → hashchange → DOM Replacement → Done
Without the HTTP round-trip and full page repaint, the user experience goes from "waiting" to "instant switch."
Step 6: Limitations of Hash Routing
Knowing the principle is not enough; as a qualified frontend developer, you should also know its hard flaws:
| Limitation | Explanation |
|---|---|
| Ugly URLs | /#/page1 is uglier than /page1, users find the # strange |
| SEO Unfriendly | Search engine crawlers usually ignore content after #, making SPA pages hard to index |
| Anchor Conflicts | If the page itself needs to use # for anchor positioning, it will conflict with routing functionality |
| Server Unaware | Content after # is not sent to the server, server logs only show /, making first-screen SSR impossible |
These limitations gave birth to History API routing (pushState + popstate), which is the history mode in Vue Router and BrowserRouter in React Router. But that's another article — Hash routing remains the best starting point for understanding frontend routing because its principle is the most direct and transparent.
Golden sentence: Frontend routing is not about "jumping pages," but about "swapping DOM." Hash gives us an entry point to change the URL without refreshing; the rest is just a mapping table + an event listener.
Remembering this sentence is enough.
Next time you write an SPA, whether using Vue Router or React Router, you should have a mental picture: a route mapping table, a container DOM, a hashchange (or popstate) event. The essence never changes.
An open question: If your SPA needs to support both anchor positioning (in-page #section1) and Hash routing (#/page1), how would you design it? Share your solution in the comments.