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路由的核心就三件事:

  1. 用pushState/replaceState改变URL,不刷新页面
  2. 监听popstate事件处理浏览器导航
  3. 服务端做fallback,防止直接访问404

说白了,History路由比Hash路由多了一层服务端的配合,但换来的是更干净的URL和更好的SEO支持。我个人在大多数项目中都会优先选择History路由,除非是那种部署环境特别受限的场景。

下一章,咱们会对比Hash路由和History路由,看看在实际项目中到底该怎么选。到时候我会分享一些我踩过的坑和总结的经验。