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
// 但页面不会刷新!
关键特性:
- 不会触发页面刷新
- 新 URL 必须是同源的(出于安全考虑)
- 可以添加任意 URL,即使该路径在服务器上不存在
- 状态对象存储在浏览器历史记录中,刷新页面后可恢复
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();
}
});