一文讲通 SPA 前端路由:从传统多页到手写 HashRouter

一、浏览器是怎么访问网页的

老师先在 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> 标签的 namehref 属性有什么区别?

解答 :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.newURLevent.oldURL 分别携带变化前后的完整 URL。

这两部分合在一起回答了 SPA 路由的两个基础问题:

  1. 怎么改 URL 但不刷新?------ 用 hash。
  2. 怎么知道 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 来填 -->

headermainfooter 是"页壳",永远不动。<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 的实例,浏览器再怎么改也改不了。和 callapply 不同------callapply 是立即执行函数,bind 是返回一个新函数。事件监听需要的是"稍后调用的函数",所以只能用 bind

学到这会想问bind(this) 里的 thisthis.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 方法的执行步骤:

  1. 通过 window.location.hash 获取当前 URL 的 hash 部分,得到类似 '#/page1' 的字符串。
  2. slice(1) 去掉第一个字符 #,得到 '/page1'------这个值正好是路由表中注册的 key。
  3. 用这个 key 去 this.routers 中查找,取出对应的 callback 函数。
  4. 通过 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 路由的三件套

今天的学习沿着一条清晰的路线展开:

  1. 传统浏览模型:URL → HTTP 请求 → 服务器返回 HTML → 整页渲染 → 白屏闪烁

  2. SPA 思想:一个页面,动态替换 DOM,不需要反复请求服务器

  3. Hash 机制 :URL # 后面的部分不会发给服务器,改 hash 不会刷新页面

  4. 锚点定位:hash 的原始用途------长页面内"坐电梯直达"

  5. hashchange 事件:浏览器提供的原生监听机制,hash 变化时自动触发

  6. 手写 HashRouter:三件套组合------hash 链接 + 挂载点 + hashchange 监听

    • this.routers = {}:前端路由表(key-value 对象)
    • register(hash, callback):注册路由规则
    • load():查表 → 取出 callback → 执行 → 替换 DOM
    • bind(this):锁定 this 指向,确保事件回调中能访问实例属性

掌握了手写的 HashRouter,后面学习 React Router 时就会发现------React 的 <Link> 就是封装了 hash 修改的 <a><Route> 就是声明式注册路由的方式,<Routes> 就是匹配 path 并渲染 element 的容器。底层逻辑和今天手写的这几十行代码完全一致

相关推荐
柒和远方3 小时前
V058:前端路由的第一性原理:从 hashchange 手写路由,到 React Router 的嵌套与懒加载
前端·javascript·react.js
冷咖啡离 我的笨笨3 小时前
通过(Node Js||.Net)基于HTML5的WebSocket实现实时视频文字传输(上)
javascript·.net·html5
张元清3 小时前
桶文件(Barrel Files):index.ts 统一导出如何拖垮 Tree Shaking、Next.js 开发内存和 tsc
javascript·react.js
名字还没想好☜3 小时前
React useImperativeHandle 实战:让父组件安全地调用子组件的方法
前端·javascript·react.js·react·forwardref
铁皮饭盒3 小时前
阿里ASR语音转文字,用JS模拟微信
前端·javascript·后端
柒@宝儿姐4 小时前
若依 B 端从浏览器访问升级为 Windows 安装包交付流程
前端·javascript·vue.js·windows
烬羽14 小时前
React Router v7 完全指南:一个 Demo 吃透前端路由
javascript·react.js·前端框架
烬羽14 小时前
从"整个页面刷新"到"丝滑切换":手写一个 HashRouter 彻底搞懂前端路由
javascript·前端框架·全栈
用户9385156350714 小时前
从零理解 React Router v6:每一个 API 都是怎么工作的
前端·javascript·全栈