第二章:深入 History API 与路由模式
在上一章中,我们了解了 Vue Router 的本质和设计哲学。现在,让我们深入探讨 Vue Router 的核心组成部分——History API 与路由模式。我们将详细分析 createWebHistory、createWebHashHistory 和 createMemoryHistory 的实现原理和使用场景。
createWebHistory:基于原生 History API
createWebHistory 是 Vue Router 推荐的路由模式,它使用浏览器原生的 History API 来管理路由状态,提供最干净的 URL 形式。
基于原生 History API:pushState、replaceState、popstate
// 简化的 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 的核心,它允许我们在不刷新页面的情况下修改浏览器历史记录:
// pushState 的使用示例
function pushStateExample() {
// 当前 URL: https://example.com/app/
// 添加新的历史记录
history.pushState(
{ page: 'profile' }, // 状态对象
'Profile', // 标题(目前大多数浏览器忽略)
'/app/profile' // 新的 URL(必须同源)
)
// URL 变为: https://example.com/app/profile
// 浏览器不会向服务器发送请求
// 用户可以点击后退按钮返回到 /app/
}pushState 的参数说明:
- state: 与新历史记录关联的状态对象,可以在
popstate事件中访问 - title: 页面标题(目前大多数浏览器忽略此参数)
- url: 新的历史记录的 URL,必须与当前页面同源
popstate 事件:浏览器前进/后退时触发,如何同步 currentRoute?
当用户点击浏览器的前进/后退按钮时,会触发 popstate 事件:
// 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 配置示例
server {
listen 80;
server_name example.com;
location / {
# 尝试查找文件,如果不存在则返回 index.html
try_files $uri $uri/ /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>这种配置确保了无论用户访问哪个路由,都会返回 index.html,然后由 Vue Router 来处理路由。
SEO 友好:<link rel="canonical"> 如何避免重复内容?
为了确保 SEO 友好,可以使用 canonical 标签避免重复内容问题:
<!-- 在 HTML 模板中 -->
<head>
<link rel="canonical" href="https://example.com/app/profile">
</head>// 在路由守卫中动态设置 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:# 后的内容不发送到服务器
// 简化的 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 事件来响应路由变化:
// hashchange 事件处理
window.addEventListener('hashchange', (event) => {
// 获取新的 hash 值
const newHash = getHash()
// 同步路由状态
router.push(newHash)
})__vue_router__:如何避免与现有 hashchange 监听器冲突?
为了避免与其他库的 hashchange 监听器冲突,Vue Router 使用了特定的标记:
// 避免冲突的实现方式
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 编码:
// 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 中如何模拟路由?
// 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
}push、replace、go 如何操作栈?
MemoryHistory 通过操作内存中的栈来模拟浏览器历史记录:
// 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 初始化:
// 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 的三种路由模式:
- WebHistory - 基于 History API,提供最干净的 URL,但需要服务器配置
- HashHistory - 基于 location.hash,兼容性好,无需服务器配置
- MemoryHistory - 基于内存栈,用于 SSR 环境
每种模式都有其适用场景:
- 生产环境推荐使用 WebHistory
- 需要兼容老浏览器时使用 HashHistory
- 服务端渲染使用 MemoryHistory
在下一章中,我们将探讨路由匹配与路径解析的实现原理,包括路径匹配算法和路由记录结构。
思考题:
- 你在项目中使用 History 模式时,是否遇到过服务端配置相关的问题?是如何解决的?
- 对于需要支持老浏览器的项目,你如何权衡使用 HashHistory 的利弊?