3、History路由实现:从pushState到popstate
讲完Hash路由,咱们来看看另一种更「正统」的实现方式——History路由。说实话,我个人更偏爱History路由,因为它生成的URL看起来更干净、更专业。你想想看,/user/123和/#/user/123,哪个更像一个正经的网址?
3.1 History API核心:pushState与replaceState
History API是HTML5带来的好东西。它让我们可以操作浏览器的历史记录,而不会触发页面刷新。核心就两个方法:
- pushState:往历史栈里压入一条新记录
- replaceState:替换当前的历史记录,不新增
它们的签名长这样:
history.pushState(state, title, url)
history.replaceState(state, title, url)
三个参数分别是:
| 参数 | 说明 | 我常用的做法 |
|---|---|---|
| state | 附加的状态对象,可以是任意JSON | 存路由参数或页面滚动位置 |
| title | 页面标题,大部分浏览器忽略它 | 传空字符串就行,别纠结 |
| url | 新的URL,必须同源 | 传相对路径,比如 /user/42 |
关键点:pushState和replaceState不会触发popstate事件,也不会真正加载新页面。它们只是改了地址栏的URL,顺便往历史栈里塞了条记录。
3.2 popstate事件:监听浏览器的「后退」和「前进」
那用户点了浏览器的后退按钮怎么办?这时候就需要popstate事件了。
popstate事件在以下情况触发:
- 用户点击浏览器的「后退」或「前进」按钮
- 调用
history.back()、history.forward()、history.go()
但注意——pushState和replaceState不会触发popstate。嗯,这里要特别留意,很多新手会踩这个坑。
window.addEventListener('popstate', function(event) {
// event.state 就是当时 pushState 传进去的 state 对象
console.log('当前状态:', event.state)
console.log('当前URL:', location.pathname)
// 根据URL渲染对应的组件
renderRoute(location.pathname)
})
我曾经踩过的坑:popstate事件只会在「用户主动触发历史导航」时生效。如果你用pushState改了URL,想立刻执行路由逻辑,必须手动调用渲染函数。别指望popstate帮你做这件事。
3.3 手动实现简易History路由
理论说完了,咱们动手写一个。我会尽量保持简洁,让你看清核心逻辑。
class HistoryRouter {
constructor(routes) {
this.routes = routes
this.currentRoute = null
// 监听popstate事件
window.addEventListener('popstate', (e) => {
this.handleRoute(location.pathname)
})
// 初始化时根据当前URL渲染
this.handleRoute(location.pathname)
}
// 路由跳转
navigate(path) {
history.pushState(null, '', path)
this.handleRoute(path)
}
// 处理路由
handleRoute(path) {
// 匹配路由规则
const route = this.matchRoute(path)
if (route) {
this.currentRoute = route
// 渲染对应的视图
document.getElementById('app').innerHTML = route.render()
} else {
// 404处理
document.getElementById('app').innerHTML = '<h1>404 - 页面未找到</h1>'
}
}
// 简单的路由匹配
matchRoute(path) {
for (const route of this.routes) {
if (route.path === path) {
return route
}
// 支持动态路由 /user/:id
const match = this.matchDynamicRoute(route.path, path)
if (match) {
return { ...route, params: match.params }
}
}
return null
}
// 动态路由匹配(简化版)
matchDynamicRoute(routePath, actualPath) {
const routeParts = routePath.split('/')
const actualParts = actualPath.split('/')
if (routeParts.length !== actualParts.length) return null
const params = {}
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 HistoryRouter([
{ path: '/', render: () => '<h1>首页</h1>' },
{ path: '/about', render: () => '<h1>关于我们</h1>' },
{ path: '/user/:id', render: function() {
return `<h1>用户详情: ${this.params.id}</h1>`
}}
])
// 页面中的链接需要拦截默认行为
document.addEventListener('click', (e) => {
if (e.target.tagName === 'A' && e.target.href.startsWith(location.origin)) {
e.preventDefault()
router.navigate(e.target.pathname)
}
})
我的习惯:在实际项目中,我会把路由匹配逻辑抽成独立的模块,方便测试。另外,动态路由的参数解析一定要做边界处理,比如 /user/ 和 /user/123/extra 这种异常情况。
3.4 History路由优缺点分析
任何技术方案都有取舍。History路由也不例外。咱们客观地分析一下:
| 维度 | 优点 | 缺点 |
|---|---|---|
| URL美观度 | 没有#号,URL干净专业 | — |
| SEO友好 | 服务端可以正确识别路径 | 需要服务端配合,否则直接访问会404 |
| 功能完整性 | 支持所有浏览器导航操作 | 需要处理popstate的兼容性 |
| 部署复杂度 | — | 服务端必须做fallback到index.html |
| 学习成本 | API简单直观 | 需要理解浏览器历史栈机制 |
核心结论:如果你的项目有SSR需求,或者对URL颜值有要求,选History路由。如果只是纯前端SPA,且部署环境不方便配服务端重定向,Hash路由更省心。
3.5 服务端配置:绕不开的坑
说到History路由,就不得不提服务端配置。这是很多新手栽跟头的地方。
为什么需要服务端配合?
- 用户直接访问
/user/123,浏览器会向服务端发请求 - 服务端没有这个路径,返回404
- 但实际上,这个路径应该由前端路由处理
解决方案很简单——让服务端把所有路径都指向 index.html:
// Nginx配置示例
location / {
try_files $uri $uri/ /index.html
}
// Apache配置
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.html [L]
我曾经遇到的情况:有一次部署上线,忘了配Nginx的try_files,结果用户刷新页面就白屏。排查了半天才发现是服务端没做fallback。从那以后,我每次部署都会把服务端配置写进部署文档里,作为checklist的一项。
3.6 小结
History路由的核心就三件事:
- 用pushState/replaceState改变URL,不刷新页面
- 监听popstate事件处理浏览器导航
- 服务端做fallback,防止直接访问404
说白了,History路由比Hash路由多了一层服务端的配合,但换来的是更干净的URL和更好的SEO支持。我个人在大多数项目中都会优先选择History路由,除非是那种部署环境特别受限的场景。
下一章,咱们会对比Hash路由和History路由,看看在实际项目中到底该怎么选。到时候我会分享一些我踩过的坑和总结的经验。