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 模式 | #/about | hashchange 事件 | 无特殊配置 |
| History 模式 | /about | pushState + 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>注意事项
- Hash 模式不需要服务器配置,适合快速原型开发
- History 模式需要服务器回退,所有路由指向 index.html
- 路由参数解析:History 模式需要手动实现正则匹配
- 404 处理:客户端需要处理未知路由
- 滚动恢复:路由切换时可能需要恢复滚动位置
最佳实践
- 新项目优先用 History 模式:URL 更美观,支持 SEO
- 封装路由器:将路由逻辑独立为可复用模块
- 支持路由参数:实现动态路由(如
/blog/:id) - 拦截链接点击:阻止默认导航行为
- 初始化 replaceState:页面加载时使用 replace 而非 push
下一节
继续学习:Canvas 2D 上下文