从页面跳转到手写 SPA:深入理解 Hash 路由与浏览器历史
- 前言
- 1. 从多页面应用到单页应用
-
- 1.1 一次传统页面跳转发生了什么
- 1.2 SPA 为什么需要前端路由
- 2. 理解 URL、Hash 与浏览历史
-
- 2.1 Hash 是 URL 中的哪一部分
- 2.2 锚点实验:先看见 Hash 如何变化
- 3. 手写 HashRouter:逐块拆解核心代码
-
- 3.1 页面骨架:路由入口与视图挂载点
- 3.2 路由表:为什么用对象保存回调
- 3.3 监听 Hash:bind 解决的不是语法,而是 this
- 3.4 register:注册只建映射,不负责渲染
- 3.5 load:读取、规范化、匹配、执行
- 3.6 实例化与页面回调:闭包把视图连接起来
- 4. 把碎片串起来:一次路由切换的完整执行链
-
- 4.1 从点击页面二到视图更新
- 4.2 首次加载为什么是特殊场景
- 5. Hash 路由的能力边界与方案选择
-
- 5.1 Hash 路由与 History 路由对比
- 5.2 从手写路由器到框架路由器
- 总结
前言
打开一个传统网站,点击“关于我们”,浏览器通常会请求一份新的 HTML,再重新解析文档、构建 DOM 并绘制页面。这个过程直观、可靠,却也意味着页面公共区域可能被反复加载,网络稍慢时还容易出现短暂白屏。移动互联网兴起之后,用户逐渐习惯原生 App 那种“只更新内容区域”的体验,单页应用(Single Page Application,SPA)也因此成为前端开发的重要形态。
SPA 只有一份主要 HTML,却仍然需要让不同 URL 对应不同内容。例如,访问 #/page1 显示页面一,访问 #/page2 显示页面二;点击浏览器的前进、后退按钮时,界面还应跟随历史记录恢复。解决“URL 变化与视图更新如何对应”这个问题的机制,就是前端路由。
本文从传统多页面跳转讲起,先用锚点实验理解 URL 的 Hash,再逐块拆解一个原生 JavaScript 实现的 HashRouter。重点不只是记住 hashchange、location.hash 和 bind 的写法,而是看清它们如何协作成一条完整的路由执行链,并进一步补齐首屏渲染、404 兜底、事件解绑和安全渲染等工程细节。
1. 从多页面应用到单页应用
1.1 一次传统页面跳转发生了什么
传统多页面应用(Multi-Page Application,MPA)通常让一个 URL 对应一份服务端资源。下面是最典型的导航结构:
<nav>
<ul>
<li><a href="/demo/index.html">首页</a></li>
<li><a href="/demo/about.html">关于我们</a></li>
</ul>
</nav>
用户点击“关于我们”后,浏览器会把当前地址切换为 /demo/about.html,向服务器请求新页面,然后解析 HTML、加载依赖资源、构建 DOM/CSSOM 并完成渲染。与此同时,浏览器通常还会向会话历史中加入一条记录,因此前进、后退按钮能够在两个页面之间导航。
示例中的首页与关于页分别拥有完整的 html、head 和 body,正是传统多页结构。关于页还给链接添加了 target="_blank",它会在新的浏览上下文中打开页面,而不是替换当前页。实际项目若明确需要新标签页,通常写成:
<a href="/demo/about.html" target="_blank" rel="noopener">
关于我们
</a>
这里的 rel="noopener" 用于切断新页面通过 window.opener 操作来源页面的能力,是更稳妥的安全习惯。若并不需要保留当前页面,则不应滥用 _blank。
传统页面导航的特点可以概括如下:
| URL 与资源 | 一个 URL 通常对应一份独立 HTML 或服务端响应 |
| 页面更新 | 整个文档重新加载和渲染 |
| 公共布局 | 页头、导航等内容可能被重复返回与解析 |
| 浏览历史 | 浏览器原生维护,前进和后退行为直观 |
| 首次加载 | 通常只获取当前页面所需内容 |
| 页面切换体验 | 受网络和服务端响应影响,可能出现刷新感或白屏 |
需要特别区分两个常见的浏览器对象:navigator 主要描述浏览器与运行环境,例如语言、在线状态和 User Agent;当前 URL 的读取与跳转主要由 window.location 负责。前端路由真正频繁使用的是 location,而不是 navigator。
1.2 SPA 为什么需要前端路由
SPA 的核心思路是:只加载一次应用外壳,后续切换页面时保留外壳,仅替换指定挂载点中的内容。例如,导航栏始终保留,只把页面内容渲染进 #container:
<header>固定导航区域</header>
<!– 路由匹配成功后,只更新这个挂载点 –>
<div id="container"></div>
这能减少整页刷新,保留应用的内存状态,也让切换动画和交互体验更接近桌面或移动 App。不过,单纯调用 DOM API 替换内容还不够:如果页面一、页面二始终共用同一个 URL,用户就无法收藏具体页面,刷新后也不知道该恢复哪个视图,浏览器的前进与后退更无从谈起。
前端路由的本质,是在不重新加载整份文档的前提下,建立 URL 状态与页面视图之间的映射关系,并在 URL 发生变化时驱动视图更新。
于是,一个最小路由系统至少要完成三件事:
- 改变 URL:点击导航后,地址栏必须出现可区分的路径状态。
- 监听变化:URL 状态改变时,JavaScript 能收到通知。
- 匹配并渲染:找到该路径对应的处理函数,把正确内容放进挂载点。
Hash 路由恰好提供了这三项能力,而且无需服务端为每个前端路径配置回退规则,因此很适合用来理解前端路由的基本模型。
2. 理解 URL、Hash 与浏览历史
2.1 Hash 是 URL 中的哪一部分
观察下面这个地址:
https://www.example.com/u/123?a=1&b=2#/page1
它可以拆成以下几部分:
| 协议 protocol | https: | 规定通信方案 |
| 主机 host | www.example.com | 标识服务器及端口 |
| 路径 pathname | /u/123 | 标识服务端资源路径 |
| 查询参数 search | ?a=1&b=2 | 携带键值形式的请求参数 |
| 片段 hash | #/page1 | 标识文档内部位置或客户端状态 |
Hash 从 # 开始,正式名称是 URL Fragment(片段标识符)。它最初用于定位长页面中的某个元素,例如访问 article.html#chapter-3 时直接滚动到第三章。它有两个非常适合前端路由的特征:
- 修改片段会改变地址栏和浏览历史,但通常不会触发当前 HTML 文档的重新请求。
- JavaScript 可以通过 location.hash 读取片段,并通过 hashchange 事件感知它的变化。
还要注意,Hash 通常不会随 HTTP 请求发送给服务器。也就是说,请求 https://example.com/index.html#/page1 时,服务器主要看到的是 /index.html;#/page1 留在浏览器端处理。这正是 Hash 路由不依赖服务端识别前端路径的原因。
// 地址为 https://example.com/index.html#/page1 时:
console.log(location.pathname); // "/index.html"
console.log(location.hash); // "#/page1"
2.2 锚点实验:先看见 Hash 如何变化
在实现路由器之前,可以先用一个长页面观察 Hash 的原始用途。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hash 锚点实验</title>
</head>
<body>
<!– URL 变成 #top 时,浏览器会定位到这个元素 –>
<div id="top"></div>
<a href="#bottom">回到底部</a>
<div style="height: 200vh; background: yellow;"></div>
<a href="#top">回到顶部</a>
<div style="height: 300vh; background: red;"></div>
<div id="bottom"></div>
<script>
window.addEventListener('hashchange', function (event) {
console.log('Hash 改变了');
console.log('新地址:', event.newURL);
console.log('旧地址:', event.oldURL);
});
</script>
</body>
</html>
点击“回到底部”时,浏览器按以下顺序工作:
旧式 HTML 也允许通过 <a name="top"></a> 定义锚点,它仍能解释早期网页的工作方式,但新代码更推荐直接给目标元素设置 id。另外,如果当前已经是 #bottom,再次点击同一个链接时 Hash 没有变化,通常也就不会产生新的 hashchange。
锚点导航与 Hash 路由使用的是同一种 URL 变化机制,区别只在于变化发生之后做什么:锚点导航让浏览器滚动到元素;Hash 路由则把 #/page1 当成业务路径,查找处理函数并替换 DOM。
3. 手写 HashRouter:逐块拆解核心代码
3.1 页面骨架:路由入口与视图挂载点
先看页面主体:
<header>
<nav>
<ul>
<!– 点击链接只改变 Hash,不重新请求整份 HTML –>
<li><a href="#/page1">页面一</a></li>
<li><a href="#/page2">页面二</a></li>
<li><a href="#/page3">页面三</a></li>
</ul>
</nav>
</header>
<!– 所有“页面”最终都渲染到同一个挂载点 –>
<div id="container"></div>
href="#/page1" 中真正的路由路径是 /page1。在 # 后保留开头的 / 不是浏览器的强制要求,而是一种约定:它让前端路径看起来更像普通 URL 路径,也方便路由表统一管理。
#container 是整个示例的视图挂载点。传统多页应用切换的是整份 HTML,当前 SPA 切换的只是这个元素的子节点。导航、文档结构和 JavaScript 运行环境都继续保留。
脚本放在 body 末尾也有实际意义:浏览器执行 document.getElementById('container') 时,挂载点已经解析完成,所以不必额外等待 DOMContentLoaded。如果脚本被移动到 head 且没有 defer,查询结果就可能是 null。
3.2 路由表:为什么用对象保存回调
核心类从构造函数开始:
class HashRouter {
constructor() {
// 每个路由器实例都有一张独立的路由表
// 键是路径,例如 /page1;值是路径对应的渲染函数
this.routers = {};
}
}
class 是创建同类对象的语法结构;调用 new HashRouter() 时,JavaScript 会创建实例,把实例绑定为构造函数中的 this,再执行 constructor。因此,this.routers 属于当前实例,而不是所有实例共享的全局变量。
路由表的结构可以直观地理解为:
{
'/page1': function () { /* 渲染页面一 */ },
'/page2': function () { /* 渲染页面二 */ },
'/page3': function () { /* 渲染页面三 */ }
}
这里保存的是函数引用,注册时并不会执行。只有当前 Hash 与键匹配时,路由器才取出并调用相应函数。于是,路径识别和具体页面渲染实现被分开:HashRouter 只负责“找谁”,每个回调负责“画什么”。
用普通对象做路由表足以解释原理,但工程代码更适合使用 Map 或 Object.create(null)。普通对象继承自 Object.prototype,某些特殊键名可能与原型属性冲突;Map 则天然表达“路径到处理函数”的映射,并提供明确的 set、get 和 has API。
3.3 监听 Hash:bind 解决的不是语法,而是 this
构造函数中最关键的一段,是注册 hashchange 监听器:
class HashRouter {
constructor() {
this.routers = {};
window.addEventListener(
'hashchange',
// bind 不会立即执行 load,而是返回 this 已固定的新函数
this.load.bind(this)
);
}
}
为什么不能直接写 window.addEventListener('hashchange', this.load)?因为 this.load 只是取出了方法的函数值,等 Hash 改变后,它会作为事件监听器被 window 调用。对于这种普通监听函数,函数内部的 this 通常指向注册事件的当前目标 window,不再是 HashRouter 实例。接下来访问 this.routers,得到的就不是实例中的路由表。
bind 正是为了解决这个调用上下文丢失问题:
const boundLoad = this.load.bind(this);
它会创建一个新函数,并把新函数以后执行时的 this 永久固定为当前路由器实例。需要纠正一个常见误解:经过 this.load.bind(this) 后,load() 内的 this 指向 HashRouter 实例,而不是 window。可以从 load() 中输出实例进行验证。
bind、call 和 apply 都能控制 this,但使用时机不同:
| fn.bind(thisArg) | 否,返回新函数 | 后续参数可预置 | 适合,事件系统需要一个待调用的函数 |
| fn.call(thisArg, a, b) | 是 | 参数逐个传入 | 不适合直接注册,会立刻执行 |
| fn.apply(thisArg, [a, b]) | 是 | 参数以数组传入 | 不适合直接注册,会立刻执行 |
也不能写 this.load(),因为这会在构造阶段马上调用方法,并把它的返回值 undefined 交给 addEventListener。事件系统需要的是函数,而不是函数调用结果。
这个写法还有一个工程细节:每次执行 bind 都会生成新的函数对象。如果以后需要 removeEventListener,就必须保留同一个引用。因此增强版会把绑定结果保存到实例属性中。
3.4 register:注册只建映射,不负责渲染
register 方法非常短,却体现了路由器最重要的抽象:
register(hash, callback) {
// 例如:this.routers['/page1'] = renderPage1
this.routers[hash] = callback;
}
调用 router.register('/page1', callback) 后,路由表中就多了一项 /page1 → callback。此时页面没有变化,因为注册阶段只负责构建配置。把“注册”和“执行”分开后,新增页面无需修改 load() 的判断逻辑,只需继续添加映射即可。
如果采用大量 if…else,代码会随页面数量线性膨胀:
// 不利于扩展的写法
if (hash === '/page1') {
renderPage1();
} else if (hash === '/page2') {
renderPage2();
} else if (hash === '/page3') {
renderPage3();
}
路由表把条件分支转化为一次属性查找,这也是各种路由库都会维护“路由配置表”的根本原因。真实框架还会在此基础上加入动态参数、嵌套路由、懒加载、导航守卫等能力。
3.5 load:读取、规范化、匹配、执行
load() 是路由匹配的核心,可以按四个动作理解:
load() {
// 1. location.hash 会得到 "#/page1"
// slice(1) 去掉开头的 #,得到路由表使用的 "/page1"
const hash = location.hash.slice(1);
let handler;
// 2. 空 Hash 被规范为根路径 /
if (!hash) {
handler = this.routers['/'];
} else {
// 3. 用当前路径查询对应的处理函数
handler = this.routers[hash];
}
// 4. 只有匹配成功才执行,避免调用 undefined 报错
if (handler) {
handler();
}
}
假设当前地址是 http://127.0.0.1:5500/demo2/index.html#/page2,每一步的数据变化如下:
| 读取地址 | location.hash | "#/page2" |
| 去掉井号 | .slice(1) | "/page2" |
| 查询路由表 | this.routers['/page2'] | 页面二的回调函数 |
| 执行处理器 | handler() | #container 显示“页面二” |
slice(1) 表示从索引 1 一直截取到字符串结尾。因为索引 0 正好是 #,所以它比手工替换更直接。如果地址没有 Hash,location.hash 是空字符串,slice(1) 仍然得到空字符串,随后进入根路径分支。
if (handler) 是最小的防御性判断:访问没有注册的 #/unknown 时,属性查找得到 undefined,路由器不会盲目调用它。不过,这也意味着旧视图会继续留在屏幕上,用户不知道导航失败。后文会用通配处理器显示 404。
还有两个细节必须看清:
- 源码虽然尝试查找根路径 /,但并没有注册 / 的回调,所以无 Hash 首次打开时依然没有内容。
- hashchange 只在 Hash 发生变化后触发。直接打开已经带有 #/page2 的地址时,仅仅注册监听器不会自动执行 load(),首屏也可能为空。
这两个问题都需要通过注册根路由并主动执行一次首屏渲染来解决。
3.6 实例化与页面回调:闭包把视图连接起来
最后一块代码负责创建路由器、找到挂载点并注册三个页面:
const router = new HashRouter();
const container = document.getElementById('container');
router.register('/page1', function () {
// 当前内容是固定字符串,因此示例中不存在外部输入注入
container.innerHTML = '<h1>页面一</h1>';
});
router.register('/page2', function () {
container.innerHTML = '<h1>页面二</h1>';
});
router.register('/page3', function () {
container.innerHTML = '<h1>页面三</h1>';
});
三个回调都使用了外层作用域中的 container。即使注册代码执行完毕,回调以后被 handler() 调用时仍能访问这个变量,这就是闭包:函数会记住其创建位置的词法作用域。于是,路由处理器无需把挂载点设成全局变量,也能在未来的事件回调中更新它。
innerHTML 会把字符串解析成 DOM,并替换容器原有的全部子节点。它适合展示最小示例,但要了解它的边界:
- 替换节点会丢失旧子树中的 DOM 状态与事件监听器。
- 把用户输入或接口返回值直接拼进 innerHTML,可能产生跨站脚本攻击(XSS)。
- 页面复杂后,手工拼接字符串不利于组件复用和精确更新。
这里写入的是开发者定义的固定字符串,所以风险可控。处理不可信文本时,应优先使用 textContent;复杂应用则通常交给 Vue、React 等框架的渲染系统管理。
完整代码展示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPA</title>
</head>
<body>
<header>
<nav>
<ul>
<li><a href="#/page1">页面一</a ></li>
<li><a href="#/page2">页面二</a ></li>
<li><a href="#/page3">页面三</a ></li>
</ul>
</nav>
</header>
<div id="container">
</div>
<script>
class HashRouter{
constructor(){
// this 实例
this.routers = {}; // 前后端分离的,前端也要独立的路由
// 传统的多页开发,前端没有独立的路由 前端路由集合
window.addEventListener('hashchange',
// this 指向事件发生的对象 window
// apply call 手动指定this 区别是参数
// bind 返回一个新函数
this.load.bind(this)
)
}
register(hash,callback){
this.routers[hash] = callback;
}
load() {
// 对象的属性或方法
console.log(this);// this -> window
let hash = location.hash.slice(1);// 切掉#
// console.log(hash);
let handler;
if(!hash){
handler = this.routers['/'];
}else{
handler = this.routers[hash];
}
if(handler){
handler();
}
}
}
// 挂载点 只需要动态修改它
// spa 极大的提升了用户体验
// 前端路由
let router=new HashRouter();
let container = document.getElementById('container');
router.register('/page1',function(){
container.innerHTML = '<h1>页面一</h1>';
})
router.register('/page2',function(){
container.innerHTML = '<h1>页面二</h1>';
})
router.register('/page3',function(){
container.innerHTML = '<h1>页面三</h1>';
})
</script>
</body>
</html>
4. 把碎片串起来:一次路由切换的完整执行链
4.1 从点击页面二到视图更新
单独看每个 API 并不复杂,真正重要的是理解它们的协作顺序。假设页面已完成初始化,用户点击“页面二”,完整链路如下:
这条链路可以浓缩成一张阶段表:
| 交互入口 | <a href="#/page2"> | 声明目标 URL 状态 |
| 状态变化 | 浏览器地址栏与历史记录 | 更新 Hash,保留同一文档 |
| 变化通知 | hashchange | 通知 JavaScript 重新匹配 |
| 路径解析 | location.hash.slice(1) | 把 URL 片段转成路由路径 |
| 路由匹配 | this.routers[hash] | 找到路径对应的处理器 |
| 视图渲染 | 路由回调与 DOM API | 更新 #container 内容 |
这也解释了为什么浏览器后退不需要单独写一个“后退按钮处理器”:后退操作改变历史记录中的 Hash,hashchange 监听器仍会接住变化,再根据恢复后的 URL 渲染视图。URL 是状态来源,视图是该状态的结果,这是前端路由比“点击后直接改 DOM”更可靠的地方。
4.2 首次加载为什么是特殊场景
事件驱动代码很容易忽略首屏。用户第一次打开 index.html#/page3 时,页面开始执行脚本之前,URL 中已经是 #/page3。监听器只能捕获未来发生的变化,不能补发过去的事件,因此必须在所有路由注册完成后主动调用一次 load():
// 先注册,后加载;顺序不能反
router.register('/page3', renderPage3);
// 根据页面启动时已经存在的 Hash 完成首屏匹配
router.load();
如果先 load() 再注册,路由表还是空的,同样无法匹配。因此一个完整的路由器通常会提供 start():先安装监听器,再主动渲染当前地址,把“监听未来变化”和“处理当前状态”统一起来。
5. Hash 路由的能力边界与方案选择
5.1 Hash 路由与 History 路由对比
现代 SPA 还常用 History API 实现“干净路径”,例如把 #/page1 写成 /page1。核心 API 是 history.pushState()、history.replaceState() 和 popstate 事件。两种模式解决的是同一个问题,但部署要求不同:
| URL 形式 | example.com/#/page1 | example.com/page1 |
| 核心监听 | hashchange | popstate |
| 主动改地址 | 修改 location.hash 或点击 Hash 链接 | history.pushState() / replaceState() |
| 是否整页刷新 | 路由切换时不会 | 正确使用 API 时不会 |
| 服务端配置 | 通常无需识别 Hash 后的路径 | 必须把前端路径回退到 SPA 入口 HTML |
| 直接刷新子路由 | 服务器仍收到入口文档路径,通常可用 | 配置不当时服务器可能返回 404 |
| URL 美观度 | 带 # | 更接近普通站点路径 |
| 适用场景 | 静态托管、教学、小型后台、部署受限场景 | 对 URL 规范和 SEO 配合要求更高的现代应用 |
History 路由最容易踩的坑发生在“直接刷新”。在应用内部从 / 切到 /page1 时,pushState 只修改浏览器地址,不会请求服务器;但用户刷新 /page1 时,浏览器会真的向服务器请求这个路径。如果服务器没有把它回退到 index.html,就会返回 404。Hash 不会发送给服务器,因此天然绕开了这个部署问题。
还要注意:调用 history.pushState() 本身不会触发 popstate,应用通常要在调用后主动渲染;popstate 主要用于响应浏览器前进和后退。这个设计与 Hash 路由的首屏问题类似——注册事件监听不等于当前状态已经被处理。
5.2 从手写路由器到框架路由器
当前 HashRouter 只实现了静态路径精确匹配,但它已经具备路由系统的骨架:路由表、URL 监听、路径解析、处理器分发和视图渲染。框架路由器是在这个骨架上继续解决复杂业务问题:
- /users/:id 这样的动态参数需要路径模式解析,而不只是字符串完全相等。
- 父布局与子页面需要嵌套路由和多个渲染出口。
- 大型应用需要按路由拆包,通过动态 import() 延迟加载页面代码。
- 登录校验、权限判断和未保存表单提示需要导航守卫。
- 页面切换后还要处理滚动位置、标题、过渡动画和异步竞态。
因此,生产项目通常不必重新发明完整路由库,但手写这个最小实现非常有价值。它揭示了 Vue Router、React Router 等工具背后的稳定模型:URL 是可观察状态,路由表把状态映射为处理逻辑,渲染层再把匹配结果变成界面。掌握这条主线后,再学习框架提供的声明式配置就不会停留在 API 记忆层面。
总结
Hash 最初用于文档内部定位,却因为“改变 URL、保留浏览历史、无需重新请求当前文档”这几个特征,成为实现前端路由的天然工具。一个最小 Hash 路由器只需要完成三步:监听 hashchange,从 location.hash 解析路径,再从路由表取出处理器更新挂载点。代码虽短,背后却串联了事件模型、this 绑定、类实例、函数引用、闭包、DOM 更新和浏览器会话历史等多项 JavaScript 基础。
真正可靠的实现还要照顾事件之外的状态:首次进入页面应主动匹配当前 URL,空路径需要默认页,未知路径需要 404,绑定函数需要保留引用以便解绑,不可信数据不应直接进入 innerHTML。Hash 路由与 History 路由的核心模型相同,主要差异在 URL 形式和服务端回退要求。理解这些底层机制后,无论使用原生 JavaScript 还是框架路由器,都能更准确地判断路由为什么更新、为什么失效,以及应该从 URL、监听、匹配还是渲染哪一层开始排查。
网硕互联帮助中心



评论前必须登录!
注册