本文从一个最朴素的多页面 Demo 出发,一步步推导出单页应用(SPA)的核心思想,并用原生 JavaScript 手写一个 HashRouter,帮你真正理解前端路由到底在做什么。
一、传统多页面时代:每次跳转都是一次"重生"
先看一段最朴素的 HTML:
<!-- index.html 首页 -->
<header>
<nav>
<ul>
<li><a href="http://127.0.0.1:5501/fe/history/demo/index.html">首页</a></li>
<li><a href="http://127.0.0.1:fe/history/demo/about.html">关于我们</a></li>
</ul>
</nav>
</header>
<main>
<h1>首页</h1>
</main>
about.html 的结构几乎一模一样,只是 <h1> 里的文字不同。
这就是传统多页面(MPA)开发模式。当用户点击 <a> 标签时,浏览器会经历这样一个完整流程:
- 浏览器拿着 URL,通过 HTTP 协议向服务器发起请求
- 服务器伺服状态,返回对应的
text/html响应 - 浏览器拿到响应数据,重新渲染整个页面
- 浏览记录里插入一条新记录
问题在哪? 每次跳转,整个页面都要从白屏开始重新渲染。在 PC 时代网速尚可忍受,但到了移动时代——页面白一下、闪烁一下——体验就变得难以接受了。
更要命的是:两个页面之间往往 90% 的结构(header、nav、footer)完全一样,只有 <main> 里的内容不同。重新渲染整个页面,纯粹是浪费。
二、核心矛盾:URL 必须变,但页面不能刷新
我们想要的是一种"既能让 URL 变化,又不会触发整页刷新"的机制。
为什么 URL 必须变?因为 URL 是资源和状态的唯一标识。用户需要通过 URL 分享某个页面、使用浏览器前进/后退按钮、刷新后回到正确的位置。如果 URL 不变,这些能力就全丢了。
那有没有 URL 中的一部分,改变了但不会触发请求?
有——hash。
三、URL 结构拆解
先复习一下一个完整 URL 的结构:
http(s)://www.baidu.com/u/123?a=1&b=2#/page1
──┬── ────┬───── ─┬─ ───┬─── ──┬───
protocol host path query hash
- protocol:协议(http/https)
- host:主机名 + 端口
- path:服务器上的资源路径
- query:查询参数
- hash:以
#开头的片段标识符
关键点:hash 部分的改变,不会触发 HTTP 请求。浏览器不会把 #/page1 发给服务器。
hash 原本的设计目的是锚点链接——在长页面中标记某个位置,点击直达:
<a name="top"></a>
<a href="#bottom">去底部</a>
<div style="height:200vh;background-color:yellow;"></div>
<a href="#top">去顶部</a>
<div style="height:300vh;background-color:green;"></div>
<a name="bottom"></a>
我们可以用一段代码验证 hash 变化时浏览器做了什么:
window.addEventListener('hashchange', function(e) {
console.log('hash 改变了');
console.log(e.newURL); // 变化后的完整 URL
console.log(e.oldURL); // 变化前的完整 URL
})
运行后会发现:点击链接时,URL 的 hash 部分变了,hashchange 事件触发了,但页面没有刷新,没有发出任何网络请求。
这就是前端路由的基石。
四、从锚点到路由:SPA 的诞生
既然 hash 变了不刷新页面,还能触发事件,那我们完全可以这样做:
- 用
#/page1、#/page2、#/page3作为不同的"路由" - 监听
hashchange事件 - 根据当前 hash,动态替换页面某一块区域的内容
页面结构变成这样:
<header>
<nav>
<ul>
<li><a href="#/page1">页面1</a></li>
<li><a href="#/page2">页面2</a></li>
<li><a href="#/page3">页面3</a></li>
</ul>
</nav>
</header>
<!-- 只需要动态修改这个容器 -->
<div id="container"></div>
整个页面只有一个 HTML 文件,#container 就是唯一的"挂载点"。所有的页面切换,本质上是 DOM 的局部替换。
这就是单页应用(SPA, Single Page Application) 的核心思想。
五、手写一个 HashRouter
理解了原理,我们来用原生 JavaScript 实现一个极简的前端路由器:
class HashRouter {
constructor() {
// 路由表:hash -> 回调函数
this.routers = {};
// 监听 hash 变化,触发加载
// 注意:事件回调中 this 默认指向 window,需要 bind
window.addEventListener('hashchange', this.load.bind(this));
}
// 注册路由
register(hash, callback) {
this.routers[hash] = callback;
}
// 根据当前 hash 执行对应回调
load() {
console.log(this);
// 实际项目中这里会解析 location.hash 并查找路由表
}
}
// 使用
let router = new HashRouter();
let container = document.getElementById('container');
router.register('/page1', () => container.innerHTML = '页面一');
router.register('/page2', () => container.innerHTML = '页面二');
router.register('/page3', () => container.innerHTML = '页面三');
这段代码虽然简单,但它已经包含了前端路由的三个核心要素:
| 要素 | 对应代码 | 作用 |
|---|---|---|
| 路由表 | this.routers = {} | URL 与渲染逻辑的映射关系 |
| 监听机制 | hashchange 事件 | 感知 URL 变化 |
| 渲染出口 | #container | 内容替换的挂载点 |
注意 this.load.bind(this) 这一行。addEventListener 的回调函数中,this 默认指向触发事件的元素(这里是 window),而不是 HashRouter 实例。bind 会返回一个绑定了正确 this 的新函数——这是 JavaScript 中处理 this 丢失问题的经典手法。
六、Hash 路由的优缺点
优点
- 实现简单:纯前端即可完成,不需要服务器配合
- 兼容性好:
hashchange事件在所有浏览器中都支持 - 不刷新页面:hash 变化只触发事件,不发请求
缺点
- URL 不够优雅:带个
#,比如example.com/#/about - SEO 不友好:搜索引擎爬虫可能忽略 hash 部分
- 服务端无法感知路由:所有路由都是前端处理的
这些缺点催生了后来更先进的 History 路由(基于 pushState / popstate API),但那是另一篇文章的话题了。
七、总结
从多页应用到单页应用,核心演进的脉络其实很清晰:
多页面(整页刷新)
↓ 痛点:白屏、重复渲染、体验差
Hash 路由(局部替换)
↓ 原理:hash 变化不触发请求 + hashchange 事件
SPA(单页应用)
↓ 框架封装:React Router / Vue Router
现代前端路由
理解了 hashchange 和"URL 变了但页面不刷新"这个核心机制,再去学 React Router 的 <HashRouter> 或 Vue Router 的 createWebHashHistory,你会发现它们底层做的,就是今天我们手写的这套逻辑——只不过用框架的方式包装得更优雅、功能更完善罢了。
所有复杂的框架,拆到最后都是朴素的原生 API。 这就是学原理的意义。