手写 hash 路由:100 行实现前端页面切换
本站所有页面切换都是自己写的 hash 路由,不到 100 行。理解它,你就理解了前端路由的核心。
一、hash 是什么
URL 里 # 后面的部分:
https://www.bzii.cn/#/post/domain-and-dns
↑ 这一段就是 hash两个关键特性:
- 改变 hash 不会向服务器发请求(页面不刷新);
- 改变 hash 会触发
hashchange事件,并留下一条历史记录(前进/后退可用)。
这就是它能用来做路由的原因。
二、最小实现
function route() {
var raw = (location.hash || "#/").replace(/^#/, "");
var seg = raw.split("/").filter(s => s.length);
if (!seg.length) return viewHome();
switch (seg[0]) {
case "post": return viewPost(decodeURIComponent(seg[1] || ""));
case "category": return viewCategory(decodeURIComponent(seg[1] || ""));
case "tag": return viewTag(decodeURIComponent(seg[1] || ""));
case "archive": return viewArchive();
case "search": return viewSearch(decodeURIComponent(seg[1] || ""));
default: return viewHome();
}
}
window.addEventListener("hashchange", () => {
route();
window.scrollTo(0, 0);
});
route(); // 首次加载也要渲染跳转就是改 hash:
<a href="#/post/domain-and-dns">域名注册与 DNS 解析</a>location.hash = "#/archive";三、路由表与参数
本站的完整路由:
| 路由 | 视图 |
|---|---|
#/ | 首页 |
#/category/:name | 分类页 |
#/tag/:name | 标签页 |
#/archive | 归档 |
#/search/:kw | 搜索 |
#/post/:id | 文章 |
#/about | 关于 |
中文参数必须编码:
location.hash = "#/category/" + encodeURIComponent("服务器");
// → #/category/%E6%9C%8D%E5%8A%A1%E5%99%A8
decodeURIComponent(seg[1]); // → 服务器四、坑:与标题锚点冲突
文章页的目录链接是 #section-1 这种锚点,但路由也用 #——两者会打架。
本站的解法:约定以 #/ 开头的才是路由,其他一律当锚点处理。
function isRouteHash(h) {
return h === "" || h.indexOf("#/") === 0;
}
window.addEventListener("hashchange", function () {
var h = location.hash;
if (!isRouteHash(h)) {
// 是标题锚点:滚过去,然后把地址栏还原成当前路由
history.replaceState(null, "", currentRouteHash);
scrollToId(decodeURIComponent(h.slice(1)));
return;
}
route();
syncNav();
window.scrollTo(0, 0);
});用 replaceState 还原地址栏很关键:否则用户点目录后刷新页面,会丢失当前文章。
五、平滑滚动
默认的锚点跳转是瞬移,体验生硬。统一劫持做平滑滚动:
document.addEventListener("click", function (e) {
var el = e.target.closest && e.target.closest("a[href^=\"#\"]");
if (!el) return;
var href = el.getAttribute("href");
if (isRouteHash(href)) return; // 路由链接交给 hashchange
var target = document.getElementById(decodeURIComponent(href.slice(1)));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
});顶部有固定导航栏时,锚点会被挡住。用 CSS 解决:
.body h2, .body h3 {
scroll-margin-top: 84px; /* 约等于导航栏高度 + 一点余量 */
}六、导航高亮
function syncNav() {
var h = location.hash || "#/";
document.querySelectorAll(".nav a").forEach(function (a) {
var target = a.getAttribute("href");
var on = target === h ||
(target === "#/tags" && h.indexOf("#/tag") === 0) ||
(target === "#/archive" && h.indexOf("#/archive") === 0);
a.classList.toggle("active", on);
});
}七、hash 路由 vs History API
| 维度 | hash 路由 | History API |
|---|---|---|
| URL 美观 | 带 # | 干净 |
file:// 直接打开 | 支持 | 不支持 |
| 服务端配置 | 无需 | 需回退到 index.html |
| SEO | 差(爬虫对 hash 后内容支持弱) | 好(配合预渲染) |
| 服务端统计 | 拿不到 hash | 能拿到完整路径 |
History API 版本(需要服务器配合):
history.pushState(null, "", "/post/domain-and-dns");
window.addEventListener("popstate", route);location / {
try_files $uri $uri/ /index.html; # 所有路径回退到 index.html
}八、本站为什么选 hash
核心原因只有一个:要能双击 index.html 直接打开。
这是个人笔记站,我需要在没有网络、没有服务器的时候也能翻自己的笔记。file:// 协议下 History API 会直接报错,hash 路由则完全正常。
代价我接受:
- URL 里多个
#,不好看; - 搜索引擎对 hash 内容抓取差(sitemap 里我如实写了 hash 地址)。
如果你做的是需要流量的站点,应该选 History API 路由并配服务端回退。
九、几个细节
// 1. 记录当前路由,供锚点冲突时还原
var currentRouteHash = location.hash || "#/";
currentRouteHash = location.hash; // 在 route() 开头更新
// 2. 搜索框输入时用 replaceState,避免刷屏历史记录
history.replaceState(null, "", "#/search/" + encodeURIComponent(kw));
// 3. 切换路由后回到顶部
window.scrollTo(0, 0);
// 4. 渲染后同步导航高亮
route(); syncNav();前端路由的本质就是:监听 URL 变化 → 解析出参数 → 渲染对应视图 → 更新历史记录。 剩下的都是细节。
