Skip to content

第二章:深入 History API 与路由模式

在上一章中,我们了解了 Vue Router 的本质和设计哲学。现在,让我们深入探讨 Vue Router 的核心组成部分——History API 与路由模式。我们将详细分析 createWebHistorycreateWebHashHistorycreateMemoryHistory 的实现原理和使用场景。

createWebHistory:基于原生 History API

createWebHistory 是 Vue Router 推荐的路由模式,它使用浏览器原生的 History API 来管理路由状态,提供最干净的 URL 形式。

基于原生 History APIpushStatereplaceStatepopstate

javascript
// 简化的 createWebHistory 实现
function createWebHistory(base = '') {
  // 标准化 base 路径
  const normalizedBase = normalizeBase(base)
  
  // 创建历史管理器对象
  const history = {
    // 当前位置
    location: getLocation(normalizedBase),
    
    // 状态操作方法
    push(to, data) {
      // 使用 pushState 添加新的历史记录
      const url = normalizedBase + to
      history.state.pushState(data, '', url)
      history.location = to
    },
    
    replace(to, data) {
      // 使用 replaceState 替换当前历史记录
      const url = normalizedBase + to
      history.state.replaceState(data, '', url)
      history.location = to
    },
    
    // 前进/后退
    go(delta) {
      history.state.go(delta)
    }
  }
  
  // 监听浏览器前进后退事件
  window.addEventListener('popstate', (event) => {
    // 当用户点击前进/后退按钮时触发
    history.location = getLocation(normalizedBase)
    // 通知路由器更新当前路由
    history.notifyListeners(history.location, event.state)
  })
  
  return history
}

// 获取当前路径
function getLocation(base) {
  // 从完整 URL 中提取路径部分
  const path = decodeURIComponent(window.location.pathname)
  const baseEndIndex = path.indexOf(base)
  
  if (baseEndIndex === -1) {
    return path
  }
  
  // 移除 base 路径
  return path.slice(baseEndIndex + base.length) || '/'
}

pushState(state, title, url):如何添加历史记录?

pushState 方法是 History API 的核心,它允许我们在不刷新页面的情况下修改浏览器历史记录:

javascript
// pushState 的使用示例
function pushStateExample() {
  // 当前 URL: https://example.com/app/
  
  // 添加新的历史记录
  history.pushState(
    { page: 'profile' },    // 状态对象
    'Profile',              // 标题(目前大多数浏览器忽略)
    '/app/profile'          // 新的 URL(必须同源)
  )
  
  // URL 变为: https://example.com/app/profile
  // 浏览器不会向服务器发送请求
  // 用户可以点击后退按钮返回到 /app/
}

pushState 的参数说明:

  1. state: 与新历史记录关联的状态对象,可以在 popstate 事件中访问
  2. title: 页面标题(目前大多数浏览器忽略此参数)
  3. url: 新的历史记录的 URL,必须与当前页面同源

popstate 事件:浏览器前进/后退时触发,如何同步 currentRoute

当用户点击浏览器的前进/后退按钮时,会触发 popstate 事件:

javascript
// popstate 事件处理
function setupPopstateListener(router) {
  window.addEventListener('popstate', (event) => {
    // 获取当前路径
    const currentPath = getLocation(router.base)
    
    // 同步路由器状态
    router.push(currentPath, event.state)
  })
}

// 完整的 WebHistory 实现示例
class WebHistory {
  constructor(base = '') {
    this.base = normalizeBase(base)
    this.location = getLocation(this.base)
    this.listeners = []
    
    // 监听 popstate 事件
    window.addEventListener('popstate', this.handlePopState.bind(this))
  }
  
  handlePopState(event) {
    this.location = getLocation(this.base)
    // 通知所有监听器
    this.listeners.forEach(listener => {
      listener(this.location, event.state)
    })
  }
  
  push(to, data) {
    const url = this.base + to
    history.pushState(data, '', url)
    this.location = to
    // 通知监听器
    this.listeners.forEach(listener => listener(to, data))
  }
  
  replace(to, data) {
    const url = this.base + to
    history.replaceState(data, '', url)
    this.location = to
    // 通知监听器
    this.listeners.forEach(listener => listener(to, data))
  }
  
  go(delta) {
    history.go(delta)
  }
  
  // 添加监听器
  addListener(listener) {
    this.listeners.push(listener)
  }
  
  // 移除监听器
  removeListener(listener) {
    const index = this.listeners.indexOf(listener)
    if (index > -1) {
      this.listeners.splice(index, 1)
    }
  }
}

服务端配置:为何需要 index.html fallback?

使用 History 模式时,需要服务器配置支持,因为直接访问 /app/profile 这样的 URL 时,服务器会尝试查找对应的文件,但实际文件并不存在:

nginx
# Nginx 配置示例
server {
  listen 80;
  server_name example.com;
  
  location / {
    # 尝试查找文件,如果不存在则返回 index.html
    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>

这种配置确保了无论用户访问哪个路由,都会返回 index.html,然后由 Vue Router 来处理路由。

为了确保 SEO 友好,可以使用 canonical 标签避免重复内容问题:

html
<!-- 在 HTML 模板中 -->
<head>
  <link rel="canonical" href="https://example.com/app/profile">
</head>
javascript
// 在路由守卫中动态设置 canonical 标签
router.afterEach((to) => {
  // 更新 canonical 标签
  let canonical = document.querySelector('link[rel="canonical"]')
  if (!canonical) {
    canonical = document.createElement('link')
    canonical.setAttribute('rel', 'canonical')
    document.head.appendChild(canonical)
  }
  canonical.setAttribute('href', `https://example.com${to.path}`)
})

createWebHashHistory:基于 location.hash

Hash 模式使用 URL 的 hash 部分来模拟路由,不需要服务器配置支持。

基于 location.hash# 后的内容不发送到服务器

javascript
// 简化的 HashHistory 实现
function createWebHashHistory(base = '') {
  const normalizedBase = normalizeBase(base)
  
  // 确保 URL 以 # 开始
  function ensureHash() {
    if (!window.location.hash) {
      window.location.hash = '#/'
    }
  }
  
  const history = {
    location: getHash(),
    
    push(to, data) {
      ensureHash()
      // 修改 hash 值
      window.location.hash = to
      history.location = to
    },
    
    replace(to, data) {
      ensureHash()
      // 替换当前 hash 值
      const href = window.location.href
      const i = href.indexOf('#')
      const base = i >= 0 ? href.slice(0, i) : href
      window.location.replace(`${base}#${to}`)
      history.location = to
    }
  }
  
  // 监听 hashchange 事件
  window.addEventListener('hashchange', () => {
    history.location = getHash()
    history.notifyListeners(history.location)
  })
  
  return history
}

// 获取当前 hash 值
function getHash() {
  // 获取 hash 部分并解码
  const href = window.location.href
  const index = href.indexOf('#')
  
  if (index < 0) return '/'
  
  const hash = href.slice(index + 1)
  return hash ? decodeURIComponent(hash) : '/'
}

hashchange 事件:监听 # 变化,同步 currentRoute

与 History 模式类似,Hash 模式通过监听 hashchange 事件来响应路由变化:

javascript
// hashchange 事件处理
window.addEventListener('hashchange', (event) => {
  // 获取新的 hash 值
  const newHash = getHash()
  
  // 同步路由状态
  router.push(newHash)
})

__vue_router__:如何避免与现有 hashchange 监听器冲突?

为了避免与其他库的 hashchange 监听器冲突,Vue Router 使用了特定的标记:

javascript
// 避免冲突的实现方式
class HashHistory {
  constructor() {
    this.isVueRouter = true
    this.listeners = []
    
    // 添加标记避免冲突
    window.__vue_router__ = window.__vue_router__ || []
    window.__vue_router__.push(this)
    
    // 监听 hashchange
    window.addEventListener('hashchange', this.handleHashChange.bind(this))
  }
  
  handleHashChange(event) {
    // 只处理 Vue Router 的变化
    if (event.isVueRouterEvent) {
      this.updateRoute(getHash())
    } else {
      // 外部事件,也进行处理
      this.updateRoute(getHash())
    }
  }
  
  push(to) {
    // 触发自定义事件避免冲突
    const event = new CustomEvent('hashchange', {
      detail: { isVueRouterEvent: true }
    })
    
    window.location.hash = to
    window.dispatchEvent(event)
  }
}

URL 编码问题:# 后的 ?& 如何处理?

Hash 模式需要正确处理 URL 编码:

javascript
// URL 编码处理
function encodeHashPath(path) {
  // 对路径进行编码,但保留 #/?& 等特殊字符
  return encodeURI(path)
    .replace(/#/g, '%23')
    .replace(/\?/g, '%3F')
    .replace(/&/g, '%26')
}

function decodeHashPath(hash) {
  try {
    return decodeURIComponent(hash)
  } catch (error) {
    // 解码失败时返回原始值
    return hash
  }
}

createMemoryHistory(SSR 专用):无浏览器环境

MemoryHistory 用于服务端渲染环境,它在内存中维护路由状态。

无浏览器环境:Node.js 中如何模拟路由?

javascript
// MemoryHistory 实现
function createMemoryHistory(base = '') {
  const normalizedBase = normalizeBase(base)
  const history = {
    // 内存中的历史记录栈
    stack: [normalizedBase + '/'],
    // 当前在栈中的位置
    index: 0,
    
    // 当前位置
    get location() {
      return history.stack[history.index]
    },
    
    push(to) {
      // 移除当前位置之后的历史记录
      history.stack = history.stack.slice(0, history.index + 1)
      // 添加新的历史记录
      history.stack.push(normalizedBase + to)
      history.index++
    },
    
    replace(to) {
      // 替换当前位置
      history.stack[history.index] = normalizedBase + to
    },
    
    go(delta) {
      const newIndex = history.index + delta
      if (newIndex < 0 || newIndex >= history.stack.length) {
        // 超出范围,不执行任何操作
        return
      }
      history.index = newIndex
    }
  }
  
  return history
}

pushreplacego 如何操作栈?

MemoryHistory 通过操作内存中的栈来模拟浏览器历史记录:

javascript
// MemoryHistory 栈操作示例
class MemoryHistory {
  constructor() {
    this.stack = ['/']
    this.index = 0
    this.listeners = []
  }
  
  push(location) {
    // 移除前进的历史记录
    this.stack = this.stack.slice(0, this.index + 1)
    // 添加新记录
    this.stack.push(location)
    this.index++
    this.notifyListeners()
  }
  
  replace(location) {
    // 替换当前记录
    this.stack[this.index] = location
    this.notifyListeners()
  }
  
  go(delta) {
    const newIndex = this.index + delta
    if (newIndex >= 0 && newIndex < this.stack.length) {
      this.index = newIndex
      this.notifyListeners()
    }
  }
  
  back() {
    this.go(-1)
  }
  
  forward() {
    this.go(1)
  }
  
  notifyListeners() {
    this.listeners.forEach(listener => {
      listener(this.stack[this.index])
    })
  }
}

SSR 中 router 如何初始化?

在服务端渲染中,路由器需要从请求的 URL 初始化:

javascript
// SSR 中的路由器初始化
function createSSRRouter(requestUrl) {
  // 创建内存历史记录
  const history = createMemoryHistory()
  
  // 从请求 URL 初始化路由
  const url = new URL(requestUrl, 'http://localhost')
  history.push(url.pathname + url.search + url.hash)
  
  // 创建路由器
  const router = createRouter({
    history,
    routes: [
      // 路由配置
    ]
  })
  
  return router
}

// 在服务端渲染中使用
async function render(requestUrl) {
  // 创建 SSR 路由器
  const router = createSSRRouter(requestUrl)
  
  // 创建应用
  const app = createApp(App)
  app.use(router)
  
  // 等待路由解析完成
  await router.push(requestUrl)
  await router.isReady()
  
  // 渲染应用
  const html = await renderToString(app)
  
  return html
}

小结

在本章中,我们深入探讨了 Vue Router 的三种路由模式:

  1. WebHistory - 基于 History API,提供最干净的 URL,但需要服务器配置
  2. HashHistory - 基于 location.hash,兼容性好,无需服务器配置
  3. MemoryHistory - 基于内存栈,用于 SSR 环境

每种模式都有其适用场景:

  • 生产环境推荐使用 WebHistory
  • 需要兼容老浏览器时使用 HashHistory
  • 服务端渲染使用 MemoryHistory

在下一章中,我们将探讨路由匹配与路径解析的实现原理,包括路径匹配算法和路由记录结构。


思考题

  1. 你在项目中使用 History 模式时,是否遇到过服务端配置相关的问题?是如何解决的?
  2. 对于需要支持老浏览器的项目,你如何权衡使用 HashHistory 的利弊?