【Vue 路由系列 01】Hash 与 History 模式:前端路由的技术实现
"你说的
history.back事件根本不存在。" —— 这句话在模拟面试中击中了最痛的点。上一篇(00 序章) 已经建立了完整的底座:浏览器的五种导航场景、历史栈记录类型之分(真实导航 vs pushState)、以及浏览器给 JS 留的两个"把手"。如果你还没读过,强烈建议先花 15 分钟读完那篇 —— 本篇的所有内容都建立在那套概念之上。
这篇进入 How 层(技术实现层):基于 00 篇的底座,具体讲 Vue Router 的 Hash 模式和 History 模式分别用了什么 API、写了什么代码来实现前端路由。同时包含
pushState不触发popstate这个经典陷阱、scrollBehavior的原理、以及服务端 fallback 配置等面试高频考点。
一、前端路由的"偷天换日":两种模式的本质区别
回顾 00 篇的结论:前端路由的核心任务只有两件事——拦截点击链接(preventDefault) + 在后退/前进时事后感知(hashchange / popstate)。但 Hash 和 History 两种模式完成这两件事的方式完全不同。这一章是连接"概念"与"API"的桥梁。
1.1 前端路由要解决的核心问题
回顾 00 篇的分析,前端路由需要在五种场景下让 SPA 正常工作:
| 场景 | 前端路由的目标 | 解决思路 |
|---|---|---|
| ① 点击链接 | 不刷新页面,切换组件 | preventDefault + JS 改 URL + JS 渲染组件 |
| ②③ 后退/前进 | 组件跟着切回去 | 监听事件 → 读 URL → 重新匹配 → 重新渲染 |
| ④ 输入 URL | 别 404 | Hash 天然免疫 / History 需要服务端 fallback |
| ⑤ 刷新 | 别 404 | 同上 |
场景①②③ 是前端代码自己能解决的,场景④⑤ 必须依赖外部配合。 这就是 Hash 和 History 两种模式的设计出发点不同。
1.2 Hash 模式的"偷天换日"
Hash 模式利用了一个浏览器特性:# 后面的内容是客户端专属的,永远不会发给服务器。
URL: example.com/#/about
↑
#号前面的部分(example.com/) → 服务器收到的
#号后面的部分(#/about) → 只有 JS 能读到,服务器永远看不到
基于这个特性,Hash 模式在每个场景下的做法:
场景①(点击链接):
<a href="#/about">
↓
preventDefault(可选,其实不拦也行,因为 # 链接本来就不触发整页导航)
↓
设置 location.hash = '#/about'
↓
浏览器自动:推栈 + 变URL + 触发 hashchange(不发请求!)
↓
Vue Router 监听 hashchange → 解析 hash → 匹配路由 → 渲染组件 ✓
场景②③(后退/前进):
用户点后退
↓
浏览器弹栈 + 恢复 URL(含 hash 部分)
↓
hash 变了 → 触发 hashchange(不发请求!)
↓
Vue Router 收到回调 → 同上 ✓
场景④⑤(输入URL / 刷新):
用户输入 example.com/#/about 并回车
↓
浏览器只请求 example.com/
↓
服务器返回 index.html ✓(因为这个文件真实存在)
↓
SPA 加载完毕 → JS 读取 location.hash → 匹配路由 → 渲染 ✓
Hash 模式的特点:五种场景全部自洽,不需要任何服务端配合。 代价是 URL 丑陋(带 #)且 SEO 不友好。
1.3 History 模式的"偷天换日"
History 模式利用了 HTML5 的 pushState API:可以改变 URL 和历史栈,但不触发页面刷新。
history.pushState(state, title, url)
↑
这个 API 让浏览器:
- 往历史栈推一条记录 ✓
- 改变 URL 栏 ✓
- 但!不刷新页面!不发请求!不触发事件!
基于这个 API,History 模式在每个场景下的做法:
场景①(点击链接):
<a href="/about">
↓
必须 preventDefault!(不拦的话浏览器真的会去请求 /about)
↓
手动调用 history.pushState({}, '', '/about')
↓
浏览器:推栈 + 变 URL(不刷新、不发请求、不触发事件)
↓
pushState 不触发任何事件!
所以 Vue Router 必须手动调用 onChange() → 匹配路由 → 渲染 ✓
场景②③(后退/前进):
用户点后退
↓
浏览器弹栈 + 恢复 URL(照做,无法阻止!)
↓
目标是 pushState 记录 → 触发 popstate 事件(不是请求服务器,而是发通知)
↓
(参见 00 篇 2.2 节:浏览器检查记录类型,
pushState 记录走 popstate 分支,不放权给 JS 处理)
↓
Vue Router 监听 popstate → 读取 URL → 匹配路由 → 渲染 ✓
场景④⑤(输入URL / 刷新):
用户输入 example.com/about 并回车
↓
浏览器向服务器发送 GET /about 请求!!!
↓
如果服务端没配置 fallback → 404 💀
↓
如果服务端配置了 try_files → 返回 index.html ✓
↓
SPA 加载 → JS 读取 URL pathname → 匹配路由 → 渲染 ✓
History 模式的特点:URL 干净美观,但场景④⑤ 存在 404 风险,必须服务端配合兜底。
1.4 两种模式的本质区别(一句话版)
聊了这么多,归根结底就是一个问题:
当用户进行某种操作时,浏览器会不会向服务器发送一个新的 HTTP 请求?
| Hash 模式 | History 模式 | |
|---|---|---|
| 点击链接后 | ❌ 不发(# 变化不算请求) | ❌ 不发(pushState 不触发请求) |
| 后退/前进后 | ❌ 不发(同上) | ❌ 不发(popstate 只是通知,目标为 pushState 记录时) |
| 输入 URL 后 | ❌ 不发(只请求 # 前的部分) | ✅ 会发(完整的 URL 路径) |
| F5 刷新后 | ❌ 不发(同上) | ✅ 会发(同上) |
这就是两种模式所有差异的根本来源。 所有 API 设计、服务端配置要求、兼容性问题,都是从这里派生出去的。
二、Hash 模式详解
2.1 什么是 Hash
URL 中 # 后面的部分叫做 hash(也叫 fragment):
https://example.com/users#/profile/123
↑↑↑↑↑↑↑↑↑↑↑
这部分就是 hash
hash 有几个重要特性:
// 1. hash 的变化不会触发浏览器向服务器请求页面
// 无论 # 后面怎么变,浏览器只会请求 https://example.com/users
// 2. hash 改变会在浏览器历史中添加记录(可以前进/后退)
location.hash = '/home' // 历史栈新增一条
location.hash = '/about' // 又新增一条
history.back() // 回到 /home ✓
// 3. 可以通过 JS 监听 hash 的变化
window.addEventListener('hashchange', (e) => {
console.log('hash 变成了:', location.hash)
console.log('旧 hash:', e.oldURL)
console.log('新 hash:', e.newURL)
})
2.2 Hash 模式的工作流程
对照 00 篇的底座图看,Hash 模式做的事情很清楚:利用
#不参与 HTTP 请求的特性 +hashchange事件驱动。
路径一:用户点击 <router-link>(主动导航)
用户点击 <router-link to="/about">
↓
Vue Router 阻止 <a> 的默认行为(preventDefault) ← 对应 00 篇"把手A"
↓
执行: location.hash = '#/about' ← 只改 # 后面部分
↓
浏览器行为:
├─ URL 变成 example.com/#/about
├─ 历史栈新增一条记录 ✓
├─ 但因为只是 hash 变化,浏览器不发 HTTP 请求! ← 这是 Hash 的核心特性
└─ 触发 hashchange 事件 ← 对应 00 篇"把手B"
↓
Vue Router 的 hashchange 监听器收到回调
↓
解析 location.hash → 去掉 '#' → 得到 '/about'
↓
匹配路由 → 渲染 About 组件
路径二:用户点浏览器后退按钮(被动导航)
用户点击浏览器 ◀ 后退按钮
↓
浏览器自己执行后退操作:
├─ 历史栈弹出一条记录 ✓
├─ URL 恢复为上一个(包括 # 后面部分)
├─ hash 发生了变化
└─ 触发 hashchange 事件 ← 同样是"把手B"
↓
Vue Router 收到回调 → 同上,解析 hash → 匹配 → 渲染
注意和 History 模式的关键差异:Hash 模式下,不管是点击还是后退,都会触发事件(hashchange)。因为 location.hash = 'xxx' 本身就会触发 hashchange。所以 Hash 模式不需要像 History 那样在 push 之后手动调 onChange——事件本身就会驱动流程。
这也是为什么 Hash 模式的实现比 History 简单:只需要监听一个事件就够了,不用分主动/被动两条路处理。
2.3 Hash 模式的优缺点
| 优点 | 缺点 |
|---|---|
| 兼容性极好,IE8 都支持 | URL 丑陋,带 # 号 |
| 无需服务端配置 | SEO 不友好(搜索引擎通常忽略 hash 内容) |
| 部署简单,随便丢个静态服务器就能跑 | hash 不会被传送到服务端(#后的内容不会出现在 HTTP 请求里) |
2.4 一个最小化的 Hash 路由实现
理解原理的最好方式是自己写一个:
class HashRouter {
constructor() {
this.routes = {} // 存储路由映射: { '/home': HomeComponent, ... }
this.currentUrl = '' // 当前路径
// 监听 hashchange 事件
window.addEventListener('hashchange', () => this.refresh())
// 初始化时也执行一次(处理首次访问带 hash 的情况)
window.addEventListener('DOMContentLoaded', () => this.refresh())
}
// 注册路由
route(path, handler) {
this.routes[path] = handler
}
// 核心方法:根据当前 hash 渲染对应组件
refresh() {
// location.hash 的格式是 "#/about",需要去掉 "#"
this.currentUrl = location.hash.slice(1) || '/'
const handler = this.routes[this.currentUrl]
if (handler) {
handler()
} else {
console.log(`404: 未找到路由 ${this.currentUrl}`)
}
}
}
// 使用示例
const router = new HashRouter()
router.route('/', () => {
document.getElementById('app').innerHTML = '<h1>首页</h1>'
})
router.route('/about', () => {
document.getElementById('app').innerHTML = '<h1>关于我们</h1>'
})
// 用户点击时只需要改变 hash:
// <a href="#/">首页</a>
// <a href="#/about">关于</a>
这就是 Vue Router Hash 模式的本质——在浏览器原生导航的底座上,利用 hashchange 事件 + # 不发请求的特性,实现了一套完整的前端路由。生产级的 Vue Router 只是在这个基础上加了动态路由匹配、嵌套路由、导航守卫等高级特性,核心原理不变。
三、History 模式:更优雅但需要配合
有了 00 篇的概念基础,History 模式的原理就一句话能说清:用
pushState改 URL(不刷新),用popstate监听后退(事后感知)。 难点在于理解它和 Hash 模式在"浏览器发不发请求"上的本质差异——这在第一章已经讲清楚了。
3.1 HTML5 History API
HTML5 提供了一组新的 API,让 JavaScript 可以在不刷新页面的情况下操作浏览器历史记录:
// 核心方法:往历史栈中推入一条新记录
// 参数1: 状态对象(可以在 popstate 事件中取回)
// 参数2: 标题(目前浏览器都忽略这个参数)
// 参数3: 新的 URL(必须同源)
history.pushState({ page: 1 }, '', '/users')
// 替换当前历史记录(不会增加历史条数)
history.replaceState({ page: 2 }, '', '/settings')
// 前进/后退
history.forward()
history.back()
history.go(-2) // 后退两步
注意 pushState 和直接修改 location.href 的区别:
| 操作 | 是否刷新页面 | 是否发请求 | 历史栈变化 |
|---|---|---|---|
location.href = '/about' | ✅ 刷新 | ✅ 发请求 | 导致导航,新页面有自己的历史栈 |
<a href="/about"> | ✅ 刷新 | ✅ 发请求 | +1 |
history.pushState('', '', '/about') | ❌ 不刷新 | ❌ 不发请求 | +1 |
这就是 History 模式能实现 SPA 的基础:pushState 改变了 URL 但不刷新页面。
3.2 popstate 事件 —— ⚠️ 面试高频考点
这是我在面试中翻车的点,现在彻底搞清楚。
当用户点击浏览器的前进 / 后退按钮,或者调用 history.back() / history.forward() / history.go() 时,会触发 popstate 事件。
window.addEventListener('popstate', (event) => {
console.log('状态变化:', event.state)
// event.state 就是 pushState 时存入的第一个参数(状态对象)
})
// 示例流程
history.pushState({ page: 'home' }, '', '/') // 不触发 popstate!
history.pushState({ page: 'about' }, '', '/about') // 不触发 popstate!
// 此时点击浏览器的后退按钮
// 触发 popstate 事件,event.state = { page: 'home' }
// 或者手动调用
history.back() // 同样触发 popstate,event.state = { page: 'home' }
3.3 ⚠️ 极其重要的细节:pushState/replaceState 不会触发 popstate
这是最容易混淆的地方(我在面试中就在这里翻车):
// ❌ 常见误解:以为 pushState 会触发 popstate
history.pushState({ id: 1 }, '', '/page1')
// popstate 不会被触发!
history.replaceState({ id: 2 }, '', '/page2')
// 同样不触发 popstate!
// ✅ 实际情况:只有"历史记录切换操作"才触发 popstate
history.pushState({ id: 1 }, '', '/page1')
history.pushState({ id: 2 }, '', '/page2')
history.back() // ← 只有这里才触发 popstate
为什么?因为 pushState/replaceState 是主动推送,你已经知道发生了什么;而 popstate 是被动响应——用户点了后退按钮或者代码调用了 history.back(),你需要在这个回调里做出反应。
💡 顺便一提:pushState 不仅不触发 popstate,它也不会触发 hashchange(即使你改的是 hash)。这在用 pushState 实现 Hash 路由时要特别注意。
3.4 我面试时的错误 vs 正确理解
❌ 我当时说的是:
"触发的是 history.back 事件,vue-router 内部监听这个事件"
错误分析:
history.back()是一个方法/API,不是事件- 它触发的真正的事件叫
popstate - Vue Router 内部确实监听了事件,但它监听的是
popstate,不是什么 "back 事件"
✅ 正确的说法:
"History 模式下,Vue Router 通过
history.pushState来改变 URL 不刷新页面,同时通过监听popstate事件来响应用户的前进/后退操作。"
3.5 History 模式的工作流程
对照 00 篇和第一章的概念图看,每一步都能对应上。
路径一:用户点击 <router-link>(主动导航)
用户点击 <router-link to="/about">
↓
Vue Router 阻止 <a> 的默认行为(preventDefault) ← 对应 00 篇"把手A:拦截"
↓
执行 history.pushState({}, '', '/about') ← 对应 00 篇"①推栈 + ②变URL"
↓
URL 变成 example.com/about(页面不刷新!) ← 浏览器不会发请求
↓
pushState 不触发任何事件!
↓
Vue Router 手动调用 onChange() ← 自己触发路由匹配
↓
匹配 /about → 渲染 About 组件 ← 对应 00 篇"④渲染(组件级)"
路径二:用户点浏览器后退按钮(被动导航)
用户点击浏览器 ◀ 后退按钮
↓
浏览器自己执行后退操作(Vue Router 管不了!) ← 00 篇的"路径二"
├─ ① 历史栈弹出一条记录 ✓
├─ ② URL 变回前一个地址 ✓
└─ ③ 浏览器检查目标记录类型(参见 00 篇 2.2 节)
目标是 pushState 记录
→ 不可能走 HTTP 加载流程
→ 改为触发 popstate 事件作为通知 ← 对应 00 篇"把手B:事件通知"
↓
Vue Router 的 popstate 监听器收到回调
↓
从 location.pathname 读取当前 URL(或从 event.state 取)
↓
匹配路由 → 渲染对应组件 ← 接管了页面展示
对比两张路径图,核心差异就一个点:
| 点击链接(主动) | 点后退(被动) | |
|---|---|---|
| 谁发起的 | JS 代码 (router.push) | 用户/浏览器 |
preventDefault? | ✅ 拦截了 <a> 默认行为 | 不适用(不是点击) |
pushState? | ✅ 手动调用 | ❌ 不调用(浏览器自己弹栈) |
| 触发什么事件? | 不触发任何事件 | 触发 popstate |
谁触发 onChange? | push 方法里手动调的 | popstate 回调里调的 |
| 最终结果 | 路由匹配 + 组件渲染 | 路由匹配 + 组件渲染 |
两条路最终汇入同一个 onChange——这就是 Vue Router 能统一处理两种情况的原因。
⚠️ 重要辨析:SPA 内回退 vs 跨页回退
上面路径二描述的只是一种情况——在 SPA 内部、后退目标是 pushState 记录时。但实际开发中,用户的后退行为可能跨越 SPA 边界。两种情况的差异极大,必须分清(完整分析参见 00 篇 2.2 节):
情况 A:SPA 内部回退(pushState → pushState)
从 /about 后退到 /home:
├─ 目标是 pushState 记录
├─ 不发 HTTP 请求
├─ 触发 popstate → Vue Router 接管
└─ 结果:无网络请求、无白屏、组件级切换 ✓
情况 B:从 SPA 回退到入口页(pushState → 真实导航)
从 /home 后退到 /(首次加载的入口):
├─ 目标是真实导航记录!(浏览器自己推的)
├─ 浏览器发起 GET / 请求 → 整页重新加载
└─ 结果:有白屏闪烁、SPA 状态全部丢失 💥
一张表总结
| SPA 内回退 | 退到 SPA 入口 | 跨站回退 | |
|---|---|---|---|
| 目标记录类型 | pushState | 真实导航 | 真实导航(可能不同源) |
| 是否发 HTTP 请求? | ❌ 不发 | ✅ 发 | ✅ 发 |
| 是否触发 popstate? | ✅ 触发 | ❌ 不触发 | ❌ 不触发 |
| Vue Router 能接管吗? | ✅ 能 | ❌ 不能 | ❌ 不能 |
| 页面是否刷新? | ❌ 不刷新 | ✅ 整页加载 | ✅ 整页跳转 |
面试高频追问:"History 模式下点后退按钮会向服务器发请求吗?"
正确回答:看后退的目标是什么记录。 如果目标是pushState推入的同源记录(即 SPA 内部跳转),则不发请求,只触发popstate由前端路由接管;如果目标是真实导航记录(如 SPA 的入口页)或者跨站记录,则会发起完整的 HTTP 请求导致整页加载。不能简单地说"会"或"不会"。
四、两种模式的全面对比
| 维度 | Hash 模式 | History 模式 |
|---|---|---|
| URL 格式 | example.com/#/about | example.com/about |
| 底层 API | hashchange 事件 | pushState + popstate 事件 |
| 是否需要服务端配置 | ❌ 不需要 | ✅ 必须配置 |
| 兼容性 | IE8+ | IE10+(现代浏览器没问题) |
| SEO | 差(搜索引擎忽略 # 后内容) | 好(正常 URL,可被爬取) |
| 服务器 404 问题 | 不会出现 | ⚠️ 用户直接访问 /about 会返回 404 |
| 第三方应用接入 | 方便(hash 不影响后端路由) | 可能冲突(需要后端配合排除) |
History 模式的 404 问题与解决方案
这是 History 模式最大的坑:
场景:用户在地址栏输入 example.com/about 然后按回车
↓
浏览器向服务器发送 GET /about 请求
↓
服务器:我根本没有 /about 这个文件啊!→ 返回 404
↓
用户看到 404 页面 💀
但在 SPA 内部点击链接跳转到 /about 是正常的,因为那是 pushState 操作,不会请求服务器。
解决方案:让服务端对所有路径返回同一个 index.html,然后由前端路由接管:
# Nginx 配置
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html; # 所有找不到的路径都回退到 index.html
}
}
// Node.js Express 配置
app.get('*', (req, res) => {
res.sendFile(path.join(__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>
五、第三种模式:Abstract(内存模式)
前面讲的是浏览器环境下的两种模式。但 Vue Router 其实还支持第三种模式——Abstract 模式(也叫内存模式):
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createMemoryHistory(), // 不依赖浏览器 API
routes: [...]
})
5.1 它是什么
Abstract 模式不操作浏览器的 URL 和历史记录,而是在内存中维护一个虚拟的路由状态。它不需要 hashchange、pushState、popstate 这些浏览器 API,完全靠 JavaScript 自己管理"当前路由是谁"。
5.2 适用场景
| 场景 | 为什么用 Abstract |
|---|---|
| SSR(服务端渲染) | Node.js 环境没有 window 和 history 对象,只能用内存模式 |
| 单元测试 | 测试时不需要真实浏览器环境,用内存模式模拟路由跳转 |
| Electron / React Native | 非浏览器环境,没有原生 History API |
| 小程序 | 小程序有自己的路由体系,Vue Router 只能以内存模式运行 |
5.3 三种模式对比
| 模式 | 创建函数 | 依赖 | 适用环境 |
|---|---|---|---|
| Hash | createWebHashHistory() | hashchange 事件 | 浏览器(兼容性最好) |
| History | createWebHistory() | pushState + popstate | 浏览器(需服务端配合) |
| Abstract | createMemoryHistory() | 无 | 非浏览器环境(SSR、测试) |
面试中如果被问到"Vue Router 有几种模式",回答三种比回答两种更能体现你的知识完整性。
六、Vue Router 中的模式选择
// Vue Router 4 的创建方式
const router = createRouter({
// 选择模式
history: createWebHistory(), // History 模式,URL: /about
// history: createWebHashHistory(), // Hash 模式,URL: #/about
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
]
})
createWebHistory 内部做了什么?
Vue Router 4 的 createWebHistory() 底层会监听浏览器的 popstate 事件。它的简化伪代码:
📌 官方参考:Vue Router 的
RouterHistory接口定义了一个listen方法,用于"当从外部触发导航(如浏览器后退和前进按钮)时将监听器附加到 History 实现"。对应到浏览器环境,这个外部导航就是通过popstate事件触发。
function createWebHistory(base = '') {
// 内部维护的 state
let currentUrl = base || '/'
// 设置初始状态
// 使用 replaceState 确保首次加载时不产生额外的历史记录
history.replaceState({ url: currentUrl }, '', currentUrl)
// 监听 popstate 事件(响应用户前进/后退)
window.addEventListener('popstate', (e) => {
const targetUrl = location.pathname
// 通知 Vue Router 更新当前路由并重新匹配
onChange(targetUrl)
})
// 提供 push 方法(用于编程式导航)
function push(to) {
history.pushState({ url: to }, '', to)
// pushState 不触发事件!所以这里需要手动触发路由变更
onChange(to)
}
// 提供 replace 方法
function replace(to) {
history.replaceState({ url: to }, '', to)
onChange(to)
}
return { push, replace }
}
重点看这段伪代码的逻辑:
- 初始化时用
replaceState设定起始状态(不产生额外历史记录) - 编程式导航(
router.push)内部调用pushState,然后手动触发onChange - 浏览器后退/前进触发
popstate,在回调里调用onChange - 不管哪种方式,最终都是走到
onChange进行路由匹配和组件渲染
这就是为什么 Vue Router 能同时处理主动导航和被动后退两种情况。
七、scrollBehavior 与 savedPosition
这是我在面试中完全没答上来的点,但它其实很简单:
const router = createRouter({
history: createWebHistory(),
routes: [...],
scrollBehavior(to, from, savedPosition) {
// savedPosition 只在通过浏览器前进/后退(popstate)时可用
// 它的值是 { top: number, left: number } 或 null
if (savedPosition) {
// 用户点了后退按钮 → 恢复之前的滚动位置
return savedPosition
} else if (to.hash) {
// 目标路由带有 hash 锚点(如 /about#section2)
return {
el: to.hash,
behavior: 'smooth'
}
} else {
// 其他情况滚到顶部
return { top: 0 }
}
}
})
savedPosition 从哪来?
它是浏览器自动维护的。当你在一个页面上滚动了 200px,然后导航走再通过后退回来时,浏览器会把 { top: 200, left: 0 } 传给 popstate 事件的关联数据中。Vue Router 把它提取出来传给你的 scrollBehavior 函数。
所以正确的回答是:
Vue Router 通过
scrollBehavior配置项和浏览器原生的history.state(保存滚动位置),实现了后退时滚动位置的自动还原。不需要自己写localStorage记录滚动位置,也不需要在beforeRouteLeave里手动保存。
八、面试高频问题速查
Q1:Hash 模式和 History 模式的区别?
标准回答模板:
Hash 模式基于
hashchange事件,URL 带#号,兼容性好无需服务端配置;History 模式基于 HTML5 的pushState+popstate,URL 更干净,但需要服务端做 fallback 配置,否则用户直接访问或 F5 刷新时会 404。选择的话,如果对 SEO 有要求或者追求更好的 URL 美观度就用 History,否则 Hash 开箱即用更省事。更深层的理解(参见 00 篇):两者的根本差异在于浏览器在不同操作下会不会发起 HTTP 请求。Hash 模式因为
#不参与请求,所以所有场景都安全;History 模式依赖pushState创建的"特殊标记记录"来实现 SPA 内回退不刷新,但跨出 SPA 边界后仍会走传统的 HTTP 加载路线。
Q2:pushState 和 popstate 是什么关系?
pushState是主动操作 API,用于改变 URL 和历史记录但不刷新页面;popstate是被动响应事件,在用户点击前进/后退按钮或调用history.back()时触发。pushState本身不会触发popstate。Vue Router 在 History 模式下,push方法内部调完pushState后会手动触发路由匹配,而浏览器后退时通过popstate回调来做同样的事。
Q3:用户在 History 模式下 F5 刷新为什么会 404?
因为 F5 刷新时浏览器会以当前 URL 向服务器发起真正的 HTTP GET 请求。History 模式的 URL(如
/about)在服务器上并不对应真实文件,所以返回 404。解决方法是让服务端将所有路径的请求 fallback 到index.html,再由前端路由接管。常见配置包括 Nginx 的try_files、Node.js Express 的通配路由、Apache 的mod_rewrite等。
Q4:history.back() 是事件吗?
不是。
history.back()是浏览器提供的方法(API),调用后会触发popstate事件。我在这里曾经犯过一个错误,把方法和事件混淆了。正确的关系是:history.back()/history.forward()/ 浏览器后退按钮 → 触发 →popstate事件。
Q5:History 模式下点后退按钮会向服务器发请求吗?
不能简单回答"会"或"不会"。 关键看后退的目标记录类型(详见 00 篇 2.2 节)。如果目标是
pushState推入的同源记录(SPA 内部跳转),则不发请求,只触发popstate由前端路由接管;如果目标是真实导航记录(如 SPA 入口页)或跨站记录,则会发起完整的 HTTP 请求导致整页加载。
九、本篇小结
概念层回顾(来自 00 篇)
| 概念 | 一句话记忆 | 详细解释 |
|---|---|---|
| 五种导航场景 | 点击 / 后退 / 前进 / 输入URL / F5 | 见 00 篇 2.1-2.3 |
| 核心分水岭 | 只有点击链接能被 JS 阻止 | preventDefault 是唯一手段 |
| 历史栈记录类型 | 真实导航 vs pushState | 决定后退时走哪条分支 |
| Hash 天然安全 | # 不参与 HTTP 请求 | 五种场景全部安全 |
| History 有风险 | 输入URL/F5 会请求服务器 | 必须配服务端 fallback |
技术层(本篇核心)
| API/概念 | 一句话记忆 |
|---|---|
| Hash 模式 | hashchange 事件驱动,URL 带 #,兼容性最好,只需一个事件监听 |
| History 模式 | pushState 改 URL + popstate 监听后退,URL 干净需服务端配合,需分主动/被动两条路 |
pushState vs popstate | 前者主动推(API),后者被动响应(Event);前者不触发后者 |
history.back() | 是方法不是事件,它触发 popstate 事件 |
scrollBehavior | savedPosition 只在浏览器前进/后退时可用,自动包含滚动位置 |
| Abstract 模式 | 不依赖浏览器 API,适用于 SSR / 测试 / 非浏览器环境 |
| SPA内回退 vs 跨页回退 | 目标为 pushState 记录则 Vue Router 接管;目标为真实导航记录则整页加载 |
🔑 最终口诀:点击可拦,后退不可拦;Hash 不请求,History 要配合。
下一篇预告:搞清楚了路由的"物理层"(URL 怎么变 + 浏览器怎么判断),下一篇我们进入 Vue Router 的"交通管制系统"——路由守卫。五种守卫的执行顺序是什么?next() 怎么用才不会死循环?导航过程中还能做数据预取吗?这些都是我在面试中踩过的坑。
👉 Vue 路由系列 02:路由守卫与死循环陷阱 —— 守卫执行全流程 + 数据预取模式 + Navigation Failure 判别
参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、MDN History API 文档、Vue Router 4 官方文档