从零理解前端路由——Hash 路由原理与实现

3 阅读8分钟

前言

本文适合刚接触前端路由、对"页面不刷新切换内容"感到好奇的新手。我们将从浏览器的基本行为出发,一步步推导出 Hash 路由的完整实现,并逐行解析代码。


一、浏览器是怎么工作的?

1.1 一次完整的页面访问流程

当你在浏览器地址栏输入一个 URL 并回车,背后发生了一系列事情:

用户输入 URL
      │
      ▼
┌──────────────┐
│  浏览器解析 URL │  →  拆出协议、主机名、路径等
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  DNS 解析域名  │  →  www.baidu.com → 服务器 IP 地址
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  发起 HTTP 请求 │  →  向服务器说:"我要访问 /index.html"
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  服务器响应    │  →  返回 text/html(或其它资源)
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  浏览器渲染页面 │  →  解析 HTML → 构建 DOM 树 → 绘制到屏幕
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 插入浏览历史   │  →  navigate 对象记录这条访问
└──────────────┘

关键点:每次访问新 URL,浏览器都会向服务器发起请求,拿到响应后整个页面重新渲染。

1.2 URL 结构解析

https://www.baidu.com/u/123?a=1&b=2#page1
└─┬──┘ └────┬─────┘└─┬─┘└─┬─┘└───────┬──────┘
 协议      主机名     路径   |   查询参数   锚点(hash)
                         参数
部分作用
协议 (protocol)https传输方式,https 是加密的 HTTP
主机名 (host)www.baidu.com域名,指向具体的服务器
路径 (path)/u/123服务器上的资源位置
查询参数 (query)?a=1&b=2传给服务器的额外数据
锚点 (hash)#page1页面内定位,不发给服务器

核心区别:路径和查询参数会发给服务器;hash 只在浏览器本地生效,服务器根本看不到它。


二、传统多页面时代

2.1 万物互联靠链接

互联网的本质是连接,而连接的载体就是 <a> 标签:

<a href="/index.html">首页</a>
<a href="/about.html">关于我们</a>

2.2 每次点击都在"重新开始"

用户在首页(/index.html)
  ↓ 点击 <a href="/about.html">
浏览器白屏
  ↓ 向服务器请求 about.html
服务器返回 HTML
  ↓ 浏览器解析渲染
页面显示(整个过程闪了一下白)

这是一个 URL 与资源一一对应 的模型:

/index.html   →   服务器上的 index.html 文件
/about.html   →   服务器上的 about.html 文件
/contact.html →   服务器上的 contact.html 文件

2.3 传统方式的问题

传统方式(路径变化导致整页刷新):

┌─────────────────────────────┐
│  【完整页面加载1】             │
│  ┌── Header ──┐  ← 加载      │
│  │   logo     │              │
│  ├────────────┤              │
│  │  首页内容   │  ← 加载      │
│  ├────────────┤              │
│  │   Footer   │  ← 加载      │
│  └────────────┘              │
└─────────────────────────────┘
         ↓ 整个页面销毁 → 白屏闪烁
┌─────────────────────────────┐
│  【完整页面加载2】             │
│  ┌── Header ──┐  ← 又加载一遍 │  Header 明明没变!
│  │   logo     │              │
│  ├────────────┤              │
│  │  关于内容   │  ← 加载      │
│  ├────────────┤              │
│  │   Footer   │  ← 又加载一遍 │  Footer 明明没变!
│  └────────────┘              │
└─────────────────────────────┘
  • :Header、Footer 这些公共部分每次都重新请求、重新渲染
  • 白屏:网速慢的时候,页面切换有明显的闪烁
  • 浪费资源:大量重复的 HTML 被反复传输

PC 时代网速快、页面简单,这个问题还不突出。但到了移动端时代,网络不稳定、页面越来越复杂,每次整页刷新的体验已经不能忍了。


三、SPA 单页应用的诞生

3.1 灵感:怎么在一个页面里显示丰富内容?

回想一下我们熟悉的 DOM 编程:

// 点击按钮,局部替换内容——页面不刷新!
button.onclick = () => {
    document.getElementById('container').innerHTML = '<h1>新内容</h1>';
};

那更进一步:能不能 URL 变了,但只用 JS 局部替换内容,不刷新整个页面?

传统:  URL 变 → 服务器返回整个新页面 → 全量替换
SPA:   URL 变 → JS 拦截变化 → 只替换 container 里的内容

3.2 SPA 的核心思路

┌─────────────────────────────┐
│ ┌── Header ──┐  ← 始终不动   │
│ │   logo     │              │
│ ├────────────┤              │
│ │            │              │
│ │ 📍 可变区域 │  ← 只有这里变化 │
│ │            │              │
│ ├────────────┤              │
│ │   Footer   │  ← 始终不动   │
│ └────────────┘              │
└─────────────────────────────┘

只加载一次 HTML,之后所有"页面切换"都是 JS 在局部替换 DOM 内容。

但这里有一个关键问题:怎么改变 URL 而不触发页面刷新?


四、Hash——巧妙利用 URL 中不刷新的那一部分

4.1 Hash 的原始用途:锚点跳转

在很早期的 HTML 规范里,# 被设计用来做页内导航——点击后滚动到页面内的某个位置,不刷新页面:

<a name="top"></a>                           <!-- 在页面顶部插个标记 -->
<a href="#bottom">去到底部</a>                <!-- 点击 → 跳到 #bottom -->
<div style="height: 200vh; background: blue;"></div>
<a href="#top">回到顶部</a>                   <!-- 点击 → 跳回 #top -->
<div style="height: 300vh; background: red;"></div>
<a name="bottom"></a>                        <!-- 在页面底部插个标记 -->

<script>
    window.addEventListener('hashchange', function(event) {
        console.log('hash 改变了');
        console.log('新URL:', event.newURL);
        console.log('旧URL:', event.oldURL);
    });
</script>

4.2 Hash 的关键特性

# 后面的任何变化,浏览器都不会向服务器发起请求,页面不会刷新。

这就是我们要的!既然:

  • URL 改变 ≠ 页面刷新(hash 的特性)
  • URL 改变 → JS 监听 → 替换 DOM → 相当于"换页"

那我们就可以把 hash 的原始用途做一个功能升级

锚点跳转(原始):  #bottom → 浏览器滚动到 <a name="bottom"> 的位置
路由跳转(升级):  #/page1 → 浏览器不动,JS 监听到变化,手动替换 DOM

4.3 Hash 路由的可感知事件

浏览器提供了 hashchange 事件,让我们能精准捕获 hash 的每一次变化:

window.addEventListener('hashchange', function(event) {
    console.log(event.oldURL);  // 变化前的完整 URL
    console.log(event.newURL);  // 变化后的完整 URL
    // 拿到新的 hash 后,就可以决定渲染什么内容了
});

五、手写一个 HashRouter

有了上面的理论基础,我们来从零实现一个前端路由器。

5.1 页面结构

<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 src="router.js"></script>
</body>

三个链接都使用 #/xxx 格式——点击后 URL 改变,但页面不刷新

5.2 HashRouter 类:完整实现

class HashRouter {
    constructor() {
        // ① 路由表:一个空对象,用来存 hash → 回调函数的映射
        this.router = {};

        // ② 监听 hash 变化
        window.addEventListener('hashchange', this.load.bind(this));
    }

    // ③ 注册路由:把 hash 和对应的渲染函数存进路由表
    register(hash, callback) {
        this.router[hash] = callback;
    }

    // ④ 加载路由:当 hash 变化时,查表并执行对应的回调
    load() {
        // 砍掉开头的 #,比如 "#/page1" → "/page1"
        let hash = location.hash.slice(1);

        // 没有 hash 时不处理
        if (!hash) return;

        // 从路由表里找到对应的回调函数
        let handler = this.router[hash];

        // 找到了就执行
        if (handler) handler();
    }
}

5.3 使用方式

// 创建路由器实例
let router = new HashRouter();

// 拿到挂载点
let container = document.getElementById('container');

// 注册三个路由:告诉路由器"URL 和 内容"的对应关系
router.register('/page1', function() {
    container.innerHTML = '<h1>页面一</h1>';
});
router.register('/page2', function() {
    container.innerHTML = '<h1>页面二</h1>';
});
router.register('/page3', function() {
    container.innerHTML = '<h1>页面三</h1>';
});

六、逐行拆解核心机制

6.1 路由表 this.router = {}

// register 执行前:空表
this.router = {};

// 执行 router.register('/page1', callback1) 后
this.router = {
    '/page1': callback1,  // 渲染页面一
};

// 三个都注册完
this.router = {
    '/page1': callback1,  // 渲染页面一
    '/page2': callback2,  // 渲染页面二
    '/page3': callback3,  // 渲染页面三
};

路由表就是一张对照表:左边是 hash 路径,右边是该路径对应的渲染函数。router 的全部工作就是"查表"。

6.2 location.hash.slice(1) 做了什么

// 假设当前 URL 是:http://127.0.0.1:5500/index.html#/page1

location.hash         // → "#/page1"
location.hash.slice(1) // → "/page1"
//              ↑
//      slice(1) 从索引 1 开始截取,砍掉了第 0 个字符 "#"

这样我们得到的 /page1 就和 register 时用的 key 完全一致了。

6.3 bind(this) 为什么至关重要

这是很多新手最困惑的地方。我们先看没有 bind 会怎样:

// 没有 bind
window.addEventListener('hashchange', this.load);

load() {
    console.log(this);       // → Window 对象 ❌
    console.log(this.router); // → undefined(Window 上没有 router!)
    // 后续代码全部报错
}

浏览器会把事件回调的 this 默认指向触发事件的对象——也就是 window。但我们的路由表 this.router 存在 HashRouter 实例上,不在 window 上。

有了 bind

window.addEventListener('hashchange', this.load.bind(this));

load() {
    console.log(this);       // → HashRouter 实例 ✅
    console.log(this.router); // → { '/page1': f, '/page2': f, '/page3': f } ✅
    // 一切正常工作
}

对比三兄弟:

方法作用执行时机
fn.bind(obj)返回一个新函数,this 永久绑定为 obj不立即执行
fn.call(obj, a, b)this 绑定为 obj,参数逐个传入立即执行
fn.apply(obj, [a, b])this 绑定为 obj,参数用数组传入立即执行

这里必须用 bind 而不是 call/apply:因为我们要把函数传给 addEventListener,让它未来在 hash 变化时才调用,而不是现在就执行。


七、完整运行流程

用户点击 <a href="#/page1">
        │
        ▼
URL 变为: index.html#/page1
        │     浏览器 NO 请求服务器!
        │     页面 NO 刷新!
        ▼
hashchange 事件触发
        │
        ▼
调用 load() ──bind──▶ this = HashRouter 实例
        │
        ▼
location.hash = "#/page1"
        │  slice(1)
        ▼
"/page1"
        │  查 this.router
        ▼
找到: function() { container.innerHTML = '<h1>页面一</h1>'; }
        │  执行
        ▼
container 内容更新 → 用户看到"页面一"
        │
        ▼
整个过程:URL 变了 + 页面没刷新 + 内容换了 ✅

八、从 Hash 路由到现代前端路由

Hash 路由是前端路由的起点,但它有个小遗憾——URL 里带着 #,不够优雅。

后来 HTML5 引入了 History APIpushState / replaceState),可以不通过 hash 就能改变 URL 路径,而且同样不刷新页面:

Hash 路由:    http://example.com/#/user/123
History 路由: http://example.com/user/123    ← 更干净

Vue Router、React Router 等现代框架默认使用的就是 History 模式,但原理是相通的:

1. 拦截 URL 变化(hashchange 或 popstate)
2. 从路由表查找匹配项
3. 局部替换 DOM(或替换组件)

理解 Hash 路由,就理解了前端路由的本质。


九、总结:三个关键词

关键词解释
SPA(单页应用)只加载一次 HTML,之后页面切换由 JS 控制,不刷新
Hash(#)URL 中不会触发页面刷新的部分,天然适合做前端路由
Router(路由)一张 hash → 内容的对照表 + 一个监听 hash 变化的调度器

核心代码速览

class HashRouter {
    constructor() {
        this.router = {};                                    // 路由表
        window.addEventListener('hashchange',                // 监听变化
            this.load.bind(this));                           // 绑定 this
    }
    register(hash, callback) {
        this.router[hash] = callback;                        // 注册路由
    }
    load() {
        let hash = location.hash.slice(1);                   // 获取 hash
        let handler = this.router[hash];                     // 查表
        if (handler) handler();                              // 执行渲染
    }
}

前端路由就是:URL 是地址,router 是看门人,DOM 编程是手段,流畅体验是结果。


参考