手写 hash 路由:100 行实现前端页面切换

本站所有页面切换都是自己写的 hash 路由,不到 100 行。理解它,你就理解了前端路由的核心。

一、hash 是什么

URL 里 # 后面的部分:

bash
https://www.bzii.cn/#/post/domain-and-dns
                    ↑ 这一段就是 hash

两个关键特性:

  1. 改变 hash 不会向服务器发请求(页面不刷新);
  2. 改变 hash 会触发 hashchange 事件,并留下一条历史记录(前进/后退可用)。

这就是它能用来做路由的原因。

二、最小实现

js
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:

html
<a href="#/post/domain-and-dns">域名注册与 DNS 解析</a>
js
location.hash = "#/archive";

三、路由表与参数

本站的完整路由:

路由视图
#/首页
#/category/:name分类页
#/tag/:name标签页
#/archive归档
#/search/:kw搜索
#/post/:id文章
#/about关于

中文参数必须编码:

js
location.hash = "#/category/" + encodeURIComponent("服务器");
// → #/category/%E6%9C%8D%E5%8A%A1%E5%99%A8

decodeURIComponent(seg[1]);   // → 服务器

四、坑:与标题锚点冲突

文章页的目录链接是 #section-1 这种锚点,但路由也用 #——两者会打架。

本站的解法:约定以 #/ 开头的才是路由,其他一律当锚点处理。

js
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 还原地址栏很关键:否则用户点目录后刷新页面,会丢失当前文章。

五、平滑滚动

默认的锚点跳转是瞬移,体验生硬。统一劫持做平滑滚动:

js
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 解决:

css
.body h2, .body h3 {
  scroll-margin-top: 84px;   /* 约等于导航栏高度 + 一点余量 */
}

六、导航高亮

js
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 版本(需要服务器配合):

js
history.pushState(null, "", "/post/domain-and-dns");
window.addEventListener("popstate", route);
nginx
location / {
  try_files $uri $uri/ /index.html;   # 所有路径回退到 index.html
}

八、本站为什么选 hash

核心原因只有一个:要能双击 index.html 直接打开

这是个人笔记站,我需要在没有网络、没有服务器的时候也能翻自己的笔记。file:// 协议下 History API 会直接报错,hash 路由则完全正常。

代价我接受:

  • URL 里多个 #,不好看;
  • 搜索引擎对 hash 内容抓取差(sitemap 里我如实写了 hash 地址)。

如果你做的是需要流量的站点,应该选 History API 路由并配服务端回退。

九、几个细节

js
// 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 变化 → 解析出参数 → 渲染对应视图 → 更新历史记录。 剩下的都是细节。