从多页应用到单页应用:前端路由的演进与 Hash 路由原理

20 阅读5分钟

本文从一个最朴素的多页面 Demo 出发,一步步推导出单页应用(SPA)的核心思想,并用原生 JavaScript 手写一个 HashRouter,帮你真正理解前端路由到底在做什么。

一、传统多页面时代:每次跳转都是一次"重生"

先看一段最朴素的 HTML:

<!-- index.html 首页 -->
<header>
    <nav>
        <ul>
            <li><a href="http://127.0.0.1:5501/fe/history/demo/index.html">首页</a></li>
            <li><a href="http://127.0.0.1:fe/history/demo/about.html">关于我们</a></li>
        </ul>
    </nav>
</header>
<main>
    <h1>首页</h1>
</main>

about.html 的结构几乎一模一样,只是 <h1> 里的文字不同。

这就是传统多页面(MPA)开发模式。当用户点击 <a> 标签时,浏览器会经历这样一个完整流程:

  1. 浏览器拿着 URL,通过 HTTP 协议向服务器发起请求
  2. 服务器伺服状态,返回对应的 text/html 响应
  3. 浏览器拿到响应数据,重新渲染整个页面
  4. 浏览记录里插入一条新记录

问题在哪? 每次跳转,整个页面都要从白屏开始重新渲染。在 PC 时代网速尚可忍受,但到了移动时代——页面白一下、闪烁一下——体验就变得难以接受了。

更要命的是:两个页面之间往往 90% 的结构(header、nav、footer)完全一样,只有 <main> 里的内容不同。重新渲染整个页面,纯粹是浪费。

二、核心矛盾:URL 必须变,但页面不能刷新

我们想要的是一种"既能让 URL 变化,又不会触发整页刷新"的机制。

为什么 URL 必须变?因为 URL 是资源和状态的唯一标识。用户需要通过 URL 分享某个页面、使用浏览器前进/后退按钮、刷新后回到正确的位置。如果 URL 不变,这些能力就全丢了。

那有没有 URL 中的一部分,改变了但不会触发请求?

有——hash

三、URL 结构拆解

先复习一下一个完整 URL 的结构:

http(s)://www.baidu.com/u/123?a=1&b=2#/page1
  ──┬──    ────┬───── ─┬─ ───┬─── ──┬───
  protocol    host    path  query  hash
  • protocol:协议(http/https)
  • host:主机名 + 端口
  • path:服务器上的资源路径
  • query:查询参数
  • hash:以 # 开头的片段标识符

关键点:hash 部分的改变,不会触发 HTTP 请求。浏览器不会把 #/page1 发给服务器。

hash 原本的设计目的是锚点链接——在长页面中标记某个位置,点击直达:

<a name="top"></a>
<a href="#bottom">去底部</a>
<div style="height:200vh;background-color:yellow;"></div>
<a href="#top">去顶部</a>
<div style="height:300vh;background-color:green;"></div>
<a name="bottom"></a>

我们可以用一段代码验证 hash 变化时浏览器做了什么:

window.addEventListener('hashchange', function(e) {
    console.log('hash 改变了');
    console.log(e.newURL);  // 变化后的完整 URL
    console.log(e.oldURL);  // 变化前的完整 URL
})

运行后会发现:点击链接时,URL 的 hash 部分变了,hashchange 事件触发了,但页面没有刷新,没有发出任何网络请求。

这就是前端路由的基石。

四、从锚点到路由:SPA 的诞生

既然 hash 变了不刷新页面,还能触发事件,那我们完全可以这样做:

  • #/page1#/page2#/page3 作为不同的"路由"
  • 监听 hashchange 事件
  • 根据当前 hash,动态替换页面某一块区域的内容

页面结构变成这样:

<header>
    <nav>
        <ul>
            <li><a href="#/page1">页面1</a></li>
            <li><a href="#/page2">页面2</a></li>
            <li><a href="#/page3">页面3</a></li>
        </ul>
    </nav>
</header>
<!-- 只需要动态修改这个容器 -->
<div id="container"></div>

整个页面只有一个 HTML 文件,#container 就是唯一的"挂载点"。所有的页面切换,本质上是 DOM 的局部替换

这就是单页应用(SPA, Single Page Application) 的核心思想。

五、手写一个 HashRouter

理解了原理,我们来用原生 JavaScript 实现一个极简的前端路由器:

class HashRouter {
    constructor() {
        // 路由表:hash -> 回调函数
        this.routers = {};
        // 监听 hash 变化,触发加载
        // 注意:事件回调中 this 默认指向 window,需要 bind
        window.addEventListener('hashchange', this.load.bind(this));
    }

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

    // 根据当前 hash 执行对应回调
    load() {
        console.log(this);
        // 实际项目中这里会解析 location.hash 并查找路由表
    }
}

// 使用
let router = new HashRouter();
let container = document.getElementById('container');

router.register('/page1', () => container.innerHTML = '页面一');
router.register('/page2', () => container.innerHTML = '页面二');
router.register('/page3', () => container.innerHTML = '页面三');

这段代码虽然简单,但它已经包含了前端路由的三个核心要素:

要素对应代码作用
路由表this.routers = {}URL 与渲染逻辑的映射关系
监听机制hashchange 事件感知 URL 变化
渲染出口#container内容替换的挂载点

注意 this.load.bind(this) 这一行。addEventListener 的回调函数中,this 默认指向触发事件的元素(这里是 window),而不是 HashRouter 实例。bind 会返回一个绑定了正确 this 的新函数——这是 JavaScript 中处理 this 丢失问题的经典手法。

六、Hash 路由的优缺点

优点

  • 实现简单:纯前端即可完成,不需要服务器配合
  • 兼容性好hashchange 事件在所有浏览器中都支持
  • 不刷新页面:hash 变化只触发事件,不发请求

缺点

  • URL 不够优雅:带个 #,比如 example.com/#/about
  • SEO 不友好:搜索引擎爬虫可能忽略 hash 部分
  • 服务端无法感知路由:所有路由都是前端处理的

这些缺点催生了后来更先进的 History 路由(基于 pushState / popstate API),但那是另一篇文章的话题了。

七、总结

从多页应用到单页应用,核心演进的脉络其实很清晰:

多页面(整页刷新)
    ↓ 痛点:白屏、重复渲染、体验差
Hash 路由(局部替换)
    ↓ 原理:hash 变化不触发请求 + hashchange 事件
SPA(单页应用)
    ↓ 框架封装:React Router / Vue Router
现代前端路由

理解了 hashchange 和"URL 变了但页面不刷新"这个核心机制,再去学 React Router 的 <HashRouter> 或 Vue Router 的 createWebHashHistory,你会发现它们底层做的,就是今天我们手写的这套逻辑——只不过用框架的方式包装得更优雅、功能更完善罢了。

所有复杂的框架,拆到最后都是朴素的原生 API。 这就是学原理的意义。