前端路由进化史:从刷新白屏到SPA,手写一个Hash路由就懂了!

前端路由进化史:从刷新白屏到SPA,手写一个Hash路由就懂了!

全文导读:本文将带你从浏览器访问一个URL的底层原理开始,一步步剖析传统Web应用"刷新白屏"的痛点,并引出单页应用(SPA)的核心解决方案------前端路由。我们会深入Hash路由的实现细节,并手把手带你实现一个完整的路由类,彻底理解其背后的原理与JavaScript this 指向的经典问题。

1. 一切从URL开始

想象一下,你在浏览器地址栏输入 www.baidu.com 并按下回车,这背后发生了什么?

  1. DNS解析 :将域名 www.baidu.com 解析为服务器的IP地址。
  2. 建立连接:浏览器通过TCP/IP协议与服务器建立连接。
  3. 发送请求 :浏览器向服务器发送一个HTTP请求,说"我要获取根路径 / 的资源"。
  4. 服务器响应 :服务器找到对应的资源(比如 index.html),将其作为HTTP响应的内容(text/html)返回给浏览器。
  5. 浏览器渲染:浏览器拿到HTML代码,开始解析、构建DOM树、加载CSS和JavaScript,最终渲染出一个漂亮的页面给用户看。
  6. 历史记录:最后,这条访问记录会被插入到浏览器的历史记录中。

这一切的核心就是 URL(统一资源定位符)。它像是一个全球通用的"门牌号",客户端(浏览器)通过它来找到并获取服务器上的特定资源。

sequenceDiagram participant U as 用户 participant B as 浏览器 participant S as 服务器 U->>B: 1. 输入网址 www.baidu.com B->>S: 2. 发起HTTP请求 (GET /) S-->>B: 3. 响应HTML文档 (index.html) B->>B: 4. 解析HTML,渲染页面 B->>B: 5. 插入一条历史记录 B-->>U: 6. 展示完整的页面

2. 传统网站的"白屏之痛"

在Web 1.0和早期的Web 2.0时代,我们的网站大多是多页面应用(MPA, Multi-Page Application)。

比如下面这两个简单的页面:

  • 首页 (index.html)
  • 关于我们 (about.html)
html 复制代码
<!-- index.html -->
<nav>
  <!-- target="_blank" 表示在新窗口或新标签页中打开链接 -->
  <a href="/index.html" target="_blank">首页</a>
  <a href="/about.html" target="_blank">关于我们</a>
</nav>
<main><h1>首页</h1></main>
html 复制代码
<!-- about.html -->
<nav>
  <a href="/index.html">首页</a>
  <a href="/about.html">关于我们</a>
</nav>
<main><h1>关于我们</h1></main>

💡 小知识target="_blank" 是HTML <a> 标签的属性,它的作用是让浏览器在新的窗口或新的标签页中打开链接。这样做的好处是用户可以在不关闭当前页面的情况下访问新内容,适合跳转到外部网站或临时查看其他页面。不过在多页面应用中,如果每个页面都这样打开,用户的标签页很快就会堆积成山,反而增加了管理负担。

当我们点击"关于我们"的链接时,浏览器会向服务器请求一个全新的 about.html 文件。这个过程中:

  • 页面会经历"卸载 → 请求 → 解析 → 加载 → 重绘"的完整流程。
  • 屏幕会有一瞬间的空白,也就是我们常说的 "白屏"
  • 虽然现代浏览器和服务器性能已经很强,但一旦页面资源庞大或网络不佳,这种体验的卡顿感会非常明显。

核心痛点:为了更新页面中的一小块内容,我们不得不重新加载整个页面,这是巨大的性能浪费和体验损失。

3. 单页应用(SPA)的"无刷新"革命

为了追求App般的流畅体验,单页应用(SPA, Single-Page Application)的概念应运而生。

SPA的核心思想在第一次加载时就把整个应用所需的HTML、CSS、JavaScript都加载进来,后续的"页面跳转"本质上只是在同一个页面内,用JavaScript动态地替换和渲染内容。

为什么要从App身上找灵感?

不知道大家有没有留意过手机和电脑的使用习惯差异:

  • 电脑屏幕大:我们可以同时打开十几个浏览器标签页,每个标签页对应一个不同的网站,切换起来游刃有余。
  • 手机屏幕小:手机浏览器的标签页管理远不如电脑方便。想象一下,如果你在手机上打开一个网页点一个链接就新开一个标签页,点一个又新开一个,顶部标签栏很快就密密麻麻挤成一团,切换起来手指都要戳冒烟了。所以手机App几乎都是"单页"的体验------在一个界面里完成所有操作,通过底部Tab或侧滑来切换内容,丝滑流畅不卡顿。

SPA正是把这种"原生App般"的体验带到了Web上!

这样做的好处显而易见:

  • 极致流畅:没有页面刷新,没有白屏。
  • 更好的体验:切换迅速,交互反馈更即时。
  • 减轻服务器压力:服务器只需提供数据API,而不用负责页面渲染(前后端分离)。

然而,SPA带来了一个致命的问题:URL不变了!

无论你怎么切换"页面",地址栏始终是 www.example.com/index.html。这会带来:

  1. 刷新页面 :一旦用户手动刷新,浏览器又会请求 index.html,所有的状态都将丢失,回到初始状态。
  2. 分享和书签:无法通过URL直接定位到"关于我们"这个页面。
  3. 浏览器导航:前进、后退按钮失效。

🔍 插曲:思考的轨迹------从DOM编程到Hash的启发

上一节我们说到从多页面到单页面的需求和具体的应用场景,那我们该如何实现SPA呢?我们可以分析得到,SPA面临的核心矛盾是:既要改变URL,又不能触发页面刷新。那这个矛盾到底怎么解决?我们不妨顺着一条自然的思考路径来推演,看看高手们当年是如何一步步找到答案的。这个过程比直接看结果更有价值。

第一步:用DOM编程阻止默认跳转

既然页面刷新是因为点击链接触发了浏览器的默认行为,那我们干脆用JavaScript阻止它

javascript 复制代码
document.querySelector('a').addEventListener('click', function(event) {
  event.preventDefault(); // 阻止默认跳转
  // 然后手动修改页面内容
  document.querySelector('main').innerHTML = '<h1>关于我们</h1>';
});

这样确实做到了不刷新页面就更新内容,体验提升了一大截!

第二步:新的痛点诞生了

但问题也随之而来:地址栏的URL没变,还是 /index.html

这就尴尬了------URL和资源的一一对应关系被打破了!

  • 用户想收藏"关于我们"这个页面,复制下来的链接却是 index.html
  • 用户点击浏览器的"后退"按钮,啥反应都没有,因为URL根本没变过。
  • 刷新页面,又回到首页,刚才的"关于我们"白切换了。

Web世界最基本的约定------一个URL对应一份资源------被我们破坏了。

第三步:有没有办法只改变URL、不刷新页面?

这时候我们开始思考:"能不能让URL变一下,但浏览器别给我刷新?"

你可能会想到:

  • 直接改 window.location.href?❌ 会刷新
  • history.pushState?这个后来才有,当时还没普及
  • 改URL的查询字符串 ?page=about?❌ 也会触发请求

那到底有没有一个部分,改了之后浏览器会"睁一只眼闭一只眼",既不刷新也不发请求?

第四步:Hash闪亮登场!

这时候,我们想起了URL中那个一直存在但常被忽略的部分------Hash(#号及其后面的内容)

bash 复制代码
https://example.com/index.html#/about
                                    ↑
                                 就是它!

Hash的天然特性完美契合了我们的需求:

  1. 修改Hash不会触发页面刷新 ------ 解决了"不刷新"的需求 ✅
  2. Hash变化会触发 hashchange 事件 ------ 我们可以监听到变化 ✅
  3. Hash会保存在浏览器历史记录中 ------ 前进/后退可用 ✅

如此一来,URL变了(#/about),资源也变了(通过JS渲染了"关于我们"),但页面没有刷新!URL和资源的对应关系以一种"前端自给自足"的方式被重新建立起来了。

这就是Hash路由诞生的完整思考轨迹! 它不是一拍脑袋想出来的,而是从"用DOM阻止跳转"这个朴素想法出发,在发现URL不变的痛点后,顺藤摸瓜找到的自然答案。


4. Hash路由:巧妙的"瞒天过海"

有了上面的思考铺垫,我们再来看Hash路由的具体实现,就豁然开朗了。

什么是URL的Hash?

ini 复制代码
https://www.example.com/user/profile?tab=edit#/settings
└─────────────────┬────────────────┘ └────┬────┘ └─┬─┘
                   主机名/路径             查询字符串  哈希 (Hash)
  • Hash就是 # 号及其后面的那一串字符,比如 #/settings
  • 关键特性1:修改Hash不会刷新页面。 当你把 #/settings 改成 #/user 时,浏览器不会发起新的网络请求。
  • 关键特性2:Hash变化会触发 hashchange 事件。 这意味着我们可以通过JavaScript监听到URL的变化。
  • 关键特性3:Hash会被保存在浏览器的历史记录中。 这意味着浏览器的前进、后退按钮可以正常使用。
  • 🔐 关键特性4:Hash不会被发送到服务端。 浏览器发起HTTP请求时,# 号及其后面的部分会被丢弃,服务端根本收不到Hash信息。因此Hash路由完全由前端控制,不需要服务端做任何特殊配置。

基于这些特性,Hash路由成了实现SPA前端路由的第一代标准方案。

1. 锚点链接:Hash的"亲兄弟"

Hash的一个传统用法是锚点链接。在内容丰富的长页面中,我们可以通过点击链接,让页面"跳转"到指定位置。

html 复制代码
<!-- 定义一个锚点 -->
<a name="top"></a>
<!-- ... 很多内容 ... -->
<!-- 点击后,页面会滚动到顶部 -->
<a href="#top">回到顶部</a>

你可能会发现,点击这个链接时,URL变化了,但页面也没有刷新。这正是我们需要的"基因"。只不过,我们不再用它来控制滚动,而是用它来控制整个页面的内容渲染

2. 实现一个简易的 Hash 路由器

我们来动手实现一个最简版的Hash路由器。它的职责很清晰:

  1. 提供一个**注册(register)**方法,让外界将"路径"和"渲染函数"对应起来。
  2. 监听 hashchange 事件,当路径变化时,找到对应的渲染函数并执行。
javascript 复制代码
class HashRouter {
  constructor() {
    // 存储路由规则:{ '/home': () => {...}, '/about': () => {...} }
    // 注意:我们统一使用带斜杠的格式,如 '/about'
    this.routes = {};
    // 监听hash变化
    // 注意:这里为何要使用 bind?我们将在下一节重点讲解!
    window.addEventListener('hashchange', this.load.bind(this));
  }

  // 注册路由
  register(hash, callback) {
    this.routes[hash] = callback;
  }

  // 加载路由对应的页面内容
  load() {
    // 获取当前URL的hash,并去掉开头的 '#'
    // 注意:这里我们统一使用带斜杠的格式,如 '/about'
    const hash = window.location.hash.slice(1) || '/';
    // 从路由表中获取对应的处理函数
    const handler = this.routes[hash];
    if (handler) {
      handler(); // 执行渲染
    } else {
      console.warn(`路由 ${hash} 未定义`);
    }
  }
}

// ---------- 使用 ----------
// 1. 创建路由实例
const router = new HashRouter();

// 2. 获取容器
const container = document.getElementById('container');

// 3. 注册路由(注意:所有路径都以斜杠开头!)
router.register('/', () => {
  container.innerHTML = '<h1>🏠 首页</h1>';
});

router.register('/about', () => {
  container.innerHTML = '<h1>👤 关于我们</h1>';
});

router.register('/products', () => {
  container.innerHTML = '<h1>📦 产品列表</h1>';
});

// 4. 页面加载时,手动执行一次 load,以匹配初始的URL
window.addEventListener('load', router.load.bind(router));

对应的HTML结构:

html 复制代码
<header>
  <nav>
    <ul>
      <!-- 注意:链接地址都改为了 hash,且统一以斜杠开头 -->
      <li><a href="#/">首页</a></li>
      <li><a href="#/about">关于我们</a></li>
      <li><a href="#/products">产品</a></li>
    </ul>
  </nav>
</header>
<!-- 内容将动态渲染到这里 -->
<main id="container"></main>

⚠️ 新手必看:Hash路径中的斜杠不是装饰品!

很多刚接触Hash路由的同学会踩这个坑:#about#/about 是完全不同的两个Hash值!

javascript 复制代码
location.hash = '#about';   // hash 值为 '#about'
location.hash = '#/about';  // hash 值为 '#/about'
// 两者不相等!

如果你在注册路由时写的是 router.register('/about', ...),但HTML中的链接写的是 <a href="#about">,那永远匹配不上!

解决方案:统一规范!

  • 建议全部使用带斜杠的格式 ,如 #/home#/about#/user/profile
  • 这样更符合URL路径的语义,也更美观
  • 在你的代码中保持前后一致:注册时写 '/about',链接时写 #/about

5. 深入理解 this:事件监听中的"指挥官"

HashRouter 的构造函数中,有一个至关重要的细节:

javascript 复制代码
window.addEventListener('hashchange', this.load.bind(this));

如果不使用 .bind(this),而是直接写成 this.load,会发生什么?程序会报错!

这就是JavaScript中一个非常经典的 this 指向问题。

this 在JavaScript中的指向,取决于函数是如何被调用的,而不是在哪里被定义。

  • 默认绑定 :当一个函数被"独立"调用时(例如 fn()),函数内的 this 在非严格模式下指向全局对象 window,在严格模式下为 undefined
  • 隐式绑定 :当一个函数作为对象的方法被调用时(例如 obj.fn()),this 指向这个对象(即 obj)。
  1. 谁来调用 load

    • hashchange 事件触发时,是浏览器(确切地说是 window 对象)来调用 load 函数。调用方式类似于 window.load()
  2. 此时的 this 指向谁?

    • 由于调用者是 window,根据"谁调用,this 指向谁"的口诀,load 函数内部的 this 默认指向 window 对象。
  3. 问题出在哪里?

    • 我们的 load 方法里有一行 const handler = this.routes[hash];
    • 如果 this 指向 window,那么 this.routes 就是 window.routes。但我们的 routes 是挂在 HashRouter 实例上的,window.routes 自然是 undefined。于是,程序就报错了。
  4. .bind(this) 做了什么?

    • bind 是函数的一个原生方法,它会创建一个新的函数 。这个新函数的 this 被永久绑定到了 bind 的第一个参数上。
    • this.load.bind(this) 中,第一个 thisHashRouter 的实例。所以,bind 创建了一个新的 load 函数,并强制指定它的 this 永远指向我们的 HashRouter 实例。
    • 此后,无论这个新函数被谁、以何种方式调用(比如被 window 调用),它的 this 始终是我们指定的那个实例。

用一个比喻来理解:

  • 实例:一个公司(HashRouter 实例)。
  • load 方法:一份公司的工作计划。
  • .bind(this):给这份工作计划盖上一个"XX公司专属"的章。
  • 事件触发:浏览器说"我要看这份工作计划"。
  • 效果 :因为计划上盖了章,浏览器看的时候,就知道这是"XX公司"的工作,里面的所有操作(如 this.routes)都必须且只能使用"XX公司"的资源。

因此,.bind(this) 是我们能正确访问到 this.routes 的关键保障。

6. 总结与展望

Hash路由的优缺点

  • 优点

    • 兼容性极好,支持所有浏览器。
    • 实现简单,无需服务器端做任何特殊配置(因为Hash压根不会发送到服务端)。
  • 缺点

    • URL中带有 # 号,不太美观。
    • SEO(搜索引擎优化)不友好,因为搜索引擎的爬虫通常不会执行JavaScript,无法抓取到 # 后的内容。

未来的路:History API

为了解决Hash路由的这些问题,HTML5引入了 History API 。它提供了 pushStatereplaceState 方法,可以直接修改URL的路径部分(/home, /about),而不会触发页面刷新 ,并且配合 popstate 事件,可以实现更优雅、更"真实"的前端路由。

javascript 复制代码
// 改变URL为 /about,不刷新页面
history.pushState({page: 'about'}, 'about', '/about');

History API 正是如今流行的 Vue Router (history模式)、React Router (BrowserRouter) 等前端路由库的底层基础。它让URL变得和传统网站一样干净,为SPA的SEO优化和更完美的体验铺平了道路。

写在最后

从浏览器原理出发,到MPA的痛点,再到SPA的诞生,最后实现了一个经典的Hash路由并透彻理解了其中的 this 指向问题。这不仅仅是一个路由的实现,更是对Web应用架构发展演进的深刻理解。

希望这篇文章能帮助你打下坚实的基础。理解了Hash路由,再去看更复杂的History API路由,你将会发现它们背后的思想是相通的。


👍 如果这篇文章对你有帮助,欢迎一键三连!

  • 点赞 👍:让更多小伙伴看到这篇内容
  • 收藏 ⭐:方便以后复习回顾
  • 评论 💬:你的每一个问题我都会认真回复

你的支持是我持续输出优质技术文章的最大动力!

下期预告:我们将深入 History API,带你手写一个更完美的 BrowserRouter,彻底告别 # 号,敬请期待!🚀

相关推荐
保加利亚的风2 小时前
Docker 学习文档(Mac + Docker Desktop 版)
前端·后端
java1234_小锋2 小时前
Vue3专题 - 条件渲染
前端·javascript·vue.js
明月_清风2 小时前
🚀 AI Agent 完全入门指南:从 LLM 到生产落地,新手必懂的 34 个核心概念
前端·后端·ai编程
嘟嘟07172 小时前
从零理解 useRef:React 中操作 DOM 与持久化变量的唯一桥梁
javascript
鸽鸽2 小时前
Vue 3 API 完全指南:从 Options 到 Composition 的进阶之路
前端·vue.js
名字还没想好☜2 小时前
React 用 Portal + Context 实现全局 Toast:一次调用、自动消失与队列管理
前端·javascript·react.js·react·next.js
嘟嘟07172 小时前
摘要 手把手实现路由鉴权守卫,逐行拆解 ProtectRoute、localStorage、Navigate state、useLocation、FormDat
javascript
HjhIron2 小时前
React Router 通关指南:从入门到鉴权,一个项目全搞定
前端