前言
本文适合刚接触前端路由、对"页面不刷新切换内容"感到好奇的新手。我们将从浏览器的基本行为出发,一步步推导出 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 API(pushState / 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 编程是手段,流畅体验是结果。