Skip to content

SPA 路由原理

单页应用(Single Page Application,SPA)通过 JavaScript 动态切换页面内容,而非每次导航都向服务器请求新页面。SPA 路由是 SPA 的核心机制,主要有两种实现模式:基于 URL 哈希(#)的 Hash 模式和基于 History API 的 History 模式。本节对比两种模式,并通过完整的路由器实现讲解 SPA 路由的工作原理。

前置知识

阅读本节前,建议先了解:popstate 事件

基础概念

什么是 SPA 路由

SPA 路由在浏览器端决定显示哪个页面组件,不向服务器请求完整 HTML:

传统多页应用 (MPA):
浏览器 ── 请求 /about ──→ 服务器返回 about.html(完整页面)
浏览器 ── 请求 /home  ──→ 服务器返回 home.html(完整页面)

单页应用 (SPA):
浏览器 ── 请求 / ──→ 服务器返回 index.html(唯一页面)
前端路由 ── /about ──→ JavaScript 渲染 About 组件
前端路由 ── /home  ──→ JavaScript 渲染 Home 组件

两种路由模式

模式URL 格式原理需要
Hash 模式#/abouthashchange 事件无特殊配置
History 模式/aboutpushState + popstate服务器配置

语法与对比

Hash 模式

javascript
// Hash 模式路由
// URL 示例: https://example.com/#/about

// 监听 hash 变化
window.addEventListener('hashchange', () => {
  const hash = location.hash.slice(1); // 去掉 #
  route(hash);
});

// 编程式导航
location.hash = '#/about';  // 触发 hashchange 事件

// 读取当前 hash
const currentHash = location.hash; // '#/about'

History 模式

javascript
// History 模式路由
// URL 示例: https://example.com/about

// 监听 popstate 事件
window.addEventListener('popstate', () => {
  const path = location.pathname;
  route(path);
});

// 编程式导航
history.pushState(null, '', '/about'); // 不触发 popstate

// 读取当前路径
const currentPath = location.pathname; // '/about'

模式对比

特性Hash 模式History 模式
URL 格式/#/path/path
URL 美观一般(有 #优秀(干净)
SEO不友好(搜索引擎忽略 hash)友好(可被爬取)
服务器配置不需要需要回退路由
兼容性IE8+IE10+
实现复杂度简单较复杂
HTML5 API不需要需要 History API

详细说明

Hash 模式路由实现

javascript
class HashRouter {
  constructor(routes) {
    this.routes = routes;
    this.currentRoute = null;

    window.addEventListener('hashchange', () => this.handleRoute());
    // 初始加载
    window.addEventListener('load', () => this.handleRoute());
  }

  handleRoute() {
    const hash = location.hash.slice(1) || '/';
    const route = this.routes[hash];

    if (route) {
      this.currentRoute = hash;
      route();
    } else {
      // 404 处理
      this.routes['/404']();
    }
  }

  navigate(path) {
    location.hash = '#' + path;
  }
}

// 使用
const router = new HashRouter({
  '/': () => renderHome(),
  '/about': () => renderAbout(),
  '/blog': () => renderBlog(),
  '/404': () => renderNotFound()
});

// 导航
router.navigate('/about');

History 模式路由实现

javascript
class HistoryRouter {
  constructor(routes) {
    this.routes = routes;
    this.currentRoute = null;

    window.addEventListener('popstate', () => this.handleRoute());
  }

  init(initialPath) {
    // 初始化当前路由(replace 而非 push)
    const path = initialPath || location.pathname;
    history.replaceState({ path }, '', path);
    this.handleRoute();
  }

  handleRoute() {
    const path = location.pathname;
    const route = this.routes[path];

    if (route) {
      this.currentRoute = path;
      route();
    } else {
      this.routes['/404']();
    }
  }

  navigate(path, state = {}) {
    if (path === this.currentRoute) return;
    history.pushState({ ...state, path }, '', path);
    this.handleRoute();
  }

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

  back() {
    history.back();
  }

  forward() {
    history.forward();
  }
}

// 使用
const router = new HistoryRouter({
  '/': () => renderHome(),
  '/about': () => renderAbout(),
  '/blog': () => renderBlog(),
  '/blog/:id': () => renderBlogPost(),
  '/404': () => renderNotFound()
});

router.init();

服务器配置

History 模式需要服务器将所有路由请求回退到 index.html

nginx
# Nginx 配置
location / {
  try_files $uri $uri/ /index.html;
}
apache
# 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>
javascript
// Node.js (Express)
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

实战示例

完整的 History 模式 SPA 路由器

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>SPA 路由演示</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, sans-serif; padding: 20px; background: #f5f7fa; }

    .app {
      max-width: 800px; margin: 0 auto;
    }

    .header {
      display: flex; justify-content: space-between; align-items: center;
      padding: 16px 24px; background: #1a73e8; color: white;
      border-radius: 12px 12px 0 0;
    }

    .header h1 { font-size: 20px; }

    .nav {
      display: flex; background: white; border-left: 1px solid #e0e0e0;
      border-right: 1px solid #e0e0e0;
    }

    .nav a {
      padding: 14px 24px; text-decoration: none;
      color: #666; font-weight: 600; font-size: 14px;
      border-bottom: 3px solid transparent;
      transition: all 0.2s;
    }

    .nav a:hover { color: #1a73e8; background: #f8f9fa; }
    .nav a.active { color: #1a73e8; border-bottom-color: #1a73e8; background: #f0f4ff; }

    .content {
      padding: 24px; background: white;
      border: 1px solid #e0e0e0; border-top: none;
      border-radius: 0 0 12px 12px; min-height: 400px;
    }

    .content h2 { font-size: 28px; color: #1a1a2e; margin-bottom: 12px; }
    .content p { color: #555; line-height: 1.8; margin-bottom: 12px; }

    .route-info {
      margin-top: 20px; padding: 16px; background: #f0f4ff;
      border-radius: 8px; font-family: monospace; font-size: 13px;
      border-left: 4px solid #1a73e8;
    }

    .mode-toggle {
      display: flex; gap: 8px; margin-bottom: 20px;
    }

    .mode-btn {
      padding: 8px 16px; border: 2px solid #e0e0e0;
      border-radius: 6px; background: white; cursor: pointer;
      font-size: 13px; font-weight: 600; color: #666;
    }

    .mode-btn.active {
      border-color: #1a73e8; background: #e3f2fd; color: #1a73e8;
    }

    .blog-list { list-style: none; padding: 0; }
    .blog-list li {
      padding: 12px 0; border-bottom: 1px solid #eee;
      cursor: pointer; font-size: 15px;
    }
    .blog-list li:hover { color: #1a73e8; }
  </style>
</head>
<body>
  <div class="app">
    <div class="header">
      <h1>SPA Router</h1>
      <span id="urlDisplay">/home</span>
    </div>

    <div class="nav" id="nav">
      <a href="/home" class="nav-link" data-route="/home">首页</a>
      <a href="/blog" class="nav-link" data-route="/blog">博客</a>
      <a href="/about" class="nav-link" data-route="/about">关于</a>
      <a href="/contact" class="nav-link" data-route="/contact">联系</a>
    </div>

    <div class="content" id="content"></div>
  </div>

  <script>
    // ====== 路由定义 ======
    const routes = {
      '/home': {
        title: '首页',
        render() {
          return `
            <h2>欢迎来到 SPA</h2>
            <p>这是一个使用 History API 实现的单页应用路由。</p>
            <p>特点:</p>
            <p>- 无刷新页面切换</p>
            <p>- 支持浏览器前进/后退</p>
            <p>- 干净的 URL(无 #)</p>
            <p>- 支持 SEO</p>
          `;
        }
      },
      '/blog': {
        title: '博客',
        render() {
          return `
            <h2>博客列表</h2>
            <ul class="blog-list">
              <li onclick="router.navigate('/blog/1')">理解 History API</li>
              <li onclick="router.navigate('/blog/2')">SPA 路由原理</li>
              <li onclick="router.navigate('/blog/3')">Hash vs History 模式</li>
            </ul>
          `;
        }
      },
      '/blog/:id': {
        title: '博客详情',
        render(params) {
          const titles = { '1': '理解 History API', '2': 'SPA 路由原理', '3': 'Hash vs History 模式' };
          return `
            <h2>${titles[params.id] || '未知文章'}</h2>
            <p>这是文章 ${params.id} 的详细内容。</p>
            <p>文章ID通过路由参数获取: params.id = ${params.id}</p>
          `;
        }
      },
      '/about': {
        title: '关于',
        render() {
          return `<h2>关于本站</h2><p>本站演示了 SPA 路由的完整实现。</p>`;
        }
      },
      '/contact': {
        title: '联系',
        render() {
          return `<h2>联系我们</h2><p>邮箱: hello@example.com</p>`;
        }
      },
      '/404': {
        title: '404',
        render() {
          return `<h2>页面不存在</h2><p>您访问的页面不存在。</p>`;
        }
      }
    };

    // ====== 路由器实现 ======
    class Router {
      constructor(routes) {
        this.routes = routes;
        this.currentPath = null;

        // 将路由转换为正则表达式
        this.routeMap = Object.keys(routes).map(path => {
          const keys = [];
          const regex = path.replace(/:(\w+)/g, (_, key) => {
            keys.push(key);
            return '([^/]+)';
          });
          return { path, regex: new RegExp('^' + regex + '$'), keys };
        });

        window.addEventListener('popstate', () => this.resolve());
      }

      init() {
        this.resolve();
      }

      resolve() {
        const path = location.pathname;
        const match = this.matchRoute(path);

        if (match) {
          const { route, params } = match;
          this.currentPath = path;
          document.getElementById('content').innerHTML = route.render(params);
          document.getElementById('urlDisplay').textContent = path;
          document.title = route.title + ' - SPA Router';
          this.updateNavActive(path);
          this.updateRouteInfo(path, params);
        } else {
          routes['/404'].render();
        }
      }

      matchRoute(path) {
        for (const { path: routePath, regex, keys } of this.routeMap) {
          const match = path.match(regex);
          if (match) {
            const params = {};
            keys.forEach((key, i) => { params[key] = match[i + 1]; });
            return { route: this.routes[routePath], params };
          }
        }
        return null;
      }

      navigate(path, replace = false) {
        if (path === this.currentPath) return;
        if (replace) {
          history.replaceState({}, '', path);
        } else {
          history.pushState({}, '', path);
        }
        this.resolve();
      }

      updateNavActive(path) {
        document.querySelectorAll('.nav-link').forEach(link => {
          const route = link.dataset.route.split('/:')[0];
          link.classList.toggle('active', path.startsWith(route));
        });
      }

      updateRouteInfo(path, params) {
        const info = document.querySelector('.route-info');
        if (info) {
          info.textContent = `路径: ${path} | 参数: ${JSON.stringify(params)} | 模式: History`;
        }
      }
    }

    // ====== 初始化 ======
    const router = new Router(routes);

    // 拦截导航链接点击
    document.querySelectorAll('.nav-link').forEach(link => {
      link.addEventListener('click', (e) => {
        e.preventDefault();
        router.navigate(link.getAttribute('href'));
      });
    });

    // 初始化路由
    history.replaceState({}, '', location.pathname || '/home');
    router.init();
  </script>
</body>
</html>

注意事项

  1. Hash 模式不需要服务器配置,适合快速原型开发
  2. History 模式需要服务器回退,所有路由指向 index.html
  3. 路由参数解析:History 模式需要手动实现正则匹配
  4. 404 处理:客户端需要处理未知路由
  5. 滚动恢复:路由切换时可能需要恢复滚动位置

最佳实践

  1. 新项目优先用 History 模式:URL 更美观,支持 SEO
  2. 封装路由器:将路由逻辑独立为可复用模块
  3. 支持路由参数:实现动态路由(如 /blog/:id
  4. 拦截链接点击:阻止默认导航行为
  5. 初始化 replaceState:页面加载时使用 replace 而非 push

下一节

继续学习:Canvas 2D 上下文

参考链接