History API 与 SPA 路由

深入理解 History API 的完整功能、SPA 路由实现原理、popstate 与 hashchange 事件的差异,以及 SPA 中实现优雅后退和书签支持的方案。

History API 与 SPA 路由(History API and SPA Routing)

一、History API 的背景

1.1 从 Hash 路由到 History API

在单页应用(SPA)出现之前,前端路由主要依赖 Hash(#)实现。Hash 路由的优点是改变它不会触发页面刷新,缺点是 URL 中带有丑陋的 # 符号,而且搜索引擎对 Hash URL 的索引支持也不理想。

HTML5 引入了 History API(又称 Push State API),允许开发者以编程方式操作浏览器历史记录,同时保持干净的 URL。React Router、Vue Router、Angular Router 等现代路由库都基于此 API 构建。

1.2 History API 概述

History API 提供了以下核心功能:

  • history.pushState():向历史记录栈添加一个新条目
  • history.replaceState():替换当前的历史记录条目
  • history.state:获取当前状态对象
  • popstate 事件:当用户点击后退/前进按钮时触发
// 基本 API
history.pushState(state, title, url);
history.replaceState(state, title, url);
history.back();      // 后退
history.forward();   // 前进
history.go(-1);      // 相对位置跳转

二、pushState 和 replaceState 详解

2.1 pushState 的行为

pushState() 接受三个参数:

history.pushState(state, title, url);
  • state:一个与新历史记录关联的状态对象(可在 popstate 事件中获取)
  • title:新历史记录的标题(大多数浏览器忽略此参数)
  • url:新历史记录的 URL(必须是同源的)
// 示例
const state = { userId: 123, page: 'profile' };
history.pushState(state, 'User Profile', '/user/profile');

// URL 变为 example.com/user/profile
// 但页面不会刷新!

关键特性

  1. 不会触发页面刷新
  2. 新 URL 必须是同源的(出于安全考虑)
  3. 可以添加任意 URL,即使该路径在服务器上不存在
  4. 状态对象存储在浏览器历史记录中,刷新页面后可恢复

2.2 replaceState 的行为

replaceState() 的参数与 pushState() 完全相同,但行为不同:

// pushState:添加新历史记录
history.pushState(null, '', '/page1');
history.pushState(null, '', '/page2');
history.back();  // 返回 /page1

// replaceState:替换当前历史记录
history.pushState(null, '', '/page1');
history.replaceState(null, '', '/page2');  // page1 被 page2 替换
history.back();  // 返回之前的页面(不是 page1!)

使用场景

  • 表单提交后,避免用户返回到表单页面
  • 授权验证失败重定向时

2.3 状态对象的使用

状态对象是存储在历史记录中的 JavaScript 对象:

// 存储复杂状态
const pageState = {
  type: 'article',
  id: 456,
  scrollPosition: 0,
  filters: { category: 'tech', sort: 'date' }
};

history.pushState(pageState, '', '/article/456');

// 用户返回时获取状态
window.addEventListener('popstate', (event) => {
  if (event.state) {
    console.log('恢复状态:', event.state);
    // 恢复滚动位置、筛选条件等
  }
});

三、popstate 事件

3.1 事件触发时机

popstate 事件在用户与浏览器历史记录交互时触发:

// 触发 popstate 的操作:
// 1. 点击浏览器后退按钮
// 2. 点击浏览器前进按钮
// 3. 调用 history.back()
// 4. 调用 history.forward()
// 5. 调用 history.go()

// 不触发 popstate 的操作:
// 1. pushState()
// 2. replaceState()
// 3. 页面正常加载(需要特殊处理)

3.2 页面加载时的处理

页面初始加载时,浏览器不会触发 popstate 事件,但历史记录中已经有一个条目:

// 页面加载完成后的状态
console.log(history.state);  // 可能为 null 或上一个页面传入的状态

// 处理页面刷新
window.addEventListener('load', () => {
  // 如果没有状态,说明是首次访问
  // 如果有状态,说明是状态性导航(如刷新)
});

// 更可靠的方法:在应用初始化时检查当前路径
function initRouter() {
  const path = window.location.pathname;
  const state = history.state;

  if (state && state.path === path) {
    // 从历史状态恢复
    restoreState(state);
  } else {
    // 首次访问或外部链接,直接导航
    navigate(path, { replace: true });
  }
}

3.3 状态恢复的最佳实践

// 将当前导航状态保存到 history.state
function navigate(url, options = {}) {
  const state = {
    path: url,
    timestamp: Date.now(),
    scrollPosition: window.scrollY,
    // 其他需要恢复的状态
  };

  if (options.replace) {
    history.replaceState(state, '', url);
  } else {
    history.pushState(state, '', url);
  }

  renderPage(url);
}

// 在 popstate 中恢复状态
window.addEventListener('popstate', (event) => {
  if (event.state) {
    // 恢复滚动位置
    window.scrollTo(0, event.state.scrollPosition || 0);
    // 渲染页面
    renderPage(event.state.path);
  }
});

// 处理页面刷新
window.addEventListener('beforeunload', (event) => {
  // 将关键状态保存到 sessionStorage
  sessionStorage.setItem('scrollPosition', window.scrollY.toString());
});

四、hashchange 事件

4.1 hashchange 与 popstate 的区别

// hashchange:监听 URL hash(#)的变化
// 触发时机:URL 的 hash 部分改变时
window.addEventListener('hashchange', (event) => {
  console.log('Old URL:', event.oldURL);
  console.log('New URL:', event.newURL);
  console.log('New Hash:', window.location.hash);
});

// popstate:监听历史记录的变化
// 触发时机:点击后退/前进按钮、调用 history.back/forward/go
window.addEventListener('popstate', (event) => {
  console.log('State:', event.state);
});

4.2 选择 hash 还是 history 模式

特性 Hash 路由 History 路由
URL 外观 /page#section /page/section
需要服务器配置 是(所有路由都需返回 index.html)
搜索引擎支持 较差 良好(需要正确配置)
外部链接可直接访问 是(需要服务器支持)
服务器日志 简单 复杂(所有路由都会记录)

4.3 Hash 路由的实现

class HashRouter {
  constructor() {
    this.routes = {};
    window.addEventListener('hashchange', () => this.handleRoute());
    window.addEventListener('load', () => this.handleRoute());
  }

  addRoute(path, handler) {
    this.routes[path] = handler;
  }

  handleRoute() {
    const hash = window.location.hash.slice(1) || '/';
    const handler = this.routes[hash] || this.routes['*'];
    if (handler) handler();
  }

  navigate(path) {
    window.location.hash = path;
  }
}

五、SPA 路由实现

5.1 基础路由类

class Router {
  constructor() {
    this.routes = [];
    window.addEventListener('popstate', () => this.handleRoute());

    // 拦截链接点击
    document.addEventListener('click', (e) => {
      const link = e.target.closest('a[href]');
      if (link && this.shouldIntercept(link)) {
        e.preventDefault();
        this.push(link.getAttribute('href'));
      }
    });
  }

  shouldIntercept(link) {
    // 同源链接才拦截
    return link.origin === window.location.origin &&
           !link.hasAttribute('target') &&
           !link.download;
  }

  addRoute(path, handler) {
    this.routes.push({ path, handler });
    return this;
  }

  push(path, state = {}) {
    history.pushState(state, '', path);
    this.handleRoute();
  }

  replace(path, state = {}) {
    history.replaceState(state, '', path);
    this.handleRoute();
  }

  handleRoute() {
    const path = window.location.pathname;
    const state = history.state;

    // 查找匹配的路由
    const route = this.matchRoute(path);
    if (route) {
      route.handler({ path, params: route.params, state });
    }
  }

  matchRoute(path) {
    for (const route of this.routes) {
      const params = this.extractParams(route.path, path);
      if (params !== null) {
        return { ...route, params };
      }
    }
    return null;
  }

  extractParams(routePath, actualPath) {
    const routeParts = routePath.split('/');
    const actualParts = actualPath.split('/');
    const params = {};

    if (routeParts.length !== actualParts.length) {
      return null;
    }

    for (let i = 0; i < routeParts.length; i++) {
      if (routeParts[i].startsWith(':')) {
        params[routeParts[i].slice(1)] = actualParts[i];
      } else if (routeParts[i] !== actualParts[i]) {
        return null;
      }
    }

    return params;
  }
}

// 使用
const router = new Router();

router.addRoute('/', () => renderHome());
router.addRoute('/user/:id', ({ params }) => renderUser(params.id));
router.addRoute('/article/:slug', ({ params }) => renderArticle(params.slug));

5.2 路由守卫

class RouterWithGuards extends Router {
  constructor() {
    super();
    this.guards = {
      beforeEach: [],
      afterEach: []
    };
  }

  beforeEach(guard) {
    this.guards.beforeEach.push(guard);
    return this;
  }

  afterEach(hook) {
    this.guards.afterEach.push(hook);
    return this;
  }

  async navigate(path, options = {}) {
    const state = options.state || {};

    // 执行守卫
    for (const guard of this.guards.beforeEach) {
      const result = await guard({ path, state });
      if (result === false) {
        return false;  // 导航被取消
      }
      if (result && typeof result === 'string') {
        path = result;  // 守卫可以重定向
      }
    }

    // 执行导航
    if (options.replace) {
      this.replace(path, state);
    } else {
      this.push(path, state);
    }

    // 执行后置钩子
    for (const hook of this.guards.afterEach) {
      hook({ path, state });
    }
  }
}

// 使用守卫
router.beforeEach(async ({ path, state }) => {
  if (path.startsWith('/admin') && !await isAuthenticated()) {
    return '/login';  // 重定向到登录
  }
  if (path === '/login' && await isAuthenticated()) {
    return '/dashboard';  // 已登录用户访问登录页则跳转
  }
});

六、服务器配置

6.1 SPA 的服务器配置原则

SPA 使用 History API 时,所有路由都由 JavaScript 处理。但当用户直接访问某个 URL(如 /user/123)或在浏览器中刷新页面时,服务器会收到该路径的请求。此时服务器必须返回 index.html,而不是 404。

6.2 Nginx 配置

server {
    listen 80;
    server_name example.com;
    root /var/www/app;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

6.3 Apache 配置

# .htaccess
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteBase /
    RewriteRule ^index\.html$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.html [L]
</IfModule>

6.4 Vercel 配置

// vercel.json
{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

七、优雅降级与错误处理

7.1 检测 History API 支持

function hasHistoryAPI() {
  return !!(window.history && window.history.pushState);
}

// 如果不支持,使用 Hash 路由降级
if (!hasHistoryAPI()) {
  // 使用 Hash 路由
}

7.2 处理外部链接

用户可能在另一个标签页打开链接,然后返回。此时需要特殊处理:

// visibilitychange 事件检测用户返回
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    // 用户从其他标签页返回
    checkForUpdates();
  }
});

// pageshow 事件(在 Safari 上比 load 更可靠)
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // 页面从 bfcache 恢复
    initApp();
  }
});

参考资料

延展阅读