从页面跳转到前端路由:手写一个简单的 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
一个最小的路由需要完成两件事:
- 注册路由:记录"路径"和"处理函数"的对应关系;
- 加载路由:读取当前 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,但这行代码也直观体现了 call 和 bind 的区别: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>
从点击"页面二"开始,完整执行过程是:
<a href="#/page2">把 URL 的 hash 改为#/page2;- 浏览器触发
hashchange事件; - 绑定后的
load方法开始执行; location.hash.slice(1)得到/page2;- 路由器从
routers中找到/page2对应的回调; - 回调修改
container.innerHTML,页面显示"页面二"。
整个过程中,浏览器地址发生了变化,页面内容也随之变化,但完整 HTML 页面没有重新跳转和渲染。
十、从多页面到 Hash 路由,真正改变了什么
传统多页面应用和这个简单 SPA 的核心区别,可以归纳为下面几项:
| 对比项 | 传统多页面 | Hash 路由 SPA |
|---|---|---|
| 内容组织 | 每份内容对应一个 HTML 页面 | 多份内容共用一个 HTML 页面 |
| 导航地址 | 链接到不同 HTML 文件 | 修改当前 URL 的 hash |
| 页面切换 | 加载并渲染新页面 | JavaScript 替换容器内容 |
| 路由关系 | 地址与服务器资源对应 | hash 与前端处理函数对应 |
| 公共结构 | 页面间可能重复 | 始终保留,只更新挂载点 |
Hash 路由的核心并不复杂,可以浓缩为三句话:
- 使用 hash 表示当前要展示的内容;
- 使用
hashchange监听地址变化; - 根据 hash 找到处理函数,并更新指定的 DOM 容器。
通过这个小例子,我们把 URL、链接、浏览历史、DOM 编程、事件监听和 this 绑定串联了起来。一个简单的 HashRouter 虽然代码不多,却已经具备了前端路由最核心的结构:注册、匹配与渲染。