前言
在上一篇中我们聊了 Hash 路由,虽然它简单好用,但 URL 中那个 # 符号总让人觉得不够“优雅”。为了让单页面应用(SPA)的 URL 看起来和普通网页一模一样,HTML5 引入了 History API。本文将带你深入理解其原理,并手写一个功能完备的 History 路由器。
一、 History 模式 vs Hash 模式
| 特性 | Hash 模式 | History 模式 |
|---|---|---|
| URL 形态 | example.com/#/user | example.com/user |
| SEO | 较差 | 友好 |
| 服务器配置 | 不需要 | 必须配置(否则刷新 404) |
| 底层 API | hashchange 事件 | pushState / replaceState |
二、 History 核心 API 详解
window.history 对象保存着用户上网的历史记录。在 HTML5 之前,我们只能做简单的翻页,而现在我们可以手动操纵记录。
1. 基础导航
history.go(n):前进或后退 n 步。history.back():后退一步。history.forward():前进步。
2. 状态操纵(核心)
-
pushState(state, title, url):state历史状态对象、title新历史条目的标题、url新的历史条目url- 作用:向浏览器历史栈中新增一条记录。
- 特点:改变 URL 但不刷新页面。
-
replaceState(state, title, url):- 作用:修改当前的历史记录,不会新增。
-
history.state:获取当前条目关联的状态对象。
三、 避坑指南:popstate 事件的真相
⚠️ 重要修正:
很多开发者认为调用
pushState会触发popstate事件。这是错误的!
popstate只在浏览器回退、前进(点击按钮或调用back/forward/go)时触发。手动调用pushState改变路由时,我们需要自己手动执行渲染逻辑。
四、 实战:手写 MyRouter (History 版)
我们将通过全局监听 <a> 标签和劫持导航行为,实现一个仿 Vue-Router 的 History 模式路由器。实现思路如下:
- 创建一个路由对象,实现一个
register方法,为每个不同路由设置响应的回调函数 - 设置一个
registerIndex方法实现注册首页回调函数,也是就当路径为'/'时 - 设置一个
registerNotFound方法实现没有匹配对应路由时的回调 - 设置一个
registerError方法用于处理异常情况 - 设置一个
assign方法用于跳转对应路由 - 设置一个
replace方法用于替换当前路径 - 设置一个通用处理路由的函数
dealPathHandler - 设置一个监听函数
listenPopState用于监听浏览器历史记录发生了变化,通过监听popState属性来实现,例如用户点击浏览器的前进、后退、以及调用history.pushState,history.replaceState方法 - 阻止a链接的默认事件,获取a链接的href属性,并调用h
istory.pushState方法 - 定义load方法,用于首次进入页面时根据
location.pathname调用对应的回调函数
1. 核心代码实现
<!doctype html>
<head> </head>
<body>
<div id="nav">
<a href="/">首页</a>
<a href="/page1">Page 1</a>
<a href="/page2">Page 2</a>
<a href="/page100">不存在的页面</a>
</div>
<div id="view" style="margin-top: 20px; font-weight: bold;"></div>
</body>
<style>
body {
margin: 0;
padding: 20px;
}
</style>
<script lang="javaScript">
class MyRouter {
constructor() {
this.router = {};
this.init();
}
init() {
this.listenPopState();
this.listenLink();
// 首次加载页面时手动触发一次
window.addEventListener('load', () => this.load());
}
// 监听浏览器的【前进/后退】按钮
listenPopState() {
window.addEventListener('popstate', (e) => {
const path = location.pathname;
this.dealPathHandler(path);
});
}
// 拦截所有 a 标签点击,防止页面刷新
listenLink() {
window.addEventListener('click', (e) => {
const dom = e.target;
if (dom.tagName.toUpperCase() === 'A') {
const path = dom.getAttribute('href');
if (path) {
e.preventDefault(); // 阻止默认跳转刷新
this.assign(path);
}
}
});
}
// 注册路由回调
register(path, callback) { this.router[path] = callback; }
registerIndex(callback) { this.router['/'] = callback; }
registerNotFound(callback) { this.router['404'] = callback; }
// 手动跳转
assign(path) {
// 将状态压入历史栈
history.pushState({ path }, null, path);
// 手动触发视图更新
this.dealPathHandler(path);
}
load() {
this.dealPathHandler(location.pathname);
}
// 通用逻辑:根据路径执行回调
dealPathHandler(path) {
let handler = this.router[path] || this.router['404'] || (() => { });
try {
handler.call(this);
} catch (e) {
console.error('路由执行异常', e);
}
}
}
// --- 应用实例 ---
const router = new MyRouter();
const view = document.getElementById('view');
router.registerIndex(() => view.innerHTML = '🏠 欢迎来到首页');
router.register('/page1', () => view.innerHTML = '📄 这是第一页');
router.register('/page2', () => view.innerHTML = '📄 这是第二页');
router.registerNotFound(() => view.innerHTML = '🚫 404 - 页面没找到');
</script>
五、 生产环境的最后一步:Nginx 配置
History 模式虽然美观,但有一个致命缺点:如果你刷新页面,浏览器会真实地向服务器请求这个路径(例如 example.com/page1)。由于服务器上并没有这个物理文件,会直接返回 404。
解决方案: 在服务器端(如 Nginx)将所有路径都重定向到 index.html,让前端路由来接管。
location / {
try_files $uri $uri/ /index.html;
}