跳到主要内容

History API 与前端路由

问题

前端路由是如何实现的?History 模式和 Hash 模式有什么区别?

答案

前端路由是 SPA(单页应用)的核心技术,通过拦截 URL 变化来渲染不同的组件,而不是刷新整个页面。主要有两种实现方式:Hash 模式History 模式

两种路由模式对比

特性Hash 模式History 模式
URL 格式example.com/#/pageexample.com/page
原理hashchange 事件pushState / popstate
服务端配置不需要需要配置回退
SEO不友好友好
兼容性IE8+IE10+
刷新页面正常工作需要服务端支持

Hash 模式

原理

Hash 模式利用 URL 的 hash 部分# 后面的内容),hash 变化不会触发页面刷新。

实现

class HashRouter {
private routes: Map<string, () => void> = new Map();
private currentPath: string = '';

constructor() {
// 监听 hash 变化
window.addEventListener('hashchange', this.handleHashChange.bind(this));
// 初始加载
window.addEventListener('load', this.handleHashChange.bind(this));
}

// 注册路由
route(path: string, callback: () => void): this {
this.routes.set(path, callback);
return this;
}

// 处理 hash 变化
private handleHashChange(): void {
const hash = window.location.hash.slice(1) || '/';

if (hash !== this.currentPath) {
this.currentPath = hash;

const handler = this.routes.get(hash);
if (handler) {
handler();
} else {
// 404 处理
const notFound = this.routes.get('*');
notFound?.();
}
}
}

// 导航
push(path: string): void {
window.location.hash = path;
}

// 替换当前路由
replace(path: string): void {
const url = new URL(window.location.href);
url.hash = path;
window.location.replace(url.toString());
}
}

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

router
.route('/', () => console.log('Home Page'))
.route('/about', () => console.log('About Page'))
.route('/user/:id', () => console.log('User Page'))
.route('*', () => console.log('404 Not Found'));

// 导航
router.push('/about'); // URL: example.com/#/about

获取 Hash 参数

function parseHash(hash: string): { path: string; query: Record<string, string> } {
const [path, queryString] = hash.slice(1).split('?');
const query: Record<string, string> = {};

if (queryString) {
const params = new URLSearchParams(queryString);
params.forEach((value, key) => {
query[key] = value;
});
}

return { path: path || '/', query };
}

// URL: example.com/#/user?id=123&name=alice
const { path, query } = parseHash(window.location.hash);
// path: '/user'
// query: { id: '123', name: 'alice' }

History 模式

History API

// pushState: 添加历史记录
history.pushState(
state, // 状态对象,可在 popstate 事件中获取
title, // 标题(大多数浏览器忽略)
url // 新 URL
);

// replaceState: 替换当前历史记录
history.replaceState(state, title, url);

// 导航
history.back(); // 后退
history.forward(); // 前进
history.go(-2); // 后退 2 步
history.go(1); // 前进 1 步

popstate 事件

popstate 事件在浏览器前进/后退时触发:

window.addEventListener('popstate', (event: PopStateEvent) => {
console.log('路由变化');
console.log('state:', event.state);
console.log('当前路径:', window.location.pathname);
});

// 注意:pushState/replaceState 不会触发 popstate
history.pushState({ page: 1 }, '', '/page1'); // 不触发 popstate
history.back(); // 触发 popstate

实现

class HistoryRouter {
private routes: Map<string, (params?: Record<string, string>) => void> = new Map();
private currentPath: string = '';

constructor() {
// 监听浏览器前进/后退
window.addEventListener('popstate', this.handlePopState.bind(this));
// 初始加载
window.addEventListener('load', () => {
this.handleRoute(window.location.pathname);
});
}

// 注册路由
route(path: string, callback: (params?: Record<string, string>) => void): this {
this.routes.set(path, callback);
return this;
}

// 处理 popstate
private handlePopState(event: PopStateEvent): void {
this.handleRoute(window.location.pathname);
}

// 路由匹配
private handleRoute(pathname: string): void {
if (pathname === this.currentPath) return;
this.currentPath = pathname;

// 精确匹配
const handler = this.routes.get(pathname);
if (handler) {
handler();
return;
}

// 动态路由匹配
for (const [pattern, callback] of this.routes) {
const params = this.matchRoute(pattern, pathname);
if (params) {
callback(params);
return;
}
}

// 404
const notFound = this.routes.get('*');
notFound?.();
}

// 匹配动态路由 /user/:id
private matchRoute(pattern: string, pathname: string): Record<string, string> | null {
const patternParts = pattern.split('/');
const pathParts = pathname.split('/');

if (patternParts.length !== pathParts.length) return null;

const params: Record<string, string> = {};

for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
// 动态参数
params[patternParts[i].slice(1)] = pathParts[i];
} else if (patternParts[i] !== pathParts[i]) {
return null;
}
}

return params;
}

// 导航
push(path: string, state?: any): void {
history.pushState(state, '', path);
this.handleRoute(path);
}

// 替换
replace(path: string, state?: any): void {
history.replaceState(state, '', path);
this.handleRoute(path);
}

// 后退
back(): void {
history.back();
}

// 前进
forward(): void {
history.forward();
}
}

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

router
.route('/', () => console.log('Home'))
.route('/about', () => console.log('About'))
.route('/user/:id', (params) => console.log('User:', params?.id))
.route('*', () => console.log('404'));

router.push('/user/123'); // User: 123

拦截链接点击

// 拦截所有链接点击
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest('a');

if (
link &&
link.href &&
link.origin === window.location.origin && // 同源
!link.hasAttribute('download') && // 非下载
link.target !== '_blank' // 非新窗口
) {
e.preventDefault();
const path = link.pathname + link.search + link.hash;
router.push(path);
}
});

服务端配置

History 模式需要服务端配置,将所有路由回退到 index.html

# Nginx
location / {
try_files $uri $uri/ /index.html;
}
// Node.js Express
app.use(express.static('dist'));
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'dist', 'index.html'));
});
# 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>

完整路由器实现

type RouteHandler = (params: Record<string, string>, query: URLSearchParams) => void;
type Middleware = (next: () => void) => void;

interface Route {
pattern: RegExp;
paramNames: string[];
handler: RouteHandler;
}

class Router {
private routes: Route[] = [];
private middlewares: Middleware[] = [];
private mode: 'hash' | 'history';

constructor(mode: 'hash' | 'history' = 'history') {
this.mode = mode;
this.init();
}

private init(): void {
if (this.mode === 'hash') {
window.addEventListener('hashchange', () => this.handleRoute());
} else {
window.addEventListener('popstate', () => this.handleRoute());
}
window.addEventListener('load', () => this.handleRoute());
}

// 注册中间件
use(middleware: Middleware): this {
this.middlewares.push(middleware);
return this;
}

// 注册路由
route(path: string, handler: RouteHandler): this {
const paramNames: string[] = [];
const pattern = path
.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
})
.replace(/\*/g, '.*');

this.routes.push({
pattern: new RegExp(`^${pattern}$`),
paramNames,
handler,
});

return this;
}

// 获取当前路径
private getPath(): string {
if (this.mode === 'hash') {
return window.location.hash.slice(1).split('?')[0] || '/';
}
return window.location.pathname;
}

// 获取查询参数
private getQuery(): URLSearchParams {
if (this.mode === 'hash') {
const queryString = window.location.hash.split('?')[1] || '';
return new URLSearchParams(queryString);
}
return new URLSearchParams(window.location.search);
}

// 处理路由
private handleRoute(): void {
const path = this.getPath();
const query = this.getQuery();

// 执行中间件
const runMiddlewares = (index: number): void => {
if (index >= this.middlewares.length) {
this.matchRoute(path, query);
return;
}
this.middlewares[index](() => runMiddlewares(index + 1));
};

runMiddlewares(0);
}

// 匹配路由
private matchRoute(path: string, query: URLSearchParams): void {
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
const params: Record<string, string> = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});
route.handler(params, query);
return;
}
}
}

// 导航
push(path: string, state?: any): void {
if (this.mode === 'hash') {
window.location.hash = path;
} else {
history.pushState(state, '', path);
this.handleRoute();
}
}

replace(path: string, state?: any): void {
if (this.mode === 'hash') {
const url = new URL(window.location.href);
url.hash = path;
window.location.replace(url.toString());
} else {
history.replaceState(state, '', path);
this.handleRoute();
}
}

back(): void {
history.back();
}

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

// 使用示例
const router = new Router('history');

// 中间件:路由守卫
router.use((next) => {
if (isAuthenticated()) {
next();
} else {
router.replace('/login');
}
});

// 路由配置
router
.route('/', () => renderHome())
.route('/user/:id', (params, query) => {
renderUser(params.id, query.get('tab'));
})
.route('/post/:postId/comment/:commentId', (params) => {
renderComment(params.postId, params.commentId);
})
.route('*', () => render404());

Navigation API 是新的浏览器标准,提供更强大的路由控制:

// 检查支持
if ('navigation' in window) {
// 监听导航事件
navigation.addEventListener('navigate', (event: NavigateEvent) => {
const url = new URL(event.destination.url);

// 拦截导航
if (url.pathname.startsWith('/app/')) {
event.intercept({
async handler() {
await renderPage(url.pathname);
},
});
}
});

// 导航
navigation.navigate('/page', {
state: { foo: 'bar' },
history: 'push', // 或 'replace'
});

// 后退/前进
navigation.back();
navigation.forward();

// 获取历史记录
const entries = navigation.entries();
const currentEntry = navigation.currentEntry;
}

常见面试问题

Q1: 前端路由的两种模式有什么区别?

答案

特性Hash 模式History 模式
URL/#/path/path
原理hashchange 事件pushState + popstate
服务端不需要配置需要配置回退
SEO不友好友好
兼容性IE8+IE10+

选择建议

  • 需要 SEO → History 模式
  • 不需要服务端配置 → Hash 模式
  • 现代项目 → 推荐 History 模式

Q2: History 模式刷新页面为什么会 404?如何解决?

答案

原因:刷新时浏览器向服务器请求 /user/123,服务器没有这个路径的文件。

解决方案:服务端配置所有路由回退到 index.html

# Nginx
location / {
try_files $uri $uri/ /index.html;
}
// Express
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'dist', 'index.html'));
});

Q3: pushState 和 replaceState 有什么区别?

答案

方法历史记录用途
pushState添加新记录正常导航
replaceState替换当前记录重定向、替换 URL
history.pushState({}, '', '/page1');  // 可以后退
history.replaceState({}, '', '/page2'); // 无法后退到 page1

Q4: 如何实现路由守卫?

答案

// 1. 中间件方式
router.use((next) => {
if (isAuthenticated()) {
next(); // 继续
} else {
router.replace('/login'); // 重定向
}
});

// 2. 路由配置方式
const routes = [
{
path: '/dashboard',
component: Dashboard,
beforeEnter: (to, from, next) => {
if (hasPermission(to.path)) {
next();
} else {
next('/403');
}
},
},
];

Q5: hashchange 和 popstate 事件的触发时机?

答案

操作hashchangepopstate
location.hash = '#/x'
history.pushState
history.replaceState
history.back/forward❌(hash 变化时触发)
点击浏览器后退✅(hash 变化时)

注意pushState/replaceState 不会触发任何事件,需要手动调用路由处理函数。

相关链接