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

2 阅读10分钟

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

老师先在 readme 里写下了今天的第一段:

## 路由 Route
- 输入url之后,浏览器就是访问的代理
- 通过http协议 向server发起请求
- 处于server伺服状态 给予浏览器响应 text/html
- 浏览器拿响应数据,并且渲染页面
- 向浏览历史插入一条记录

这是最传统的浏览模型。用户在地址栏输入 URL,浏览器充当"用户代理",通过 HTTP 协议向服务器发起请求。服务器收到请求后处于"伺服"状态,返回 text/html 响应。浏览器拿到 HTML 数据,渲染页面,同时向浏览历史中插入一条记录。

这个过程每个 Web 开发者都熟悉,但它有一个隐藏的问题。


二、传统多页面的白屏痛点

readme 接着写道:

## 链接
万物互联靠的就是链接
<a href="https://www.baidu.com"></a>
除了跳转,还有什么?
传统的,每次都得重新渲染整个页面,速度慢,没有必要重新渲染整个页面

老师打开了 demo 目录下的两个文件,让我们直观感受传统多页面的工作方式。

demo/index.html:

<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 给出了答案:

单页应用 Single Page Application
SPA
怎么样把丰富的内容在一个网页里显示
DOM 编程

SPA(Single Page Application,单页应用) 的核心理念:不再每次跳转都去服务器拿一整页 HTML,而是只加载一次页面,后续的内容切换全部通过 JavaScript 操作 DOM 来完成。

readme 用两行伪代码抓住了 SPA 路由的精髓:

访问体验上提升
根据相应的url
/index.html content DOM 放到#container
/about.html content DOM 放到#container

这里出现了两个关键概念。第一个是 #container——页面里的一个"挂载点",它是一个空的 DOM 元素,所有页面内容的切换都发生在它内部。第二个是根据 URL 决定放什么内容——URL 不同,往挂载点里塞的 DOM 也不同。这两种能力合在一起,就是 SPA 路由的基础。


四、关键问题:怎么改 URL 但不发请求?

SPA 需要 URL 变化(因为不同的 URL 对应不同的资源,用户的浏览历史、前进后退都要正常工作),但坚决不能发起新的 HTTP 请求(因为一请求就会整页刷新)。

readme 提出了这个矛盾:

## 单页也有
- 点击链接跳转
- url和资源是一一对应关系
不只是dom编程
怎么改变url
hash 方式可以做到
改变hash url就改变了,就不会重新发送请求,不会跳转

答案就是 hash


五、URL 五段式与 hash 的特殊地位

readme 画出了 URL 的完整结构:

## Hash 路由
http(s)://www.baidu.com/u/123?a=1&b=2#page1
protocol    host      path  queryString  hash
名称作用改它会不会发请求
https://protocol协议
www.baidu.comhost主机(域名)
/u/123path路径
?a=1&b=2queryString查询参数
#page1hash哈希/锚点不会

hash 的独特之处在于:它不会发送给服务器。 浏览器在发起 HTTP 请求时,URL 中 # 以及 # 后面的全部内容都会被"截留",服务器根本不知道 hash 的存在。所以修改 hash 不会触发任何网络请求,页面也不会刷新。

这就是 SPA 路由的突破口。

学到这会想问: hash 除了做路由还能干什么?<a> 标签的 namehref 属性有什么区别?

解答:hash 的原始用途是"锚点定位"——在长页面里标记某个位置,点击后"坐电梯直达"。<a name="top"> 是在页面某处立一个路标(目的地),<a href="#top"> 是从别处跳向那个路标(出发点)。一个埋点,一个跳点,配套工作。SPA 前端路由是对这个机制的创造性借用——浏览器以为你在跳锚点,实际上你在换页面。


六、demo 验证:锚点定位与 hashchange 事件

老师打开了 demo2/demo.html,用代码验证 hash 的两个基础能力。

第一部分:锚点定位(浏览器行为,零 JS)

<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 切入观察)

<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 页面结构

<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

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:注册路由

register(hash, callback) {
  this.routers[hash] = callback
}

register 只做一件事:往路由表里存一条记录。hash 是钥匙(比如 '/page1'),callback 是钥匙对应的门后面的内容。

连续调用三次:

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 变成了:

{
  '/page1': () => container.innerHTML = '页面1',
  '/page2': () => container.innerHTML = '页面2',
  '/page3': () => container.innerHTML = '页面3',
}

每一个 key 对应一个箭头函数。这些箭头函数现在不会执行,只是被存起来,等到对应的 hash 被匹配时才会被调用——这就是 callback(回调函数)的含义:不是现在执行,是"回头需要时再执行"。

7.4 load:查表并执行

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 页面切换流程是这样的:

用户点击 <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 的容器。底层逻辑和今天手写的这几十行代码完全一致