JS-手写前端路由系列(二):HTML5 History 模式深度进阶

17 阅读3分钟

前言

在上一篇中我们聊了 Hash 路由,虽然它简单好用,但 URL 中那个 # 符号总让人觉得不够“优雅”。为了让单页面应用(SPA)的 URL 看起来和普通网页一模一样,HTML5 引入了 History API。本文将带你深入理解其原理,并手写一个功能完备的 History 路由器。

一、 History 模式 vs Hash 模式

特性Hash 模式History 模式
URL 形态example.com/#/userexample.com/user
SEO较差友好
服务器配置不需要必须配置(否则刷新 404)
底层 APIhashchange 事件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 模式路由器。实现思路如下:

  1. 创建一个路由对象,实现一个register方法,为每个不同路由设置响应的回调函数
  2. 设置一个registerIndex方法实现注册首页回调函数,也是就当路径为'/'
  3. 设置一个registerNotFound方法实现没有匹配对应路由时的回调
  4. 设置一个registerError方法用于处理异常情况
  5. 设置一个assign方法用于跳转对应路由
  6. 设置一个replace方法用于替换当前路径
  7. 设置一个通用处理路由的函数dealPathHandler
  8. 设置一个监听函数listenPopState用于监听浏览器历史记录发生了变化,通过监听popState属性来实现,例如用户点击浏览器的前进、后退、以及调用history.pushState,history.replaceState方法
  9. 阻止a链接的默认事件,获取a链接的href属性,并调用history.pushState方法
  10. 定义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;
}