从"坐电梯"到"前端路由"------深入理解 Hash 路由原理
一、浏览器是怎么工作的?
在聊路由之前,我们先搞清楚一个最基础的问题:在浏览器里输入一个 URL,到底发生了什么?
你的笔记里把整个过程拆成了 5 个步骤,这个拆解非常精准:
arduino
URL → 浏览器发起请求 → 服务器响应 text/html → 浏览器渲染页面 → 插入一条浏览历史
每一步的底层含义是这样的:
- URL 是浏览器的访问代理 :你在地址栏输入的东西,本质上是一个"资源定位符",告诉浏览器"我要找什么东西"。浏览器自己不存储网页,它只是一个代理,替你去服务器那里拿数据。
- HTTP 协议发起请求:浏览器按照 HTTP 协议的格式,向服务器发送一个请求报文。这个报文里包含了方法(GET/POST)、路径(/index.html)、头信息等。
- 服务器伺服并响应 :服务器收到请求后,根据路径找到对应的资源,然后返回一个 HTTP 响应。响应头里
Content-Type: text/html告诉浏览器:"我返回的是一个 HTML 文档,请用 HTML 的方式解析我。" - 浏览器渲染页面 :浏览器拿到 HTML 之后,开始构建 DOM 树、CSSOM 树,合并成渲染树,然后进行布局(Layout)和绘制(Paint),最终把像素显示在屏幕上。这个过程叫关键渲染路径(Critical Rendering Path)。
- 插入浏览历史 :渲染完成后,浏览器会在当前标签页的
history栈中插入一条记录。navigator对象就是用来访问这些历史记录的。这一步非常关键------没有它,"前进/后退"功能就不存在。
javascript
// navigator 对象承载了浏览历史相关的所有信息
console.log(window.history.length); // 当前标签页的历史记录条数
你笔记里提到的 navigator 对象,实际上浏览历史的核心接口在 window.history 上,它是浏览器提供给 JavaScript 操作历史栈的唯一入口。
二、传统多页面模式:demo 文件夹解读
打开 demo/index.html 和 demo/about.html,你会发现这是最"原始"的网页跳转方式:
xml
<!-- demo/index.html -->
<li><a href="http://127.0.0.1:5500/djz-ai/fe/history/demo/index.html">首页</a></li>
<li><a href="http://127.0.0.1:5500/djz-ai/fe/history/demo/about.html">关于我们</a></li>
这种方式的特点
每个 <a> 标签的 href 指向的是一个完整的、独立的 HTML 文件。当你点击"关于我们"时:
- 浏览器向服务器发起一个全新的 HTTP 请求:
GET /about.html - 服务器返回
about.html的完整内容 - 浏览器销毁当前页面的所有状态(DOM、JS 变量、事件监听器全部清空)
- 重新解析、重新渲染整个页面
- 屏幕会出现短暂的白屏(网速越慢越明显)
你的笔记精准地概括了这个问题:
"传统多页面每次都需要重新渲染,移动端时代有点没必要了,页面会白一下。"
这里涉及一个深层原因:浏览器的导航是"破坏性"的。当一个文档被卸载(unload),它对应的 JavaScript 执行上下文会被完全销毁。这意味着:
- 所有内存中的变量、闭包、定时器 → 消失
- 所有事件监听器 → 解绑
- DOM 树 → 从内存中移除
- CSSOM → 重建
这不是"优化不够好",而是浏览器架构层面的设计选择------新页面的 JS 执行环境必须是干净隔离的,这是安全模型的要求。
三、锚点链接:Hash 的原始使命
在学习 Hash 路由之前,必须先理解 hash 最初是干什么的。打开 demo2/demo.html:
css
<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:red;"></div>
<a name="bottom"></a>
锚点的底层机制
<a name="top"> 在页面中埋下一个命名锚点 (named anchor),href="#bottom" 告诉浏览器:"跳转到当前页面中 name="bottom" 的元素位置"。
当 URL 的 hash 部分改变时------比如 page.html#top → page.html#bottom------浏览器做了两件事:
- 不发起新的网络请求 。因为
#后面的内容根本不会发送到服务器,它是纯客户端的概念。 - 滚动到对应锚点位置 。浏览器在 DOM 树中查找
name="bottom"或id="bottom"的元素,然后调用滚动 API 将视口定位到该元素。
hashchange 事件
demo2/demo.html 里监听了这个事件:
javascript
window.addEventListener('hashchange', function (event) {
console.log('hash改变了');
console.log(event.newURL); // hash 改变后的完整 URL
console.log(event.oldURL); // hash 改变前的完整 URL
});
hashchange 是浏览器原生提供的事件。当 hash 从 #top 变为 #bottom 时:
event.newURL=http://...demo.html#bottomevent.oldURL=http://...demo.html#top
关键洞察:hash 改变时,页面不会重新加载,不会发请求,DOM 状态保持完整,JS 执行上下文不销毁。 这是 hash 能做前端路由的根本原因。
四、SPA 的核心矛盾与解决思路
矛盾在哪?
你的笔记里写得很清楚:
"url 一定要变,不同的 url 对应不同的资源。但 url 变了又要页面不刷新。"
这是一个看似矛盾的诉求:
- 从用户体验角度:URL 必须变(用户要能收藏、分享、前进后退)
- 从性能角度:页面不能刷新(要避免白屏,保持 JS 状态)
传统多页面模式解决了前者(URL 和资源一一对应),但牺牲了后者(每次都要重新加载)。
hash 如何破局?
"改变 hash,url 改变了,不会跳转。"
这句话是整篇文章的核心。我们来看 URL 的结构:
ruby
http(s)://www.baidu.com/u/123?a=1&b=2#page1
协议 主机名 路径 查询参数 hash
#page1 这部分有几个关键特性:
| 特性 | 说明 |
|---|---|
| 不会发送到服务器 | HTTP 请求中不包含 # 之后的内容 |
| 改变时不刷新页面 | 浏览器不会发起导航 |
| 会产生历史记录 | 浏览器的前进/后退按钮可用 |
| 可被 JS 监听 | hashchange 事件能捕获变化 |
这四点组合在一起,刚好满足了 SPA 的全部需求:
- URL 可以变化(不同 hash 对应不同"页面")→ ✅ 可收藏、可分享
- 不会重新加载页面 → ✅ 无白屏、状态保留
- 有历史记录 → ✅ 前进后退正常
- JS 可以监听变化 → ✅ 根据 hash 动态渲染内容
SPA 的本质
"单页应用 Single Page Application,怎么把丰富的内容在一个网页里显示?DOM 编程。"
SPA 的本质是在单个 HTML 文档内,通过 JavaScript 动态替换 DOM 内容来模拟"页面切换" 。前端路由就是这套机制的"指挥官"------它监听 URL 变化,然后决定显示什么内容。URL 是"地址",DOM 是"房间里的家具",前端路由是"搬家具的人"。
五、手写 HashRouter:demo2/index.html 逐行解析
这是文章的重头戏。demo2/index.html 实现了一个最小可用的 Hash 路由器。我们逐段拆解。
5.1 页面结构
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>
这里的关键变化:href 不再是完整的 URL(如 http://...about.html),而是 #/page1。
这意味着点击链接时,浏览器不会发起 HTTP 请求,只会改变 URL 的 hash 部分。#/page1 中的 /page1 其实是一个约定 ------它模仿了服务器路径的写法(/page1),让 URL 看起来像"正经的路径",但本质上它还是 hash 的一部分。
<div id="container"> 是挂载点,SPA 中所有"页面"的内容都渲染到这里。
5.2 HashRouter 类
kotlin
class HashRouter {
constructor() {
this.routers = {}; // 前端路由集合
window.addEventListener('hashchange', this.load.bind(this));
}
this.routers = {}
这是一个对象,用来存储 hash 路径 → 回调函数 的映射关系。比如:
ini
{
'/page1': () => container.innerHTML = '页面一',
'/page2': () => container.innerHTML = '页面二',
'/page3': () => container.innerHTML = '页面三'
}
这个对象就是"路由表"------前端路由的核心数据结构。它回答了"当 URL 是这个 hash 时,我应该渲染什么内容"。
this.load.bind(this) 深度解析
这是整个代码中最能体现 JavaScript 底层机制的一行。我们先看如果不 用 bind 会发生什么:
javascript
window.addEventListener('hashchange', this.load);
// 当 hashchange 触发时:
load() {
console.log(this); // → window(不是 HashRouter 实例!)
let hash = location.hash.slice(1);
let handler = this.routers[hash]; // this.routers 是 undefined!
}
为什么会这样?因为 addEventListener 的回调函数中,this 默认指向事件绑定的 DOM 元素 (这里是 window)。
你代码里的注释写得很到位:
// this 指向事件发生的对象 window
这涉及到 JavaScript 中 this 的绑定规则之一:当函数作为事件处理函数被调用时,this 指向绑定事件的元素。
三种解决方案:
| 方法 | 写法 | 原理 | 是否立即执行 |
|---|---|---|---|
bind |
this.load.bind(this) |
返回一个 this 被永久锁定的新函数 |
❌ 不立即执行 |
call |
--- | 立即调用函数,第一个参数指定 this |
✅ 立即执行 |
apply |
--- | 同 call,但参数以数组形式传递 |
✅ 立即执行 |
这里必须用 bind 而不是 call/apply,因为 addEventListener 的第二个参数需要的是一个函数引用 ,而不是函数的执行结果。call/apply 会立即调用函数,而我们只需要传递函数,等事件触发时才执行。
从更底层的角度看,bind 的实现原理可以简化理解为:
javascript
Function.prototype.myBind = function (context) {
const fn = this; // 保存原函数
return function (...args) {
return fn.apply(context, args); // 用 apply 强制指定 this
};
};
所以 this.load.bind(this) 本质上是创建了一个闭包,在闭包内部用 apply 把 this 强行指定为构造函数的实例对象。
5.3 register 方法
javascript
register(hash, callback) {
this.routers[hash] = callback;
}
这个方法简单但关键。它接收两个参数:
hash:hash 路径(如'/page1')callback:当匹配到这个 hash 时要执行的回调函数
每调用一次 register,就往 this.routers 对象里添加一条记录。这是策略模式的体现------把"做什么"(callback)和"什么时候做"(hash 匹配)分离开。
5.4 load 方法
javascript
load() {
console.log(this); // HashRouter 实例(bind 确保的)
let hash = location.hash.slice(1); // 去掉开头的 #
let handler;
if (!hash) {
// hash 为空时显示默认内容
} else {
handler = this.routers[hash]; // 从路由表中查找
}
handler.call(this); // 执行回调,this 依然指向实例
}
逐行分析:
location.hash.slice(1)
location.hash 获取的是 #/page1(带 # 号),slice(1) 切掉第一个字符得到 /page1。这样就和 register 时存的 key 对上了。
handler = this.routers[hash]
从路由表对象中取值。这是一个 O(1) 的操作,因为 JavaScript 对象的属性查找本质上是哈希表查找。
handler.call(this)
为什么不用 handler() 直接调用?用 call(this) 是为了让回调函数内部的 this 也指向 HashRouter 实例,保持一致性。如果回调里需要访问 this.routers 或实例上的其他属性,这个 this 就能用。
5.5 组装使用
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 = '页面三');
这里的回调函数里直接操作 container.innerHTML,因为 container 在闭包中被捕获了。实际项目中这里通常是渲染组件。
5.6 完整数据流
python
用户点击 #/page1
→ URL hash 变为 #/page1
→ 浏览器触发 hashchange 事件
→ load() 被调用(this 指向 router 实例)
→ location.hash.slice(1) → '/page1'
→ this.routers['/page1'] → 回调函数
→ 回调执行 → container.innerHTML = '页面一'
→ DOM 更新,用户看到新内容
→ 全程无 HTTP 请求,无页面刷新
六、从 demo 到 demo2:一次架构演进
把两个文件夹放在一起对比,能看到一条清晰的演进路线:
| 维度 | demo(传统多页) | demo2(SPA + Hash 路由) |
|---|---|---|
| 跳转方式 | 完整 URL,<a href="http://..."> |
Hash 片段,<a href="#/page1"> |
| 请求次数 | 每次跳转都发 HTTP 请求 | 0 次额外请求 |
| 页面刷新 | 整页刷新,有白屏 | 无刷新,无白屏 |
| JS 状态 | 每次跳转后销毁重建 | 全程保留 |
| 路由控制 | 服务器决定 | JavaScript 控制(前端路由) |
| URL 结构 | /index.html、/about.html |
#/page1、#/page2 |
这个演进不是简单的"技术升级",而是职责转移------路由的职责从服务器端转移到了浏览器端。服务器不再关心"用户当前在哪个页面",它只负责提供数据和初始 HTML,页面之间的导航完全由前端控制。
这也是你笔记里说的:
"前后端分离的,前端也要独立的路由。"
七、总结
回顾整个知识体系的递进关系:
- 浏览器导航的底层机制:URL → 请求 → 响应 → 渲染 → 历史记录
- 传统多页面的痛点:每次跳转 = 销毁一切 + 重新加载 + 白屏
- 锚点链接揭示了 hash 的特性:改变 hash 不刷新页面,但有历史记录,且可被 JS 监听
- SPA 利用这个特性:用 hash 变化模拟"页面切换",用 DOM 操作替换内容
- HashRouter 是最小实现 :路由表(对象映射)+ hashchange 事件 +
bind绑定 this
Hash 路由是前端路由的"第一代方案",之后还有 History API(pushState/popState)实现的更优雅的方案,但理解了 hash 路由,就理解了前端路由最核心的思想:用 JavaScript 接管浏览器的导航行为,在不刷新页面的前提下实现 URL 与视图的同步。# 从"坐电梯"到"前端路由"------深入理解 Hash 路由原理
一、浏览器是怎么工作的?
在聊路由之前,我们先搞清楚一个最基础的问题:在浏览器里输入一个 URL,到底发生了什么?
你的笔记里把整个过程拆成了 5 个步骤,这个拆解非常精准:
arduino
URL → 浏览器发起请求 → 服务器响应 text/html → 浏览器渲染页面 → 插入一条浏览历史
每一步的底层含义是这样的:
- URL 是浏览器的访问代理 :你在地址栏输入的东西,本质上是一个"资源定位符",告诉浏览器"我要找什么东西"。浏览器自己不存储网页,它只是一个代理,替你去服务器那里拿数据。
- HTTP 协议发起请求:浏览器按照 HTTP 协议的格式,向服务器发送一个请求报文。这个报文里包含了方法(GET/POST)、路径(/index.html)、头信息等。
- 服务器伺服并响应 :服务器收到请求后,根据路径找到对应的资源,然后返回一个 HTTP 响应。响应头里
Content-Type: text/html告诉浏览器:"我返回的是一个 HTML 文档,请用 HTML 的方式解析我。" - 浏览器渲染页面 :浏览器拿到 HTML 之后,开始构建 DOM 树、CSSOM 树,合并成渲染树,然后进行布局(Layout)和绘制(Paint),最终把像素显示在屏幕上。这个过程叫关键渲染路径(Critical Rendering Path)。
- 插入浏览历史 :渲染完成后,浏览器会在当前标签页的
history栈中插入一条记录。navigator对象就是用来访问这些历史记录的。这一步非常关键------没有它,"前进/后退"功能就不存在。
javascript
// navigator 对象承载了浏览历史相关的所有信息
console.log(window.history.length); // 当前标签页的历史记录条数
你笔记里提到的 navigator 对象,实际上浏览历史的核心接口在 window.history 上,它是浏览器提供给 JavaScript 操作历史栈的唯一入口。
二、传统多页面模式:demo 文件夹解读
打开 demo/index.html 和 demo/about.html,你会发现这是最"原始"的网页跳转方式:
xml
<!-- demo/index.html -->
<li><a href="http://127.0.0.1:5500/djz-ai/fe/history/demo/index.html">首页</a></li>
<li><a href="http://127.0.0.1:5500/djz-ai/fe/history/demo/about.html">关于我们</a></li>
这种方式的特点
每个 <a> 标签的 href 指向的是一个完整的、独立的 HTML 文件。当你点击"关于我们"时:
- 浏览器向服务器发起一个全新的 HTTP 请求:
GET /about.html - 服务器返回
about.html的完整内容 - 浏览器销毁当前页面的所有状态(DOM、JS 变量、事件监听器全部清空)
- 重新解析、重新渲染整个页面
- 屏幕会出现短暂的白屏(网速越慢越明显)
你的笔记精准地概括了这个问题:
"传统多页面每次都需要重新渲染,移动端时代有点没必要了,页面会白一下。"
这里涉及一个深层原因:浏览器的导航是"破坏性"的。当一个文档被卸载(unload),它对应的 JavaScript 执行上下文会被完全销毁。这意味着:
- 所有内存中的变量、闭包、定时器 → 消失
- 所有事件监听器 → 解绑
- DOM 树 → 从内存中移除
- CSSOM → 重建
这不是"优化不够好",而是浏览器架构层面的设计选择------新页面的 JS 执行环境必须是干净隔离的,这是安全模型的要求。
三、锚点链接:Hash 的原始使命
在学习 Hash 路由之前,必须先理解 hash 最初是干什么的。打开 demo2/demo.html:
css
<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:red;"></div>
<a name="bottom"></a>
锚点的底层机制
<a name="top"> 在页面中埋下一个命名锚点 (named anchor),href="#bottom" 告诉浏览器:"跳转到当前页面中 name="bottom" 的元素位置"。
当 URL 的 hash 部分改变时------比如 page.html#top → page.html#bottom------浏览器做了两件事:
- 不发起新的网络请求 。因为
#后面的内容根本不会发送到服务器,它是纯客户端的概念。 - 滚动到对应锚点位置 。浏览器在 DOM 树中查找
name="bottom"或id="bottom"的元素,然后调用滚动 API 将视口定位到该元素。
hashchange 事件
demo2/demo.html 里监听了这个事件:
javascript
window.addEventListener('hashchange', function (event) {
console.log('hash改变了');
console.log(event.newURL); // hash 改变后的完整 URL
console.log(event.oldURL); // hash 改变前的完整 URL
});
hashchange 是浏览器原生提供的事件。当 hash 从 #top 变为 #bottom 时:
event.newURL=http://...demo.html#bottomevent.oldURL=http://...demo.html#top
关键洞察:hash 改变时,页面不会重新加载,不会发请求,DOM 状态保持完整,JS 执行上下文不销毁。 这是 hash 能做前端路由的根本原因。
四、SPA 的核心矛盾与解决思路
矛盾在哪?
你的笔记里写得很清楚:
"url 一定要变,不同的 url 对应不同的资源。但 url 变了又要页面不刷新。"
这是一个看似矛盾的诉求:
- 从用户体验角度:URL 必须变(用户要能收藏、分享、前进后退)
- 从性能角度:页面不能刷新(要避免白屏,保持 JS 状态)
传统多页面模式解决了前者(URL 和资源一一对应),但牺牲了后者(每次都要重新加载)。
hash 如何破局?
"改变 hash,url 改变了,不会跳转。"
这句话是整篇文章的核心。我们来看 URL 的结构:
ruby
http(s)://www.baidu.com/u/123?a=1&b=2#page1
协议 主机名 路径 查询参数 hash
#page1 这部分有几个关键特性:
| 特性 | 说明 |
|---|---|
| 不会发送到服务器 | HTTP 请求中不包含 # 之后的内容 |
| 改变时不刷新页面 | 浏览器不会发起导航 |
| 会产生历史记录 | 浏览器的前进/后退按钮可用 |
| 可被 JS 监听 | hashchange 事件能捕获变化 |
这四点组合在一起,刚好满足了 SPA 的全部需求:
- URL 可以变化(不同 hash 对应不同"页面")→ ✅ 可收藏、可分享
- 不会重新加载页面 → ✅ 无白屏、状态保留
- 有历史记录 → ✅ 前进后退正常
- JS 可以监听变化 → ✅ 根据 hash 动态渲染内容
SPA 的本质
"单页应用 Single Page Application,怎么把丰富的内容在一个网页里显示?DOM 编程。"
SPA 的本质是在单个 HTML 文档内,通过 JavaScript 动态替换 DOM 内容来模拟"页面切换" 。前端路由就是这套机制的"指挥官"------它监听 URL 变化,然后决定显示什么内容。URL 是"地址",DOM 是"房间里的家具",前端路由是"搬家具的人"。
五、手写 HashRouter:demo2/index.html 逐行解析
这是文章的重头戏。demo2/index.html 实现了一个最小可用的 Hash 路由器。我们逐段拆解。
5.1 页面结构
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>
这里的关键变化:href 不再是完整的 URL(如 http://...about.html),而是 #/page1。
这意味着点击链接时,浏览器不会发起 HTTP 请求,只会改变 URL 的 hash 部分。#/page1 中的 /page1 其实是一个约定 ------它模仿了服务器路径的写法(/page1),让 URL 看起来像"正经的路径",但本质上它还是 hash 的一部分。
<div id="container"> 是挂载点,SPA 中所有"页面"的内容都渲染到这里。
5.2 HashRouter 类
kotlin
class HashRouter {
constructor() {
this.routers = {}; // 前端路由集合
window.addEventListener('hashchange', this.load.bind(this));
}
this.routers = {}
这是一个对象,用来存储 hash 路径 → 回调函数 的映射关系。比如:
ini
{
'/page1': () => container.innerHTML = '页面一',
'/page2': () => container.innerHTML = '页面二',
'/page3': () => container.innerHTML = '页面三'
}
这个对象就是"路由表"------前端路由的核心数据结构。它回答了"当 URL 是这个 hash 时,我应该渲染什么内容"。
this.load.bind(this) 深度解析
这是整个代码中最能体现 JavaScript 底层机制的一行。我们先看如果不 用 bind 会发生什么:
javascript
window.addEventListener('hashchange', this.load);
// 当 hashchange 触发时:
load() {
console.log(this); // → window(不是 HashRouter 实例!)
let hash = location.hash.slice(1);
let handler = this.routers[hash]; // this.routers 是 undefined!
}
为什么会这样?因为 addEventListener 的回调函数中,this 默认指向事件绑定的 DOM 元素 (这里是 window)。
你代码里的注释写得很到位:
// this 指向事件发生的对象 window
这涉及到 JavaScript 中 this 的绑定规则之一:当函数作为事件处理函数被调用时,this 指向绑定事件的元素。
三种解决方案:
| 方法 | 写法 | 原理 | 是否立即执行 |
|---|---|---|---|
bind |
this.load.bind(this) |
返回一个 this 被永久锁定的新函数 |
❌ 不立即执行 |
call |
--- | 立即调用函数,第一个参数指定 this |
✅ 立即执行 |
apply |
--- | 同 call,但参数以数组形式传递 |
✅ 立即执行 |
这里必须用 bind 而不是 call/apply,因为 addEventListener 的第二个参数需要的是一个函数引用 ,而不是函数的执行结果。call/apply 会立即调用函数,而我们只需要传递函数,等事件触发时才执行。
从更底层的角度看,bind 的实现原理可以简化理解为:
javascript
Function.prototype.myBind = function (context) {
const fn = this; // 保存原函数
return function (...args) {
return fn.apply(context, args); // 用 apply 强制指定 this
};
};
所以 this.load.bind(this) 本质上是创建了一个闭包,在闭包内部用 apply 把 this 强行指定为构造函数的实例对象。
5.3 register 方法
javascript
register(hash, callback) {
this.routers[hash] = callback;
}
这个方法简单但关键。它接收两个参数:
hash:hash 路径(如'/page1')callback:当匹配到这个 hash 时要执行的回调函数
每调用一次 register,就往 this.routers 对象里添加一条记录。这是策略模式的体现------把"做什么"(callback)和"什么时候做"(hash 匹配)分离开。
5.4 load 方法
javascript
load() {
console.log(this); // HashRouter 实例(bind 确保的)
let hash = location.hash.slice(1); // 去掉开头的 #
let handler;
if (!hash) {
// hash 为空时显示默认内容
} else {
handler = this.routers[hash]; // 从路由表中查找
}
handler.call(this); // 执行回调,this 依然指向实例
}
逐行分析:
location.hash.slice(1)
location.hash 获取的是 #/page1(带 # 号),slice(1) 切掉第一个字符得到 /page1。这样就和 register 时存的 key 对上了。
handler = this.routers[hash]
从路由表对象中取值。这是一个 O(1) 的操作,因为 JavaScript 对象的属性查找本质上是哈希表查找。
handler.call(this)
为什么不用 handler() 直接调用?用 call(this) 是为了让回调函数内部的 this 也指向 HashRouter 实例,保持一致性。如果回调里需要访问 this.routers 或实例上的其他属性,这个 this 就能用。
5.5 组装使用
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 = '页面三');
这里的回调函数里直接操作 container.innerHTML,因为 container 在闭包中被捕获了。实际项目中这里通常是渲染组件。
5.6 完整数据流
python
用户点击 #/page1
→ URL hash 变为 #/page1
→ 浏览器触发 hashchange 事件
→ load() 被调用(this 指向 router 实例)
→ location.hash.slice(1) → '/page1'
→ this.routers['/page1'] → 回调函数
→ 回调执行 → container.innerHTML = '页面一'
→ DOM 更新,用户看到新内容
→ 全程无 HTTP 请求,无页面刷新
六、从 demo 到 demo2:一次架构演进
把两个文件夹放在一起对比,能看到一条清晰的演进路线:
| 维度 | demo(传统多页) | demo2(SPA + Hash 路由) |
|---|---|---|
| 跳转方式 | 完整 URL,<a href="http://..."> |
Hash 片段,<a href="#/page1"> |
| 请求次数 | 每次跳转都发 HTTP 请求 | 0 次额外请求 |
| 页面刷新 | 整页刷新,有白屏 | 无刷新,无白屏 |
| JS 状态 | 每次跳转后销毁重建 | 全程保留 |
| 路由控制 | 服务器决定 | JavaScript 控制(前端路由) |
| URL 结构 | /index.html、/about.html |
#/page1、#/page2 |
这个演进不是简单的"技术升级",而是职责转移------路由的职责从服务器端转移到了浏览器端。服务器不再关心"用户当前在哪个页面",它只负责提供数据和初始 HTML,页面之间的导航完全由前端控制。
这也是你笔记里说的:
"前后端分离的,前端也要独立的路由。"
七、总结
回顾整个知识体系的递进关系:
- 浏览器导航的底层机制:URL → 请求 → 响应 → 渲染 → 历史记录
- 传统多页面的痛点:每次跳转 = 销毁一切 + 重新加载 + 白屏
- 锚点链接揭示了 hash 的特性:改变 hash 不刷新页面,但有历史记录,且可被 JS 监听
- SPA 利用这个特性:用 hash 变化模拟"页面切换",用 DOM 操作替换内容
- HashRouter 是最小实现 :路由表(对象映射)+ hashchange 事件 +
bind绑定 this
Hash 路由是前端路由的"第一代方案",之后还有 History API(pushState/popState)实现的更优雅的方案,但理解了 hash 路由,就理解了前端路由最核心的思想:用 JavaScript 接管浏览器的导航行为,在不刷新页面的前提下实现 URL 与视图的同步。