背景
打开一个网页,浏览器地址栏里的 URL 发生变化,页面内容随之更新------这件事在今天的 Web 应用里已经习以为常。但在单页应用(SPA)成为主流之前,URL 变化几乎总是意味着一次完整的页面刷新。
本文从浏览器 URL 的结构出发,拆解传统多页导航的问题,再逐步说明 Hash 路由如何在「不刷新页面」和「URL 与资源一一对应」之间找到平衡,最后给出一份可运行的 HashRouter 实现。
传统多页导航的工作方式
先看一个最简单的多页面站点。两个 HTML 文件,通过 <a> 链接互相跳转:
xml
<!-- demo/index.html -->
<nav>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="about.html">关于我们</a></li>
</ul>
</nav>
<main><h1>首页</h1></main>
<!-- demo/about.html -->
<nav>
<ul>
<li><a href="index.html">首页</a></li>
<li><a href="about.html">关于我们</a></li>
</ul>
</nav>
<main><h1>关于我们</h1></main>
用户点击链接时,浏览器执行以下步骤:
- 根据
href中的 URL 向服务器发起 HTTP 请求 - 服务器返回对应资源的
text/html响应 - 浏览器接收响应,销毁当前页面,重新渲染整个文档
- 浏览器历史记录中插入一条新记录
这个流程本身是合理的------URL 和资源是一一对应的关系,每个 URL 指向一个完整的 HTML 文档。但在移动网络环境下,每次跳转都经历「请求 → 等待 → 白屏 → 重新渲染」,体验上会明显感觉到页面闪烁。
单页应用的诉求
移动时代带来了一个变化:App 内的页面切换是即时的,不存在浏览器级别的整页刷新。用户对 Web 的预期也随之提高------内容切换应该更快,公共部分(导航栏、侧边栏)不应该反复销毁重建。
单页应用(Single Page Application,SPA)的思路是:
- 整个应用只加载一个 HTML 文档
- 导航栏、页脚等公共区域保持不变
- 点击链接时,只替换内容区域(例如一个
<div id="container">) - 不再向服务器请求完整的 HTML 页面
这就引出了一个问题:URL 还要不要变?
如果 URL 不变,用户无法收藏特定页面,也无法使用浏览器的前进/后退。SPA 需要一种机制,既能改变 URL,又不会触发整页刷新。
URL 的 Hash 部分
一个完整的 URL 可以拆成以下结构:
bash
http(s)://www.example.com/path/to/page?q=search#/section
└──┬──┘ └─────┬──────┘ └─────┬─────┘ └──┬──┘ └───┬───┘
protocol host path query hash/fragment
Hash 是 URL 中 # 之后的部分。它的原始用途是锚链接------标记长页面中的某个位置,点击后浏览器直接滚动到对应区域,不触发页面刷新:
css
<a href="#bottom">去到底部</a>
<div style="height: 200vh;"></div>
<a name="bottom"></a>
Hash 有两个关键特性:
- 修改 hash 不会导致浏览器向服务器发起请求 。
#之后的内容不会发送到服务端。 - 修改 hash 会更新浏览器地址栏,并且会在浏览器历史记录中插入一条新记录。
这两个特性恰好满足了 SPA 前端路由的需求------URL 变了,页面没刷新,历史记录也有了。
Hash 路由的设计
前端路由要解决的核心问题是:把不同的 URL(更准确地说,不同的 hash)映射到不同的内容渲染逻辑上。
拆开来看,需要三样东西:
- 路由表:一个存储「hash 路径 → 渲染函数」映射的数据结构
- 监听机制:当 hash 变化时,能够感知并响应
- 渲染入口:一个固定的 DOM 挂载点,用于替换内容
浏览器提供了 hashchange 事件来支持第二点。每当 URL 的 hash 部分发生变化(无论是用户点击锚链接、手动修改地址栏、还是 JavaScript 修改 location.hash),hashchange 事件都会触发:
javascript
window.addEventListener('hashchange', function(event) {
console.log('hash 改变了');
console.log(event.newURL); // 变化后的完整 URL
console.log(event.oldURL); // 变化前的完整 URL
});
基于这个事件,可以构建一个简单的 HashRouter 类。
实现
以下实现来自 demo2/index.html:
javascript
class HashRouter {
constructor() {
// 路由表:存储 hash 路径到回调函数的映射
this.routers = {};
// 监听 hashchange 事件
// 注意:事件回调中的 this 默认指向 window
// 使用 bind 将 this 绑定到 HashRouter 实例
window.addEventListener('hashchange', this.load.bind(this));
}
// 注册路由:将 hash 路径与回调函数关联
register(hash, callback) {
this.routers[hash] = callback;
}
// 加载路由:根据当前 hash 执行对应的回调
load() {
let hash = location.hash.slice(1); // 去掉开头的 #
let handler;
if (!hash) {
// hash 为空时不做处理(可以在这里设置默认路由)
} else {
handler = this.routers[hash];
}
handler.call(this); // 执行回调,手动指定 this
}
}
使用方式:
ini
let router = new HashRouter();
let container = document.getElementById('container');
// 注册三条路由,每条路由对应不同的内容渲染
router.register('/page1', () => (container.innerHTML = '页面一'));
router.register('/page2', () => (container.innerHTML = '页面二'));
router.register('/page3', () => (container.innerHTML = '页面三'));
页面中的导航链接使用 hash 形式的 href:
xml
<nav>
<ul>
<li><a href="#/page1">页面一</a></li>
<li><a href="#/page2">页面二</a></li>
<li><a href="#/page3">页面三</a></li>
</ul>
</nav>
<div id="container"></div>
几处值得注意的实现细节:
location.hash的返回值包含开头的#,注册和匹配时统一用.slice(1)去掉,保持路由键名干净addEventListener的回调中,this默认指向触发事件的 DOM 元素(这里就是window)。代码中使用.bind(this)返回一个新函数,将this固定为 HashRouter 实例,确保load方法内部能正确访问this.routersregister方法将回调存入this.routers对象,load方法通过this.routers[hash]完成 O(1) 查找------路由表本质上是一个键值对
回看:从锚链接到前端路由
readme.md 中概括了这条线索:
- 锚链接是 hash 的原始用途------在长页面中定位,用户体验类似「坐电梯直达」
- hash 作为 URL 的一部分,改变它不会触发页面刷新,但会更新浏览器地址栏和历史记录
- 前端路由抓住了这个特性:用
#/page1、#/about这样的 hash 值模拟多页面的 URL 结构,通过hashchange事件触发 DOM 或组件替换
这是一个典型的「复用已有基础设施解决新问题」的模式。浏览器没有专门为 SPA 设计路由 API,但 hash + hashchange 的组合刚好能拼出前端路由需要的全部行为。
Hash 路由的局限与后续方向
Hash 路由解决了 SPA 最紧迫的问题,但也有明显的局限:
- URL 中始终带着
#,不够干净 - hash 部分不会发送到服务端,SEO 不友好
- 与服务端渲染(SSR)配合困难
HTML5 引入了 History API(pushState / replaceState + popstate 事件),可以在不刷新页面的情况下修改完整的 URL 路径,不再需要 #。这是 Hash 路由之后的下一个演进方向,原理与本文讨论的 Hash 路由一脉相承------都是在「不刷新页面」和「URL 可变」之间建立映射。
小结
前端路由要做的事情可以归结为一句话:URL 改变时,用一段 JavaScript 逻辑替代浏览器的默认导航行为,在不刷新页面的前提下完成内容替换。
Hash 路由是这条思路上最直接的实现:用 # 之后的片段做路径标识,用 hashchange 事件做触发器,用一个键值对做路由表。实现代码不过二十行,但它在传统多页和现代 SPA 之间划出了一条清晰的分界线。