从页面跳转到前端路由:手写一个简单的 HashRouter

从页面跳转到前端路由:手写一个简单的 HashRouter

当我们在浏览器地址栏输入一个 URL 并访问页面时,浏览器会向目标服务器发起请求,服务器返回 HTML,浏览器再解析并渲染页面。与此同时,这次访问也会成为浏览历史中的一条记录。

如果一个网站有"首页"和"关于我们"两个页面,最直接的实现方式是分别创建两个 HTML 文件,再使用链接完成页面之间的跳转。这种方式简单直观,但每次跳转都会重新加载并渲染整个页面。对于希望获得更连贯体验的应用来说,我们真正想改变的往往只是页面中的一部分内容,而不是把整个页面重新加载一遍。

单页应用(Single Page Application,SPA)正是从这个需求出发:页面的公共结构保持不变,JavaScript 根据当前 URL 动态替换内容区域。要理解这种工作方式,需要先弄清楚传统页面跳转、URL、hash 和 DOM 之间的关系。

一、传统多页面应用如何跳转

下面是一个简化的首页:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>首页</title>
</head>
<body>
  <header>
    <nav>
      <ul>
        <li><a href="./index.html">首页</a></li>
        <li><a href="./about.html">关于我们</a></li>
      </ul>
    </nav>
  </header>

  <main>
    <h1>首页</h1>
  </main>

  <footer></footer>
</body>
</html>

"关于我们"页面拥有相同的页头和页脚,主要区别只是主体内容:

html 复制代码
<main>
  <h1>关于我们</h1>
</main>

点击"关于我们"链接后,浏览器访问 about.html。即使两个页面的公共结构几乎完全一样,浏览器仍然会加载一个新的 HTML 页面并重新渲染。

这个过程体现了一种直接的资源对应关系:

text 复制代码
index.html  → 首页
about.html  → 关于我们

多页面应用的优点是关系清晰,但问题也很明显:只改变主体内容时,公共区域也跟着重新加载。在网络状况不理想时,页面切换还可能出现短暂空白,破坏连续的操作体验。

二、单页应用要解决什么问题

如果不再为每一块内容准备一个完整 HTML 页面,那么怎样在同一个网页里显示不同内容?答案是 DOM 编程。

DOM 可以理解为浏览器根据 HTML 创建的对象结构。JavaScript 可以找到其中的节点,并修改节点的内容。例如,先在页面中准备一个内容容器:

html 复制代码
<div id="container"></div>

再通过 JavaScript 找到它:

js 复制代码
const container = document.getElementById("container");

接下来只要修改这个容器,就可以切换页面的主体内容:

js 复制代码
container.innerHTML = "页面一";

此时,页面的页头、导航等公共结构都没有变化,只有 #container 内部的内容被替换。SPA 的基本思路也就出现了:

text 复制代码
URL 发生变化
      ↓
JavaScript 判断当前 URL
      ↓
选择对应的内容
      ↓
将内容渲染到 #container

不过,这里还有一个关键问题:既然页面没有真正跳转,URL 应该如何跟着内容一起变化?

如果 URL 不变,页面一、页面二和页面三就无法通过地址区分,也无法形成"一个地址对应一份内容"的关系。hash 恰好可以解决这个问题。

三、先认识 URL 的组成

以下面这个地址为例:

text 复制代码
https://www.baidu.com/u/123?a=1&b=2#/page1

它的整体结构可以表示为:

text 复制代码
protocol://host/pathname?queryString#hash

各部分含义如下:

名称 示例内容 说明
protocol https: 协议,包含末尾的 :
hostname www.baidu.com 主机名,不包含端口号
port 空字符串 示例中没有显式端口号
host www.baidu.com 由主机名和端口组成
origin https://www.baidu.com 由协议、主机名和端口组成
pathname /u/123 路径,包含开头的 /
search ?a=1&b=2 查询字符串,包含开头的 ?
queryString a=1&b=2 查询参数部分,通常不包含 ?
hash #/page1 hash 部分,包含开头的 #

在浏览器中,可以通过 location.hash 取得当前地址的 hash。假设地址是:

text 复制代码
http://127.0.0.1/index.html#/page1

那么:

js 复制代码
console.log(location.hash); // "#/page1"

如果路由表中使用 /page1 作为键,就需要移除开头的 #

js 复制代码
const hash = location.hash.slice(1);
console.log(hash); // "/page1"

slice(1) 表示从索引 1 开始截取字符串,因此会跳过索引 0 位置的 #

四、hash 最初可以用来做什么

hash 可以标记长页面中的某个位置。点击锚点链接时,URL 的 hash 会变化,浏览器会移动到对应位置。

html 复制代码
<!-- 顶部锚点 -->
<a name="top"></a>

<a href="#bottom">去到底部</a>

<div style="height: 200vh; background-color: blue;"></div>

<a href="#top">回到顶部</a>

<div style="height: 300vh; background-color: red;"></div>

<!-- 底部锚点 -->
<a name="bottom"></a>

点击"去到底部"后,地址末尾变成 #bottom;点击"回到顶部"后,地址末尾变成 #top。可以把这种体验理解成在长页面中乘坐电梯,直接到达指定位置。

hash 改变时还会触发 hashchange 事件:

js 复制代码
window.addEventListener("hashchange", function (event) {
  console.log("hash 改变了");
  console.log(event.newURL);
  console.log(event.oldURL);
});

事件对象中的两个属性分别表示:

  • event.newURL:hash 改变后的完整 URL;
  • event.oldURL:hash 改变前的完整 URL。

这说明 JavaScript 能够监听 hash 的变化。于是,我们不只可以让 hash 指向长页面中的位置,还可以把不同 hash 当作不同的页面标识:

text 复制代码
#/page1 → 页面一
#/page2 → 页面二
#/page3 → 页面三

当 hash 改变时,页面不必整体重新加载,JavaScript 只需要替换内容容器中的 DOM。这就是一个简单的 Hash 路由。

五、搭建 SPA 的页面骨架

先准备三个导航链接和一个内容容器:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<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>
    // 路由代码写在这里
  </script>
</body>
</html>

三个链接仍然是普通的 <a> 元素,只是 href 不再指向另外一个 HTML 文件,而是分别修改当前页面的 hash。

#container 是内容的挂载点。无论访问哪个路由,都只需要动态修改它:

js 复制代码
const container = document.getElementById("container");

六、设计一个 HashRouter

一个最小的路由需要完成两件事:

  1. 注册路由:记录"路径"和"处理函数"的对应关系;
  2. 加载路由:读取当前 hash,找到并执行相应的处理函数。

可以使用一个类把这些行为组织起来:

js 复制代码
class HashRouter {
  constructor() {
    this.routers = {};

    window.addEventListener(
      "hashchange",
      this.load.bind(this)
    );
  }

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

  load() {
    const hash = location.hash.slice(1);
    const handler = this.routers[hash];

    if (handler) {
      handler.call(this);
    }
  }
}

下面逐段分析这份代码。

1. 使用对象保存路由表

构造函数中的 routers 是前端路由集合:

js 复制代码
this.routers = {};

每注册一条路由,就向这个对象加入一个键值对。例如:

js 复制代码
router.register("/page1", callback);

执行之后,路由表可以理解为:

js 复制代码
{
  "/page1": callback
}

传统多页面开发由不同 HTML 地址对应不同页面;在这个 SPA 中,前端自己维护 hash 和处理函数的对应关系。

2. register:注册路径和回调函数

register 方法接收两个参数:

js 复制代码
register(hash, callback) {
  this.routers[hash] = callback;
}
  • hash 是去掉 # 之后的路由路径,例如 /page1
  • callback 是匹配到该路由时需要执行的函数。

这行代码把路径作为对象的属性名,把回调函数作为属性值。之后只要知道当前路径,就能从 routers 中找到对应函数。

3. 监听 hashchange

路由器需要在 URL 的 hash 改变时重新加载内容:

js 复制代码
window.addEventListener(
  "hashchange",
  this.load.bind(this)
);

这里不能简单写成:

js 复制代码
window.addEventListener("hashchange", this.load);

load 被当作事件处理函数调用时,方法内部的 this 不会自动保持为当前 HashRouter 实例。这样一来,下面的代码就无法通过 this 正确访问实例的路由表:

js 复制代码
this.routers[hash]

bind 会返回一个新函数,并把新函数执行时的 this 固定为传入的对象:

js 复制代码
this.load.bind(this)

这里括号里的 this 是正在创建的路由器实例。因此,未来发生 hashchange、浏览器调用这个新函数时,load 内部的 this 仍然指向该实例。

顺便对比三个常见方法:

  • call:立即调用函数,参数逐个传入;
  • apply:立即调用函数,参数以数组形式传入;
  • bind:不立即调用,而是返回绑定了 this 的新函数,适合交给事件监听器稍后执行。

事件监听需要保存一个未来才执行的函数,所以这里使用 bind

4. load:读取并匹配当前路由

load 首先读取 hash 并去掉 #

js 复制代码
const hash = location.hash.slice(1);

接着用路径查找处理函数:

js 复制代码
const handler = this.routers[hash];

例如,当前地址的 hash 是 #/page2,那么:

text 复制代码
location.hash                 → "#/page2"
location.hash.slice(1)        → "/page2"
this.routers["/page2"]       → 页面二的处理函数

最后,在确实找到函数时才执行:

js 复制代码
if (handler) {
  handler.call(this);
}

这里的判断很重要。页面刚打开时可能没有 hash,用户也可能输入一个没有注册过的 hash。如果直接写成 handler.call(this)handler 就可能是 undefined,调用它会产生错误。

handler.call(this) 会立即执行路由处理函数,并把函数执行时的 this 指向路由器实例。当前示例中的箭头函数并没有使用这个 this,但这行代码也直观体现了 callbind 的区别:bind 为未来的事件准备函数,call 在匹配路由后立即执行函数。

七、注册三个页面

创建路由器实例并取得挂载点:

js 复制代码
const router = new HashRouter();
const container = document.getElementById("container");

然后注册三条路由:

js 复制代码
router.register(
  "/page1",
  () => container.innerHTML = "页面一"
);

router.register(
  "/page2",
  () => container.innerHTML = "页面二"
);

router.register(
  "/page3",
  () => container.innerHTML = "页面三"
);

箭头函数是每条路由的处理函数。以 /page1 为例,当 URL 变成 #/page1 时,路由器找到对应函数并执行:

js 复制代码
() => container.innerHTML = "页面一"

它将字符串"页面一"写入内容容器。点击另外两个链接时,仍然是同一个 HTML 页面,只是 #container 中的内容发生变化。

完整的对应关系如下:

链接 截取后的 hash 执行结果
#/page1 /page1 #container 显示"页面一"
#/page2 /page2 #container 显示"页面二"
#/page3 /page3 #container 显示"页面三"

八、处理首次打开页面

hashchange 只在 hash 发生变化时触发。如果用户直接通过带 hash 的地址打开页面,例如:

text 复制代码
http://127.0.0.1/index.html#/page2

页面加载完成时 hash 已经是 #/page2,但它没有在页面加载后再次变化,因此只监听 hashchange 还不足以完成首次渲染。

注册完全部路由后,主动调用一次 load 即可:

js 复制代码
router.load();

这样有两种情况:

  • 首次打开页面时,主动读取当前 hash 并渲染;
  • 后续点击导航时,由 hashchange 事件触发渲染。

如果地址中没有 hash,当前实现会因为找不到处理函数而保持容器为空。可以在首次加载前提供一个默认 hash:

js 复制代码
if (!location.hash) {
  location.hash = "/page1";
} else {
  router.load();
}

没有 hash 时,将它设置为 /page1。这个修改会触发 hashchange,随后由路由器显示页面一;已经存在 hash 时,则直接调用 load 完成首次渲染。

九、完整实现

将页面结构和路由逻辑合在一起,可以得到一个可运行的简单 SPA:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<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.routers = {};

        // hash 改变后重新匹配路由
        // bind 保证 load 内部的 this 始终是路由器实例
        window.addEventListener(
          "hashchange",
          this.load.bind(this)
        );
      }

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

      load() {
        // location.hash 包含 #,路由表中的路径不包含 #
        const hash = location.hash.slice(1);
        const handler = this.routers[hash];

        // 空 hash 或未注册的 hash 不执行,避免调用 undefined
        if (handler) {
          handler.call(this);
        }
      }
    }

    const router = new HashRouter();
    const container = document.getElementById("container");

    router.register(
      "/page1",
      () => container.innerHTML = "页面一"
    );

    router.register(
      "/page2",
      () => container.innerHTML = "页面二"
    );

    router.register(
      "/page3",
      () => container.innerHTML = "页面三"
    );

    // 提供默认页面,并保证带 hash 直接访问时也能完成渲染
    if (!location.hash) {
      location.hash = "/page1";
    } else {
      router.load();
    }
  </script>
</body>
</html>

从点击"页面二"开始,完整执行过程是:

  1. <a href="#/page2"> 把 URL 的 hash 改为 #/page2
  2. 浏览器触发 hashchange 事件;
  3. 绑定后的 load 方法开始执行;
  4. location.hash.slice(1) 得到 /page2
  5. 路由器从 routers 中找到 /page2 对应的回调;
  6. 回调修改 container.innerHTML,页面显示"页面二"。

整个过程中,浏览器地址发生了变化,页面内容也随之变化,但完整 HTML 页面没有重新跳转和渲染。

十、从多页面到 Hash 路由,真正改变了什么

传统多页面应用和这个简单 SPA 的核心区别,可以归纳为下面几项:

对比项 传统多页面 Hash 路由 SPA
内容组织 每份内容对应一个 HTML 页面 多份内容共用一个 HTML 页面
导航地址 链接到不同 HTML 文件 修改当前 URL 的 hash
页面切换 加载并渲染新页面 JavaScript 替换容器内容
路由关系 地址与服务器资源对应 hash 与前端处理函数对应
公共结构 页面间可能重复 始终保留,只更新挂载点

Hash 路由的核心并不复杂,可以浓缩为三句话:

  1. 使用 hash 表示当前要展示的内容;
  2. 使用 hashchange 监听地址变化;
  3. 根据 hash 找到处理函数,并更新指定的 DOM 容器。

通过这个小例子,我们把 URL、链接、浏览历史、DOM 编程、事件监听和 this 绑定串联了起来。一个简单的 HashRouter 虽然代码不多,却已经具备了前端路由最核心的结构:注册、匹配与渲染。

相关推荐
做前端的娜娜子1 小时前
移动端上拉加载与下拉刷新实现方案
前端·面试·掘金·金石计划
叱咤月海鱼鱼猫1 小时前
iframe 弹窗取消按钮触发父页面弹窗接口
前端
勾勾圈圈蛋蛋1 小时前
黑马Vue_day12(一):getters,怎么看 Apifox 文档,工程化等工具介绍;
前端
参宿71 小时前
像素vs条数级虚拟列表
前端
paopaokaka_luck2 小时前
基于springboot3+vue3的云南本土影视文旅推荐平台(协同过滤算法、Echarts图形化分析)
前端·spring boot·学习·算法·echarts·mybatis
狂师2 小时前
AI 测试提效 | 别搞万能 Skill,推荐用 5 个 Agent Skill 串起 UI 自动化全流程
前端·人工智能·测试
breeze jiang2 小时前
React useRef 实战:从 input 聚焦到 Web Worker 引用
前端·javascript·react.js
90后的晨仔3 小时前
uni-app 生命周期深度解析(iOS / Android / 鸿蒙 / Vue3 四端对照)
前端