1、Hash 路由和 History 路由对缓存的影响是什么?
核心结论:路由模式本身不直接影响 HTTP 缓存,但决定了缓存失效的触发条件和粒度。
一、Hash 路由的缓存特征
bash
https://example.com/#/user/profile
| 维度 | 表现 |
|---|---|
| HTTP 请求 | # 后的内容不会 发送到服务端,每次路由切换只发 GET /,服务端永远看到的是同一个 URL |
| 缓存行为 | 整站是一个 HTML 入口,一损俱损------HTML 缓存失效时全站刷新 |
| 版本控制 | 通常靠构建时给 index.html 加 hash(如 app.abc123.js),HTML 本身无 hash |
| 典型问题 | 发版后用户访问的还是旧 HTML,引用的旧 JS/CSS 可能已被 CDN 清理,导致 404 |
缓存失效难点
html
<!-- 发版前 -->
<script src="/app.abc123.js"></script>
<!-- 发版后,用户浏览器缓存了旧的 index.html,还在请求 app.abc123.js -->
<!-- 但 CDN 上只有 app.def456.js,直接白屏 -->
解决 :必须确保 index.html 永不缓存 或极短缓存 (Cache-Control: no-cache),让浏览器每次重新获取 HTML 拿到最新资源映射。
二、History 路由的缓存特征
arduino
https://example.com/user/profile
| 维度 | 表现 |
|---|---|
| HTTP 请求 | 每个路由路径独立请求(首次访问 /user/profile 会发这个请求到服务端) |
| 服务端配合 | 需要配置回退到 index.html (Nginx try_files 或服务端路由) |
| 缓存行为 | 可以按路由粒度配置缓存策略,但通常仍回退到同一个 HTML |
| SEO/首屏 | 服务端可针对不同路径做差异化渲染(SSR/SSG) |
缓存配置更灵活
nginx
# Nginx 示例:API 不缓存,HTML 不缓存,静态资源长期缓存
location /api/ {
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache"; # HTML 始终重新验证
}
location ~* \.(js|css|png|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
三、关键差异对比
| 场景 | Hash 路由 | History 路由 |
|---|---|---|
| HTML 缓存策略 | 必须禁止缓存,否则发版后白屏风险极高 | 同样需要禁止 HTML 缓存,但可按路径做 SSR 差异化 |
| CDN/边缘缓存 | 整站一个 URL,无法针对特定路由预热 | 可预热特定路径(如 /product/detail) |
| 服务端感知路由 | 不能,服务端永远只看到 / |
能,可做路径级别的缓存头、SSR、权限校验 |
| 资源 404 风险 | 高(旧 HTML 指向已删除的旧 hash 文件) | 同样存在,但可通过服务端路由兜底 |
| 多页应用迁移 | 天然单页,无迁移成本 | 需确保旧书签/外链能正确回退 |
四、对实际开发的影响
1. 静态资源命名(两者通用)
无论哪种路由,必须给 JS/CSS 加 content-hash:
js
// vite/webpack 配置
output: {
filename: 'js/[name]-[contenthash:8].js'
}
这样 app.js 内容不变则 URL 不变(长期缓存),内容变了 URL 变(强制更新)。
2. HTML 的缓存策略(核心)
http
# 这是最关键的响应头,两种路由模式都需要
Cache-Control: no-cache # 或 no-store,确保每次重新验证
如果 HTML 被强缓存,Hash 和 History 都会发版后用户看不到更新。
3. History 路由的特殊陷阱
nginx
# 错误配置:把 /user/profile 缓存了 1 小时
location /user/ {
expires 1h; # 灾难!用户 1 小时内看不到该路由的更新
}
必须确保所有非文件路径都回退到 index.html 且不缓存。
五、一句话总结
路由模式决定的是 URL 形态和服务端参与度,不直接改变浏览器缓存机制。真正决定缓存行为的是 HTTP 响应头(
Cache-Control、ETag)和资源命名策略。Hash 路由因为服务端无感知,对 HTML 缓存更敏感;History 路由因为服务端可介入,缓存策略可以做得更精细,但也更容易配错。
2、为什么#后的内容不会发送给服务器?
这是 HTTP 协议的规定,# 及之后的内容属于「片段标识符」(Fragment Identifier),设计初衷是定位文档内部位置,不是请求的一部分。
一、协议层面的规定
RFC 3986 对 URI 结构的定义
markdown
https://example.com/path?query=123#section2
\_____________________/\___________/\______/
| | |
请求部分 查询参数 片段标识符
(发给服务端) (发给服务端) (浏览器自用)
- Scheme (
https://) → 协议 - Authority (
example.com) → 主机 - Path (
/path) → 资源路径 - Query (
?query=123) → 查询参数,会发送 - Fragment (
#section2) → 片段,不发送
为什么这样设计?
片段标识符的原始用途是锚点定位:
html
<!-- 点击跳转到页面内 id="chapter3" 的元素 -->
<a href="https://example.com/doc.html#chapter3">第三章</a>
如果 #chapter3 发给服务端,服务端拿到它没有任何意义------它不知道页面里哪个元素叫 chapter3,这是文档结构层面的信息,只有浏览器在解析 HTML 后才能处理。
二、实际验证
浏览器行为
javascript
// 你在地址栏输入
location.href = 'https://api.example.com/data#hashvalue'
// 实际发出的 HTTP 请求
GET /data HTTP/1.1 ← 注意:没有 #hashvalue
Host: api.example.com
服务端永远看不到
javascript
// Node.js Express
app.get('/data', (req, res) => {
console.log(req.url); // 输出: "/data"
console.log(req.originalUrl); // 输出: "/data"
// req 对象里没有任何地方能拿到 #hashvalue
})
浏览器内部处理流程
bash
用户输入/点击: https://example.com/page#section
│
▼
解析 URL
│
├── 提取请求部分: https://example.com/page
│ │
│ ▼
│ 发送 HTTP 请求到服务端
│ │
│ ▼
│ 接收响应 (HTML/JSON/...)
│
└── 提取片段: #section
│
▼
浏览器本地处理
│
├── 页面已加载?→ 滚动到 id="section" 的元素
│
└── 页面未加载?→ 等页面渲染完成后滚动
三、SPA 路由的「副作用」
Hash 路由(如 Vue Router 的 createWebHashHistory)利用了这个协议特性:
javascript
// 浏览器地址栏
https://app.example.com/#/user/profile
// 实际 HTTP 请求(每次路由切换)
GET / HTTP/1.1 ← 永远是根路径,服务端无感知
Host: app.example.com
// 路由变化由浏览器本地处理
window.addEventListener('hashchange', () => {
const route = location.hash.slice(1) // "/user/profile"
// 前端自己渲染对应组件,不发新请求
})
好处 :不需要服务端配合,任何静态服务器都能部署。
代价 :SEO 不友好(搜索引擎通常忽略 # 后内容),URL 带 # 不够美观。
四、对比 History 路由
javascript
// History 路由(createWebHistory)
https://app.example.com/user/profile
// 路由切换时
history.pushState({}, '', '/user/profile') // 浏览器地址变了
// 但默认不会发请求!需要前端拦截处理
// 用户刷新页面或首次访问时
GET /user/profile HTTP/1.1 ← 这个会发给服务端!
这里 /user/profile 是路径部分 ,不是片段,所以必须服务端配置回退:
nginx
# 否则服务端返回 404,因为磁盘上根本没有 /user/profile/index.html
location / {
try_files $uri $uri/ /index.html; # 所有路径都回退到 index.html
}
五、一句话总结
#是浏览器和文档之间的「私有约定」,从 HTTP 协议诞生起就不是请求的一部分。SPA 的 Hash 路由是「借用」这个特性实现前端路由,而 History 路由则是通过history.pushState伪造路径,需要服务端配合兜底。`
3、查询参数,为什么可以发送给服务端?它改变了路由了吗?
查询参数(?key=value)没有改变路由 (Path 没变),但它是 HTTP 协议规定的请求组成部分,所以必须发送给服务端。
一、协议层面的区别
bash
https://example.com/user?id=123#section
\____/ \______/ \______/
│ │ │
Path Query Fragment
│ │ │
资源路径 参数 锚点
│ │ │
会发送 会发送 不发送
| 部分 | 协议角色 | 发送? |
|---|---|---|
Path (/user) |
标识「哪个资源」 | ✅ 是 |
Query (?id=123) |
资源的「附加条件」 | ✅ 是 |
Fragment (#section) |
文档「内部位置」 | ❌ 否 |
二、为什么 Query 必须发送?
1. 它是资源定位的一部分
bash
GET /user?id=123 HTTP/1.1
服务端收到这个请求时,Path 和 Query 共同决定返回什么:
javascript
// Express 示例
app.get('/user', (req, res) => {
const id = req.query.id // "123"
// 根据 id 查询数据库,返回不同用户数据
res.json(await db.user.findById(id))
})
没有 Query,服务端无法区分:
/user?id=123→ 返回用户 123 的资料/user?id=456→ 返回用户 456 的资料
2. 协议设计如此(RFC 3986)
Query 是 "hierarchical data" (层级数据),用于缩小资源范围 。
Fragment 是 "secondary resource" (次级资源),用于客户端内部定位。
简单说:Query 影响「服务端返回什么」,Fragment 影响「浏览器怎么看」。
三、Query 改变路由了吗?
没有。 路由(Path)还是 /user,Query 只是给这个路由附加了条件:
| 地址 | Path(路由) | Query | 是否同一页面 |
|---|---|---|---|
/user?id=123 |
/user |
id=123 |
✅ 是 |
/user?id=456 |
/user |
id=456 |
✅ 是 |
/user/123 |
/user/123 |
无 | ❌ 不是(RESTful 路径参数) |
Query vs 路径参数的区别
javascript
// Query 方式(条件过滤)
GET /user?id=123 → 在 /user 这个集合里筛选 id=123 的
// 路径参数方式(资源定位)
GET /user/123 → 直接定位到 id=123 这个资源
两者服务端都能收到,但语义不同:
- Query :可选条件、筛选、搜索(
?page=2&keyword=vue) - Path 参数 :资源唯一标识(
/user/123)
四、实际场景对比
| 场景 | 地址 | 服务端收到 | 用途 |
|---|---|---|---|
| 分页 | /list?page=2 |
/list?page=2 |
服务端按页码查询 |
| 搜索 | /search?q=vue |
/search?q=vue |
服务端按关键词检索 |
| 筛选 | /products?category=phone |
同上 | 服务端按分类过滤 |
| 锚点定位 | /doc#chapter3 |
/doc |
服务端只返回文档,浏览器自己滚动 |
五、一句话总结
Query(
?后)是 HTTP 请求的一部分,因为它影响「服务端返回什么内容」;Fragment(#后)不是,因为它只影响「浏览器怎么展示内容」。Query 不改变路由(Path),只是给同一路由附加了查询条件。
4、?后面的内容变化,会使得浏览器重新发起请求吗?
会,但取决于具体变化方式和浏览器/框架的处理。
一、直接答案
| 操作 | 是否重新请求 | 原因 |
|---|---|---|
| 地址栏直接修改 Query 后回车 | ✅ 会 | 完整导航,浏览器重新加载页面 |
<a href="?page=2"> 点击 |
✅ 会 | 同上,标准导航行为 |
history.pushState({page:2}, '', '?page=2') |
❌ 不会 | 仅修改地址栏,不发请求 |
| SPA 框架路由切换(如 Vue Router) | ❌ 不会 | 框架拦截,前端本地处理 |
二、核心区分:浏览器导航 vs 地址栏伪装
场景 1:浏览器认为「页面变了」→ 重新请求
ini
当前: https://example.com/list?page=1
用户: 手动改成 ?page=2 并回车
或点击 <a href="?page=2">
浏览器行为:
1. 解析新 URL
2. 发现 Query 变了 → 判定为不同资源
3. 发送 GET /list?page=2
4. 重新加载页面
浏览器判定依据:完整 URL(含 Query)变了 = 新资源。
场景 2:JS 伪装地址 → 不请求
javascript
// 纯修改地址栏,不触发导航
history.pushState({page: 2}, '', '?page=2')
// 浏览器行为:
// - 地址栏显示 ?page=2
// - 不发送任何 HTTP 请求
// - 页面不刷新
// - 但会产生一条新历史记录
SPA 路由的原理就是这个 :用 pushState 骗过地址栏,实际前端自己处理。
三、Query 变化 vs Hash 变化的对比
| 操作 | Query 变化 | Hash 变化 |
|---|---|---|
| 直接修改地址栏回车 | ✅ 重新请求 | ❌ 不请求(页面内锚点跳转) |
pushState 修改 |
❌ 不请求 | ❌ 不请求 |
<a href="..."> 点击 |
✅ 重新请求 | ❌ 不请求(触发 hashchange) |
| 浏览器前进/后退 | 按历史记录决定 | 按历史记录决定 |
关键差异:
- Hash 变化 :浏览器天生不请求 (协议规定
#不发送) - Query 变化 :浏览器默认会请求,除非被 JS 拦截
四、SPA 框架如何处理 Query?
Vue Router 示例
javascript
// 当前: /list?page=1
router.push({ query: { page: 2 } }) // 或点击 <router-link :to="{ query: { page: 2 } }">
// 框架内部行为:
// 1. 调用 history.pushState(null, '', '?page=2') ← 不请求
// 2. 触发路由守卫 + 组件重新渲染
// 3. 组件内通过 useRoute().query.page 获取新值
// 4. 你可能在 watch 里发 API 请求获取新数据
注意 :框架不发页面请求,但你的代码通常会发 API 请求:
vue
<script setup>
const route = useRoute()
watch(() => route.query.page, (newPage) => {
// 这里你自己调 API
fetchData(newPage)
})
</script>
五、浏览器缓存的影响
即使 Query 变化导致重新请求,缓存策略仍然生效:
http
GET /list?page=1 → 200,缓存
GET /list?page=2 → 200,新请求(URL 不同,缓存不命中)
GET /list?page=1 → 304 或 from disk cache(URL 相同,缓存命中)
Query 是 URL 的一部分 ,所以 ?page=1 和 ?page=2 是两个独立的缓存条目。
六、一句话总结
Query 变化默认会触发浏览器重新请求,因为完整 URL 变了。但 SPA 通过
history.pushState拦截了这个行为,只改地址栏不发请求,由前端自己处理数据更新。
5、这个问题的根源是什么?本质是啥?
这个问题的根源在于浏览器对 URL 变化的「导航判定」机制。
一、本质:浏览器的「导航」vs「状态」
bash
┌─────────────────────────────────────────┐
│ URL 结构 │
│ https://host/path?query#fragment │
│ │ │ │ │
│ │ │ └─ 浏览器私用 │
│ │ │ (不发送) │
│ │ │ │
│ │ └─ 查询条件 │
│ │ (发送,影响服务端) │
│ │ │
│ └─ 资源路径 │
│ (发送,标识资源) │
└─────────────────────────────────────────┘
浏览器只认一个原则:
「导航」= 地址栏的 URL 变了,且不是 JS 伪装的 → 重新请求资源
二、核心矛盾
| 层面 | 设计初衷 | 被 SPA 借用后的冲突 |
|---|---|---|
| HTTP 协议 | Query 是请求的一部分,变化 = 新资源 | SPA 想把它当「前端状态」用 |
| 浏览器导航 | URL 变化 = 页面跳转 | SPA 想做到「无刷新更新」 |
| 历史记录 | 记录用户访问过的「页面」 | SPA 想记录「应用状态」 |
SPA 的解决方案 :history.pushState ------ 欺骗浏览器。
javascript
// 浏览器以为:用户在同一个页面内操作(不导航)
// 实际上:前端偷偷改了地址,自己处理一切
history.pushState({}, '', '?page=2')
三、一句话本质
Query 属于 HTTP 请求,变化触发重新请求是浏览器的「默认导航行为」;SPA 用
pushState拦截了这个行为,把 Query 降级为「前端状态管理工具」,这是「协议设计」与「应用需求」之间的张力。
6、history.pushState({}, '', '?page=2') 这会触发重新向服务器获取资源吗?
不会。 pushState 只改地址栏,不发任何请求。
一、直接验证
javascript
// 执行这行代码
history.pushState({}, '', '?page=2')
// 浏览器行为:
// ✅ 地址栏变成 ?page=2
// ✅ 产生一条新历史记录(可前进/后退)
// ❌ 不发送 HTTP 请求
// ❌ 不重新加载页面
// ❌ 不触发 popstate 事件(前进/后退才触发)
二、与真正导航的对比
| 操作 | 地址栏变化 | 发请求? | 页面刷新? | 历史记录? |
|---|---|---|---|---|
pushState('?page=2') |
✅ | ❌ 否 | ❌ 否 | ✅ 新增 |
location.href = '?page=2' |
✅ | ✅ 是 | ✅ 是 | ✅ 新增 |
location.replace('?page=2') |
✅ | ✅ 是 | ✅ 是 | ❌ 替换 |
点击 <a href="?page=2"> |
✅ | ✅ 是 | ✅ 是 | ✅ 新增 |
| 浏览器前进/后退 | ✅ | 按历史 | 按历史 | --- |
三、SPA 路由的核心原理
javascript
// Vue Router / React Router 内部大致逻辑:
function navigateTo(query) {
// 1. 骗过浏览器:只改地址,不请求
history.pushState({ query }, '', '?' + new URLSearchParams(query))
// 2. 前端自己处理:匹配路由、渲染组件、发 API
const matchedRoute = matchRoute(location.pathname)
render(matchedRoute.component)
fetchData(query) // 你自己调接口取数据
}
关键 :pushState 让浏览器「以为」还在当前页,SPA 趁机接管一切。
四、注意一个细节
pushState 本身不触发 popstate,但前进/后退会:
javascript
// 用户点击后退按钮
window.addEventListener('popstate', (e) => {
// 这里能拿到 pushState 时传的第一个参数
console.log(e.state) // { query: ... }
// 前端自己恢复对应状态
})
五、一句话总结
pushState是浏览器的「地址栏伪装 API」,只操作历史记录和地址显示,完全不涉及网络请求。SPA 路由的无刷新体验,全靠它「欺骗」浏览器实现的。
7、是不是说history路由不会触发新的请求到服务器?
不是。History 路由本身不会触发请求,但用户刷新或直接访问时会触发。
一、区分两个概念
| 场景 | 是否发请求 | 原因 |
|---|---|---|
前端 pushState 切换路由 |
❌ 不会 | JS 只改地址栏,不发请求 |
| 用户按 F5 刷新页面 | ✅ 会 | 浏览器重新加载当前 URL |
| 用户直接输入地址访问 | ✅ 会 | 浏览器发起全新导航 |
| 点击外部链接进入 | ✅ 会 | 同上 |
二、History 路由的完整流程
sql
用户点击 <router-link to="/user/123">
│
▼
Vue Router 拦截点击
│
▼
history.pushState(null, '', '/user/123')
│
├── 地址栏变成 /user/123
├── 新增历史记录
├── ❌ 不发 HTTP 请求
│
▼
前端匹配路由 → 渲染 User 组件
│
▼
组件内调 API: fetch('/api/user/123') ← 这个请求是你自己发的
注意 :pushState 那一步确实没请求,但组件加载数据时的 fetch 是真实请求。
三、服务端必须配合的原因
当用户刷新 或直接访问 https://app.com/user/123 时:
bash
浏览器发送: GET /user/123 HTTP/1.1
服务端收到这个请求,但磁盘上根本没有 /user/123/index.html
│
▼
必须配置回退:
Nginx: try_files $uri $uri/ /index.html
Node: app.get('*', (req, res) => res.sendFile('index.html'))
│
▼
返回 index.html → 浏览器加载 SPA → 前端路由接管 → 渲染 User 组件
如果没有回退配置:服务端返回 404,SPA 根本加载不起来。
四、Hash 路由 vs History 路由对比
| 场景 | Hash 路由 (/#/user/123) |
History 路由 (/user/123) |
|---|---|---|
pushState/hashchange 切换 |
❌ 不发请求 | ❌ 不发请求 |
| 用户刷新页面 | ❌ 不发请求(服务端只看到 /) |
✅ 会发请求 (服务端看到 /user/123) |
| 需要服务端配置 | 不需要 | 必须配置回退 |
| 为什么能直接用 | # 后内容不发送,服务端无感知 |
路径变化服务端能感知,必须配合 |
五、一句话纠正
History 路由的「前端切换」不会触发请求(靠
pushState),但「用户刷新或直接访问」会触发请求(这是标准浏览器导航)。所以 History 路由必须服务端配置回退到index.html,否则刷新就 404。