【Vue 路由系列 00】序章:浏览器的导航系统——前端路由的"积木底座"
很多人学前端路由,一上来就背
pushState、hashchange、popstate这些 API,但从来没搞清楚一个更基础的问题:浏览器自己的导航系统是怎么工作的?这篇是整个系列的序章(底座篇)。它不涉及任何框架,不讨论 Vue Router 的 API,只做一件事:彻底讲清楚浏览器内部的导航系统是如何运转的。读完这篇,你就理解了前端路由到底在"干预"什么——之后不管是 Vue Router 还是 React Router,原理都是同一套。
建议阅读顺序:如果你已经理解浏览器导航的基本机制,可以直接跳到 01 篇。如果不确定自己是否真的懂,花 15 分钟读完这篇——它会节省你后面无数个"为什么"。
一、什么叫做"路由"?
先别管前端路由。我们先搞清楚"路由"这个词本身是什么意思。
1.1 传统 Web 的路由:服务端的活儿
在还没有 JavaScript 框架的时代,"路由"完全是服务端的事:
浏览器输入 example.com/users 并回车
↓
浏览器向服务器发送 HTTP GET /users 请求
↓
【服务端路由】服务器根据 URL 路径判断:
/ → 返回首页 HTML
/users → 返回用户列表 HTML
/about → 返回关于我们 HTML
/settings → 返回设置页 HTML
↓
浏览器拿到 HTML → 解析 → 渲染出完整页面
核心特征:每个 URL 对应一份不同的 HTML 页面,由服务器决定返回哪份。 用户每点一次链接、输一次地址,就是一次完整的"请求→响应→渲染"循环。
1.2 SPA 的路由:前端的活儿
单页应用(SPA)改变了这个规则。整个网站只有一份 HTML(index.html),页面内容的切换完全靠 JavaScript:
浏览器加载 example.com → 只拿到一份 index.html
↓
这份 HTML 里只有一个 <div id="app"></div>
↓
之后的操作全部由 JS 接管:
URL 变成 /users → JS 把 UserList 组件塞进 #app
URL 变成 /about → JS 把 About 组件塞进 #app
URL 变成 /settings → JS 把 Settings 组件塞进 #app
这就是"前端路由"的含义:由前端 JavaScript 来决定"当前 URL 应该渲染哪个组件",而不是由服务器返回不同的 HTML。
1.3 为什么需要前端路由?
| 传统多页应用的问题 | SPA 前端路由的解法 |
|---|---|
| 每次跳转都白屏(整页刷新) | 不刷新页面,只切换组件 |
| 服务端压力巨大(每次渲染完整 HTML) | HTML 只请求一次,后续只可能请求 JSON 数据 |
| 页面状态丢失(表单、滚动位置清零) | 用 keep-alive 等方案保持状态 |
| 无法做转场动画(没有过渡空间) | 组件级切换,可以做动画 |
到这里,问题来了——
1.4 核心问题:JS 怎么知道 URL 变了?
传统模式下,URL 变化是浏览器的行为(点击链接→发请求→拿HTML)。但在 SPA 里,我们需要 JS 能够感知 URL 的变化,然后根据变化来切换组件。
这就引出了本文最关键的议题:浏览器本身有一套导航系统,前端路由要做的就是在这套系统的各个环节上"动手脚"。
二、浏览器的导航系统:五种场景的完整行为
这是全文最重要的"积木底座"。彻底搞清楚浏览器在每个场景下做了什么,后面所有前端路由方案(Vue Router、React Router 等)就只是"在哪个环节做了什么改造"的区别。
2.1 五种触发导航的方式
用户在浏览器里有五种方式会触发"导航"(即 URL 变化和页面更新):
| 编号 | 触发方式 | 日常操作 |
|---|---|---|
| 场景① | 点击链接 | 点击 <a href="/about"> |
| 场景② | 浏览器后退 | 点击 ◀ 后退按钮 |
| 场景③ | 浏览器前进 | 点击 ▶ 前进按钮 |
| 场景④ | 输入 URL | 在地址栏输入地址后按回车 |
| 场景⑤ | 刷新页面 | 按 F5 或 Ctrl+R |
在没有前端框架的传统网页中,这五种场景下浏览器的行为是一致的:
任何一种场景触发 → 浏览器执行同样的 4 步流程:
Step 1: 操作历史栈(推入/弹出/移动指针)
Step 2: 改变 URL 栏的显示
Step 3: 向服务器发送 HTTP GET 请求
Step 4: 拿到 HTML → 销毁旧页面 → 渲染新页面
这 4 步是浏览器内核硬编码的,JavaScript 无法阻止它们执行。前端路由能做的只是在某些步骤前后"插入自己的逻辑"。
2.2 浏览器历史栈(History Stack)
先理解浏览器怎么记录"你去过哪些页面":
历史栈结构(后进先出):
栈底 [0] example.com/ ← 最初访问的页面
[1] example.com/users ← 点了"用户列表"链接
[2] example.com/about ← 点了"关于我们"链接 ← 栈顶(当前位置)
当前指针指向 [2]
前进/后退 = 移动栈指针:
点后退 ◀:指针从 [2] 移到 [1],浏览器展示 /users 的页面
再点后退:指针从 [1] 移到 [0],浏览器展示首页
点前进 ▶:指针从 [0] 移回 [1],又展示 /users
到这里都很直观。但有一个极其重要、却几乎从未被讲清楚的细节:
⚠️ 历史栈中的记录是有"类型"之分的
不是所有历史栈记录都是同一种东西。 栈里的每条记录,根据它是怎么进来的,携带了不同的隐式标记:
| 记录类型 | 怎么进栈的 | 进栈时发生了什么 | 后退时的行为 |
|---|---|---|---|
| 真实导航记录 | 用户点击 <a> 链接(JS 未拦截)、或在地址栏输入 URL 回车 | 浏览器自动推入 → 发送 HTTP 请求 → 加载完整页面 | 重新发起 HTTP 请求 → 整页加载 |
| pushState 记录 | JS 调用 history.pushState() 手动推入 | JS 推入 → 不发请求 → 不刷新页面 | 不发请求 → 触发 popstate 事件 → 通知 JS 处理 |
用一张图表达这个关系:
浏览器的后退判断逻辑(内核级):
用户点了后退按钮
↓
弹出目标记录,检查它的类型
↓
┌─────────────────────────────────────┐
│ 是 pushState 记录吗? │
│ │
│ YES → 这条是 JS 塞进来的"假记录" │
│ → 不存在对应的服务端页面资源 │
│ → 不可能走 HTTP 加载流程 │
│ → 触发 popstate 通知 JS │
│ → 【放权给前端路由接管】 │
│ │
│ NO → 这是真实的页面导航记录 │
│ → 对应一个服务端返回的 HTML 页面 │
│ → 发起 HTTP GET 请求 │
│ → 整页加载渲染 │
│ → 【走传统的老路线】 │
└─────────────────────────────────────┘
这就是 History 模式能工作的根本原因——
pushState的本质就是往历史栈里塞一条"特殊标记"的记录,让浏览器在后退到它时知道:"这条别当真,问 JS 就行"。如果没有这套机制,SPA 根本不可能实现"后退不刷新"。
同一个历史栈里可以混合存在两种记录。比如用户从百度搜索进入你的 SPA 网站,历史栈可能是这样的:
[0] baidu.com/s?wd=vue ← 真实导航记录(百度的搜索结果页)
[1] example.com/ ← 真实导航记录(首次访问 SPA,服务器返回 index.html)
[2] example.com/home ← pushState 记录(Vue Router 在 SPA 内部推入)
[3] example.com/about ← pushState 记录(同上)← 当前位置
从 [3] 退到 [2] → pushState 记录 → popstate → Vue Router 接管 ✓
从 [2] 退到 [1] → 真实导航记录 → HTTP 请求加载 index.html(整页刷新!)
从 [1] 退到 [0] → 不同源 → 整页跳转回百度
这个区分是理解后续所有行为的基础。记住它,后面五种场景的每一种都能解释通了。
2.3 五种场景的逐一拆解
现在我们把五种场景逐一拆开看,标注每一步浏览器做了什么,以及 JS 有没有机会干预:
场景①:用户点击 <a> 链接
用户点击 <a href="/about">关于我们</a>
│
▼
┌─────────────────────────────────────────────┐
│ 浏览器准备执行的 4 步: │
│ │
│ ① 将 /about 推入历史栈 │
│ ② URL 栏变成 example.com/about │
│ ③ 向服务器发送 GET /about │
│ ④ 拿到 HTML → 销毁当前页 → 渲染新页 │
│ │
│ ⚠️ 但是!第 ① 步之前有一个"决策点": │
│ 如果 JS 调用了 e.preventDefault() │
│ → 浏览器取消整个 4 步流程! │
│ → 后面的所有步骤都不会执行 │
└─────────────────────────────────────────────┘
│
▼ (如果不拦截,走完 4 步 → 整页跳转)
▼ (如果 preventDefault → 4 步全取消 → JS 接管)
│
│ JS 可以做的事:
│ ├─ 自己调用 history.pushState() 来推栈+变URL(但不触发第③步请求)
│ └─ 或者设置 location.hash = '#/about'(只变#后面部分)
│
▼
JS 自己决定渲染什么组件
关键点:这是唯一可以被 JS 完全阻止的场景。preventDefault 是前端路由的起点。
场景②③:用户点击后退/前进按钮
用户点击浏览器 ◀ 后退按钮(前进按钮逻辑相同,方向相反)
│
▼
┌─────────────────────────────────────────────┐
│ 浏览器的前两步(不受 JS 控制,一定执行): │
│ │
│ ① 弹出历史栈的一条记录 ✓ │
│ ② URL 栏变回目标地址 ✓ │
│ │
│ 第 ③ 步:浏览器检查目标记录的类型 │
│ (参见 2.2 节的"记录类型之分") │
│ │
│ ┌─ 如果是 pushState 记录(SPA 内回退): │
│ │ → 不发送 HTTP 请求! │
│ │ → 触发 popstate 事件通知 JS │
│ │ → JS 接管渲染 │
│ │ │
│ ├─ 如果是真实导航记录(同源整页回退): │
│ │ → 发送 HTTP GET 请求 │
│ │ → 整页加载渲染 │
│ │ │
│ └─ 如果目标不同源(跨站回退): │
│ → 完全的页面导航 │
│ → 整页跳转 │
│ │
│ ⚠️ 注意:无论哪种情况,①和②都已经执行完了! │
│ JS 无法阻止浏览器弹栈和变 URL │
└─────────────────────────────────────────────┘
│
▼ (仅 pushState 记录时才有此步骤)
JS 通过监听 popstate 事件来感知"后退发生了"
│
▼
JS 读取当前 URL → 匹配路由 → 渲染对应组件
关键点:后退/前进无法被阻止。JS 能做的只是事后感知——但前提是目标记录必须是 pushState 类型的。如果是真实导航记录,JS 连感知的机会都没有(因为直接整页加载了,当前页面已经被销毁)。
💡 为什么 Hash 模式不需要区分这些? 因为 Hash 模式中,
#后面的变化在浏览器看来永远不构成新页面——不管怎么后退前进,浏览器都不会发 HTTP 请求,只会触发hashchange。所以 Hash 模式的后退处理比 History 简单得多:不用判断记录类型,反正都不会请求服务器。
场景④:用户在地址栏输入 URL 并回车
用户在地址栏输入 example.com/about 并按回车
│
▼
┌─────────────────────────────────────────────┐
│ 这等同于发起一次全新的页面导航: │
│ │
│ ① 清空历史栈(或在其上推入新记录) │
│ ② URL 栏变成输入的地址 │
│ ③ 向服务器发送 HTTP GET 请求 │
│ ④ 拿到 HTML → 渲染页面 │
│ │
│ ⚠️ 这整个过程 JS 完全无法干预! │
│ 没有 preventDefault 可调用 │
│ 因为这不是"点击事件" │
└─────────────────────────────────────────────┘
关键点:这是 History 模式的死穴。 用户直接输入 URL 回车 = 一次全新的、不可拦截的服务器请求。如果服务器上没有 /about 这个文件 → 404。(Hash 模式不受影响,因为不管输什么都只请求 #/ 前面的部分。)
场景⑤:用户按 F5 刷新
用户按 F5(或 Ctrl+R)刷新
│
▼
┌─────────────────────────────────────────────┐
│ 浏览器以当前 URL 栏的地址重新发起请求: │
│ │
│ 如果当前是 example.com/#/about(Hash 模式) │
│ → 请求的是 example.com/ │
│ → hash 部分被丢弃,不发往服务器 │
│ → 服务器返回 index.html ✓ │
│ │
│ 如果当前是 example.com/about(History 模式) │
│ → 请求的就是 example.com/about │
│ → 服务器必须返回 index.html(fallback 配置) │
│ → 否则 404 💀 │
│ │
│ ⚠️ 同样,JS 无法拦截刷新操作! │
└─────────────────────────────────────────────┘
关键点:和场景④一样,F5 刷新是不可拦截的。这就是为什么 History 模式需要服务端配合——必须在服务端配置"所有路径都返回 index.html"。
2.4 五种场景汇总表
把上面的分析压缩成一张表。如果你只记住一张表,就记这张:
| 场景 | 触发方式 | 浏览器能否被 JS 阻止? | JS 如何感知? | Hash 模式是否安全? | History 模式是否安全? |
|---|---|---|---|---|---|
| ① 点击链接 | <a> 点击 | ✅ 可以 (preventDefault) | 不需要感知,主动控制 | ✅ 安全 | ✅ 安全 |
| ② 后退按钮 | ◀ 点击 | ❌ 不能 | 见下方详细说明 ⬇️ | ✅ 安全(不发请求) | ⚠️ 有条件安全(仅 SPA 内部) |
| ③ 前进按钮 | ▶ 点击 | ❌ 不能 | 同左 ⬅️ | ✅ 安全(不发请求) | ⚠️ 有条件安全(仅 SPA 内部) |
| ④ 输入 URL | 地址栏回车 | ❌ 不能 | 无法感知(已跳走) | ✅ 安全(只请求 # 前) | ❌ 404 风险 |
| ⑤ 刷新 | F5 / Ctrl+R | ❌ 不能 | 无法感知(已跳走) | ✅ 安全 | ❌ 404 风险 |
📌 为什么场景②③的"JS 如何感知"不能简单写"hashchange / popstate"?
因为这两种模式的感知机制有本质差异,不是同一个事件换了个名字:
Hash 模式:后退/前进 → 浏览器 一定 触发
hashchange(因为#后变化永远不算新页面)→ JS 一定能感知到History 模式(后退/前进)(根据 HTML 标准 和 MDN 文档 的定义):
- 只有使用
pushState()或replaceState()添加的历史记录条目,在被遍历(后退/前进)时才会触发popstate事件 → JS 收到事件并可通过e.state获取之前存储的状态 ✓- 真实导航记录(跨文档跳转)在被遍历时 不会触发 popstate → 浏览器直接发起 HTTP 请求重新加载页面 → JS 完全无法感知 💥
⚠️ 关键理解:这不是 "浏览器先决定派发 popstate,然后发现是真实导航就取消了",而是 浏览器内核在遍历历史栈时,会在两条完全不同的处理路径中二选一:
- 路径 A:目标为 pushState 记录 → 走"反序列化状态 + 派发 popstate"路径
- 路径 B:目标为真实导航记录 → 走"HTTP 请求 + 新文档加载"路径
popstate只存在于路径 A 上。路径 B 根本不经过事件派发系统——popstate从一开始就没被创建过。所以 History 模式在场景②③下是有条件的——只有在
pushState()/replaceState()记录范围内后退/前进才能被 JS 接管。一旦越界(退到入口页或跨页跳转),JS 就彻底失联了。
从这张表中可以直接得出几个结论:
- JS 能控制的只有场景①(点击链接),其余四种都无法阻止
- Hash 模式在五种场景下全部安全(得益于
#不参与 HTTP 请求的特性) - History 模式的风险只在场景④和⑤(用户绕过 SPA 直接和服务器交互)
- 所以前端路由的核心任务就两件事:拦截场景① + 在场景②③中事后感知
2.5 浏览器给 JS 留的两个"把手"
总结一下浏览器给 JavaScript 留的"干预入口":
把手 A:preventDefault(拦截场景①的唯一手段)
// 唯一能阻止浏览器导航的方法
document.addEventListener('click', (e) => {
const link = e.target.closest('a[href]')
if (link && link.origin === location.origin) {
e.preventDefault() // ← 阻止浏览器执行那 4 步!
// 现在你可以自己决定干什么了
}
})
把手 B:事件监听(感知场景②③的后退/前进)
⚠️ 这是两种模式感知机制差异最大的地方,必须分开展开:
// ═══════════════════════════════════════
// Hash 模式的感知方式(简单、无脑、无条件)
// ═══════════════════════════════════════
// 不管后退还是前进,浏览器一定会触发 hashchange
// 原因:# 后面的变化在浏览器看来永远不算"新页面导航"
// 所以后退/前进的处理逻辑里一定包含"检查 hash 是否变化 → 触发 hashchange"这一步
window.addEventListener('hashchange', () => {
// 触发时机:用户点后退 / 点前进 / JS 手动修改 location.hash
// 触发条件:无条件——只要 hash 部分变了就触发
console.log('hash 变了:', location.hash)
// → JS 拿到新 hash → 匹配路由 → 渲染组件
})
// ⚠️ **重要限制(MDN 标准规定)**:
// 如果使用 `history.pushState()` 或 `history.replaceState()` 修改 URL 的 hash 部分
// → 不触发 hashchange 事件!
// 这意味着 Hash 路由的实现必须用 `location.hash = '#/home'` 方式改 hash
// 而不是 `history.pushState(null, '', '#/home')`
// 参考:https://developer.mozilla.org/en-US/docs/Web/API/Window/hashchange_event
// ════════════════════════════════════════════════
// History 模式的感知方式(有条件——且前进后退对称)
// ════════════════════════════════════════════════
// ⚠️ popstate 不是"监听所有历史栈变化"!
// 根据 HTML 标准和 MDN 文档,popstate 只在以下情况触发:
// - 用户点击浏览器后退/前进按钮(或调用 history.back()/history.forward())
// - **且** 目标历史记录条目是用 pushState() 或 replaceState() 添加的
// ↓
// 只有 pushState 记录在被遍历时才触发 popstate,真实导航记录走另一条处理路径
window.addEventListener('popstate', (e) => {
// 触发时机:用户点后退 或 点前进(两者完全对称)
// (前提:目标记录是用 pushState()/replaceState() 创建的)
// 触发条件:有条件!取决于历史栈中目标条目的类型
// 后退 (◀):历史栈指针前移 → 检查类型 → pushState? → 是 → 派发 popstate ✓
// 前进 (▶):历史栈指针后移 → 检查类型 → pushState? → 是 → 派发 popstate ✓
// (前进与后退的检查逻辑完全相同,标准规定两者对称处理)
// 🕒 注意:popstate 是异步触发的,可能在当前任务队列清空后才执行
// 所以如果需要在文档完全就绪后执行路由逻辑,可以考虑用 setTimeout 包裹:
// setTimeout(() => { matchRoute(location.pathname) }, 0)
console.log('当前 URL:', location.pathname)
console.log('pushState 时存的状态:', e.state)
// → JS 拿到 pathname → 匹配路由 → 渲染组件
})
// ⚠️ 如果用户后退/前进的目标是"真实导航记录"(入口页或跨页跳转):
// 浏览器走的是另一条处理路径(HTTP 请求加载新文档)
// → 这条路径根本不经过事件派发系统
// → popstate 事件从未被创建(不是"被忽略",是根本不存在于那条路径上)
// → 监听器永远不会收到回调
// → 浏览器发起 HTTP 请求 → 整页加载 → 当前页面被销毁
// → JS 彻底失联
// 📚 参考标准:
// - HTML Standard: https://html.spec.whatwg.org/multipage/browsing-the-web.html#event-popstate
// - MDN: https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
两种感知机制的本质区别:
Hash 模式 (hashchange) | History 模式 (popstate) | |
|---|---|---|
| 触发条件 | 无条件——后退/前进一定触发(hash 变化就触) | 有条件——只有浏览器底层走到 pushState 分支时才派发 |
| 为什么 | # 后变化永远不算页面导航 → 浏览器处理逻辑里一定包含 hashchange 派发步骤 | HTML 标准将历史记录条目分为两类: 1. pushState 记录 → 遍历时触发 popstate(通过事件系统)2. 真实导航记录 → 遍历时 HTTP 加载(不经过事件系统) |
| 后退到入口页时 | ✅ 仍触发 hashchange,JS 可接管 | ❌ 走真实导航分支 → HTTP 加载 → 整页刷新 → JS 失联(popstate 从未被创建) |
| 前进时 | ✅ 与后退完全相同,无条件触发 | ✅ 与后退完全对称(HTML 标准规定)——前进目标为 pushState 记录则派发 popstate;为真实导航记录则直接加载 |
| 跨页回退/前进时 | ✅ 仍触发(同源前提下) | ❌ 整页跳转,完全不经过事件系统 → JS 无法感知 |
| 可靠性 | 100% 可靠——在 SPA 内部永远不会失联 | 部分可靠——只在 pushState 记录范围内有效,越界即失联 |
💡 这就是为什么说 "Hash 模式比 History 模式更省心" —— 你不用关心历史栈里有什么类型的记录、用户会退到哪里/进到哪里,
hashchange永远会响。而popstate的触发取决于浏览器内核走了哪条分支——这条分支在代码层面你无法控制、也无法预测用户的历史栈组成。
三、本篇小结 & 下篇预告
核心概念速查表
| 概念 | 一句话记忆 |
|---|---|
| 什么是"路由" | "URL 对应什么内容"的映射关系。传统 Web 由服务器决定(返回不同 HTML),前端路由由 JS 决定(渲染不同组件) |
| 五种导航场景 | 点击链接 / 后退 / 前进 / 输入 URL / F5刷新 — 每种场景浏览器的行为都不同 |
| 核心分水岭 | JS 能阻止的只有点击链接(preventDefault),其余四种都无法拦截 |
| 历史栈记录类型 | 真实导航记录(浏览器自己推,后退时整页加载) vs pushState 记录(JS 推,后退时触发 popstate)——这是 History 模式能工作的根本原因 |
| Hash 天然安全 | # 后内容不发往服务器 → 五种场景都不会触发新的 HTTP 请求,后退也不需要判断记录类型 |
| History 有风险 | 输入 URL 和 F5 会向服务器发请求 → 必须配置服务端 fallback;SPA 内回退安全(popstate),退到入口页则整页加载 |
🔑 记忆口诀:点击可拦,后退不可拦;Hash 不请求,History 要配合。
本篇遗留的问题
读完本篇,你应该能回答这些问题:
- ✅ 浏览器有几种导航方式?
- ✅ 哪种能被 JS 拦截,哪种不能?
- ✅ 历史栈里的记录为什么有类型之分?
- ✅ 为什么 SPA 内部后退不会刷新页面,但跨页后退会?
但你可能还会有这些疑问:
- ❓
pushState具体怎么用?参数是什么? - ❓
popstate事件的event.state怎么取? - ❓ Vue Router 怎么封装这些原生 API?
- ❓ Hash 模式和 History 模式的代码实现有什么区别?
- ❓ 服务端 fallback 到底怎么配?
这些就是下一篇要解决的问题。
👉 Vue 路由系列 01:Hash 与 History 模式 —— 在本篇底座之上,正式进入技术层:
pushState/popstate/hashchange的 API 细节 + Vue Router 的内部封装 + 两种模式的完整代码实现 + 面试高频问题速查。
参考来源:MDN History API 文档、HTML5 Specification、浏览器内核文档