一、浏览器是怎么访问网页的
老师先在 readme 里写下了今天的第一段:
markdown
## 路由 Route
- 输入url之后,浏览器就是访问的代理
- 通过http协议 向server发起请求
- 处于server伺服状态 给予浏览器响应 text/html
- 浏览器拿响应数据,并且渲染页面
- 向浏览历史插入一条记录
这是最传统的浏览模型。用户在地址栏输入 URL,浏览器充当"用户代理",通过 HTTP 协议向服务器发起请求。服务器收到请求后处于"伺服"状态,返回 text/html 响应。浏览器拿到 HTML 数据,渲染页面,同时向浏览历史中插入一条记录。
这个过程每个 Web 开发者都熟悉,但它有一个隐藏的问题。
二、传统多页面的白屏痛点
readme 接着写道:
ini
## 链接
万物互联靠的就是链接
<a href="https://www.baidu.com"></a>
除了跳转,还有什么?
传统的,每次都得重新渲染整个页面,速度慢,没有必要重新渲染整个页面
老师打开了 demo 目录下的两个文件,让我们直观感受传统多页面的工作方式。
demo/index.html:
xml
<li><a href="http://127.0.0.1:5500/.../index.html">首页</a></li>
<li><a href="http://127.0.0.1:5500/.../about.html">关于我们</a></li>
这两个页面使用完整的服务器 URL 互相跳转。每次点击链接,浏览器就会发起一次全新的 HTTP 请求,服务器返回整个 HTML 页面,浏览器重新渲染所有内容。
问题在哪里? 如果网速慢一点,页面会"白一下"。因为整个页面都在重新加载------导航栏、页脚、布局,这些原本没变的东西也要重新下载、重新解析、重新渲染。readme 写道:
传统的多页面 每次都需要重新渲染 在移动端时代就没有必要了,页面可能会白一下(如果网速慢一点)
从 PC 时代到移动时代,用户的期望变了。App 里切换页面从来不会有"白屏闪烁",Web 能不能也做到?
三、SPA 的思想:一个页面,动态换内容
readme 给出了答案:
vbnet
单页应用 Single Page Application
SPA
怎么样把丰富的内容在一个网页里显示
DOM 编程
SPA(Single Page Application,单页应用) 的核心理念:不再每次跳转都去服务器拿一整页 HTML,而是只加载一次页面,后续的内容切换全部通过 JavaScript 操作 DOM 来完成。
readme 用两行伪代码抓住了 SPA 路由的精髓:
bash
访问体验上提升
根据相应的url
/index.html content DOM 放到#container
/about.html content DOM 放到#container
这里出现了两个关键概念。第一个是 #container ------页面里的一个"挂载点",它是一个空的 DOM 元素,所有页面内容的切换都发生在它内部。第二个是根据 URL 决定放什么内容------URL 不同,往挂载点里塞的 DOM 也不同。这两种能力合在一起,就是 SPA 路由的基础。
四、关键问题:怎么改 URL 但不发请求?
SPA 需要 URL 变化(因为不同的 URL 对应不同的资源,用户的浏览历史、前进后退都要正常工作),但坚决不能发起新的 HTTP 请求(因为一请求就会整页刷新)。
readme 提出了这个矛盾:
bash
## 单页也有
- 点击链接跳转
- url和资源是一一对应关系
不只是dom编程
怎么改变url
hash 方式可以做到
改变hash url就改变了,就不会重新发送请求,不会跳转
答案就是 hash。
五、URL 五段式与 hash 的特殊地位
readme 画出了 URL 的完整结构:
ruby
## Hash 路由
http(s)://www.baidu.com/u/123?a=1&b=2#page1
protocol host path queryString hash
| 段 | 名称 | 作用 | 改它会不会发请求 |
|---|---|---|---|
https:// |
protocol | 协议 | 会 |
www.baidu.com |
host | 主机(域名) | 会 |
/u/123 |
path | 路径 | 会 |
?a=1&b=2 |
queryString | 查询参数 | 会 |
#page1 |
hash | 哈希/锚点 | 不会 |
hash 的独特之处在于:它不会发送给服务器。 浏览器在发起 HTTP 请求时,URL 中 # 以及 # 后面的全部内容都会被"截留",服务器根本不知道 hash 的存在。所以修改 hash 不会触发任何网络请求,页面也不会刷新。
这就是 SPA 路由的突破口。
学到这会想问 : hash 除了做路由还能干什么?
<a>标签的name和href属性有什么区别?解答 :hash 的原始用途是"锚点定位"------在长页面里标记某个位置,点击后"坐电梯直达"。
<a name="top">是在页面某处立一个路标(目的地),<a href="#top">是从别处跳向那个路标(出发点)。一个埋点,一个跳点,配套工作。SPA 前端路由是对这个机制的创造性借用------浏览器以为你在跳锚点,实际上你在换页面。
六、demo 验证:锚点定位与 hashchange 事件
老师打开了 demo2/demo.html,用代码验证 hash 的两个基础能力。
第一部分:锚点定位(浏览器行为,零 JS)
xml
<a name="top"></a> <!-- 页面顶部立一个锚点 -->
<a href="#bottom">去底部</a> <!-- 点击后 hash 变 #bottom,页面滚到底 -->
<div style="height: 200vh;background-color: yellow;"></div>
<div style="height: 300vh;background-color: red;"></div>
<a href="#top">回到顶部</a> <!-- 点击后 hash 变 #top,页面滚回顶 -->
<a name="bottom"></a> <!-- 底部锚点 -->
纯浏览器行为,不需要任何 JavaScript。点击"去底部",URL 尾部加上 #bottom,浏览器自动滚动到 <a name="bottom"> 的位置。全程不发请求,不刷新页面,但浏览历史照常记录,前进后退按钮正常工作。
第二部分:hashchange 事件(JS 切入观察)
xml
<script>
window.addEventListener('hashchange', function (event) {
console.log('hash改变了')
console.log(event.newURL) // 变化后的完整 URL
console.log(event.oldURL) // 变化前的完整 URL
})
</script>
hashchange 是浏览器提供的原生事件。当 URL 中 # 后面的部分发生任何变化,浏览器就会触发这个事件。event.newURL 和 event.oldURL 分别携带变化前后的完整 URL。
这两部分合在一起回答了 SPA 路由的两个基础问题:
- 怎么改 URL 但不刷新?------ 用 hash。
- 怎么知道 URL 变了?------ 监听
hashchange事件。
有了这两个基础能力,手写一个 HashRouter 的条件就齐备了。
七、手写 HashRouter:SPA 路由的三件套
老师打开 demo2/index.html,这是今天最核心的文件。
7.1 页面结构
xml
<header>
<nav>
<ul>
<li><a href="#/page1">首页1</a></li> <!-- hash 链接 -->
<li><a href="#/page2">页面2</a></li>
<li><a href="#/page3">页面3</a></li>
</ul>
</nav>
</header>
<main></main>
<footer></footer>
<div id="container"></div> <!-- 挂载点:空的,JS 来填 -->
header、main、footer 是"页壳",永远不动。<div id="container"> 是挂载点------初始为空,所有页面内容都往这里动态注入。三个 <a> 标签的 href 全部以 # 开头,确保点击只改 hash 不发请求。
7.2 HashRouter 类:constructor
kotlin
class HashRouter {
constructor() {
// this指向实例
this.routers = {} // 路由表,前后端分离,前端也要有独立的路由
window.addEventListener('hashchange',
this.load.bind(this) // bind 会返回一个新函数
)
}
}
constructor 做了两件事。
第一件事:创建路由表 this.routers = {}。传统多页开发中,路由规则在后端(不同的 URL 路径对应不同的服务器文件)。前后端分离后,前端也需要自己的路由表------就是 JavaScript 里的一个普通对象,key 是 hash 路径,value 是对应的处理函数。
第二件事:监听 hashchange 事件 。this.load.bind(this) 这行代码值得仔细拆解。
addEventListener 有一个默认行为:回调函数里的 this 会被设置为触发事件的 DOM 元素(在这里就是 window)。如果不加 bind(this),load 方法中的 this 就会指向 window,而不是 HashRouter 的实例------那就拿不到 this.routers 了。
bind(this) 的作用就是返回一个新函数,这个新函数内部的 this 被永久锁定为 HashRouter 的实例,浏览器再怎么改也改不了。和 call、apply 不同------call 和 apply 是立即执行函数,bind 是返回一个新函数。事件监听需要的是"稍后调用的函数",所以只能用 bind。
学到这会想问 :
bind(this)里的this和this.routers里的this是同一个吗?如果有多个new HashRouter()实例,它们的this.routers会互相干扰吗?this.routers本质上是什么数据结构?解答 :
constructor里所有的this都是同一个东西------正在被new出来的那个实例对象。this.routers是给这个实例挂属性,bind(this)是把load方法锁给同一个实例。每new一次都会创建一个全新的对象,各自拥有独立的routers,就像每个人都有自己的一部手机,通讯录互不串号。this.routers本质上就是一个普通的 JavaScript 对象,以 key-value 方式存储,key 是 hash 路径字符串,value 是回调函数。
7.3 register:注册路由
javascript
register(hash, callback) {
this.routers[hash] = callback
}
register 只做一件事:往路由表里存一条记录。hash 是钥匙(比如 '/page1'),callback 是钥匙对应的门后面的内容。
连续调用三次:
ini
let router = new HashRouter()
let container = document.getElementById('container')
router.register('/page1', () => container.innerHTML = '页面1')
router.register('/page2', () => container.innerHTML = '页面2')
router.register('/page3', () => container.innerHTML = '页面3')
三次之后,this.routers 变成了:
ini
{
'/page1': () => container.innerHTML = '页面1',
'/page2': () => container.innerHTML = '页面2',
'/page3': () => container.innerHTML = '页面3',
}
每一个 key 对应一个箭头函数。这些箭头函数现在不会执行 ,只是被存起来,等到对应的 hash 被匹配时才会被调用------这就是 callback(回调函数)的含义:不是现在执行,是"回头需要时再执行"。
7.4 load:查表并执行
javascript
load() {
console.log(this) // 验证 this 是 HashRouter 实例
let hash = window.location.hash.slice(1) // '#/page1' → '/page1'
let handler
if (!hash) {
// hash 为空(刚打开页面),什么都不做
} else {
handler = this.routers[hash] // 从路由表取出 callback
}
handler.call(this) // 执行 callback
}
load 方法的执行步骤:
- 通过
window.location.hash获取当前 URL 的 hash 部分,得到类似'#/page1'的字符串。 - 用
slice(1)去掉第一个字符#,得到'/page1'------这个值正好是路由表中注册的 key。 - 用这个 key 去
this.routers中查找,取出对应的 callback 函数。 - 通过
handler.call(this)执行它。效果就是container.innerHTML = '页面1',挂载点中出现了新内容。
!hash 的判空处理也很重要:当用户刚打开页面,还没有点击任何链接时,hash 是空字符串,此时去路由表里查不到任何东西。这个判断防止了空值导致的错误。
八、完整流程串联
当所有这些代码组合在一起,一次完整的 SPA 页面切换流程是这样的:
ini
用户点击 <a href="#/page2">页面2</a>
↓
浏览器修改 URL:index.html → index.html#/page2
↓
没有 HTTP 请求发出(因为只有 hash 变了)
↓
浏览器触发 hashchange 事件
↓
load() 被调用
↓ hash = '/page2'
↓ handler = this.routers['/page2'] → 拿到 callback
↓ handler.call(this) → 执行 callback
↓ container.innerHTML = '页面2'
↓
页面上 <div id="container"> 内部出现 "页面2"
↓
导航栏、页脚纹丝不动,只有挂载点内容被替换
全程零刷新、零请求。用户在同一个页面里完成了"页面切换",体验和原生 App 一样流畅。
九、总结:SPA Hash 路由的三件套
今天的学习沿着一条清晰的路线展开:
-
传统浏览模型:URL → HTTP 请求 → 服务器返回 HTML → 整页渲染 → 白屏闪烁
-
SPA 思想:一个页面,动态替换 DOM,不需要反复请求服务器
-
Hash 机制 :URL
#后面的部分不会发给服务器,改 hash 不会刷新页面 -
锚点定位:hash 的原始用途------长页面内"坐电梯直达"
-
hashchange 事件:浏览器提供的原生监听机制,hash 变化时自动触发
-
手写 HashRouter:三件套组合------hash 链接 + 挂载点 + hashchange 监听
this.routers = {}:前端路由表(key-value 对象)register(hash, callback):注册路由规则load():查表 → 取出 callback → 执行 → 替换 DOMbind(this):锁定this指向,确保事件回调中能访问实例属性
掌握了手写的 HashRouter,后面学习 React Router 时就会发现------React 的 <Link> 就是封装了 hash 修改的 <a>,<Route> 就是声明式注册路由的方式,<Routes> 就是匹配 path 并渲染 element 的容器。底层逻辑和今天手写的这几十行代码完全一致