前端路由与 SPA
前端路由与 SPA
一、导读
| 定义: | 先分清「地址栏里是什么」与「屏幕上画什么」谁说了算,再谈 history、popstate、bfcache 与 SPA 常见坑。 |
| 代码块 | 含 // 语法要点、//正确示例、//错误示例;示例以原生 API 为主,框架是「同一套约束下的语法糖」。 |
| 啥时候用 | 每条对应「联调、SEO、回退、埋点」里会踩的线。 |
| 姊妹篇 | 《BOM》写过 history / location;《网络层》写过 URL 与 fetch;《异步》写过 popstate 与任务;《性能》写过 INP 与主线程——本篇把它们拧成一条路由线。 |
目标:能说清 hash 路由 vs History 路由 的语义差异;知道
history.pushState不会自动请求网络;能解释 「刷新 404」 的根因与 nginx 配置思路;知道 bfcache 对pageshow/ 副作用的影响。
边界:本文不写某一框架 Router 的 API 大全,只写所有框架都要服从的浏览器契约。
2 一张图看懂 SPA 导航的本质
┌─────────────────────────────────────────────────────────────┐
│ 传统 MPA:URL 变化 = 浏览器自动发 GET 请求 = 整页刷新 │
│ │
│ 用户点击 /about │
│ ↓ │
│ 浏览器 GET /about → 服务器返回新 HTML → 页面完全重建 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SPA 路由:URL 变化 = JS 手动控制 = DOM 局部更新 │
│ │
│ 用户点击「关于」按钮 │
│ ↓ │
│ JS 调用 history.pushState() → 地址栏变 │
│ ↓ │
│ JS 手动 fetch 数据 → 手动 render 组件 │
│ ↓ │
│ 页面不会整体刷新,状态/滚动位置(可能)保留 │
└─────────────────────────────────────────────────────────────┘
记住这个不等式:
- MPA:URL 变 = 网络请求自动发生
- SPA:URL 变 ≠ 网络请求(除非你手动 fetch)
二、先立心智模型:URL、文档、视图三层
1 传统多页应用(MPA)
定义:每次导航通常伴随 整页 HTML 文档 的卸载与加载;地址栏 URL 与 服务端路由 一一对应,浏览器天然发 GET 拉新文档。
// 语法要点:一次导航 ≈ 新 Document
用户点击 <a href="/about"> → 浏览器请求 /about → 返回 HTML → 解析执行
MPA 的优势:
- 服务器直出 HTML → SEO 天生友好(搜索引擎能直接抓完整内容)
- 每个页面独立 → bfcache 恢复完美(历史导航飞快)
- 服务器渲染 → 首屏可能更快(不需要等 JS 下载解析执行才有内容)
- 监控简单 → UV/PV 直接从 CDN 日志算
MPA 的劣势:
- 每次导航要加载完整 HTML → 体验有「闪烁」
- 换页要重新执行 JS/CSS → 复杂交互状态丢失(除非用 localStorage / Cookie)
- 服务器压力大 → 需要 SSR 或 CDN 缓存
啥时候用
- 内容站、强 SEO、弱交互 — 博客、官网、电商商品页;或「MPA + 局部岛屿」(部分组件局部水化)更优。
2 单页应用(SPA)
定义:首个 HTML 壳子加载后,后续「换页」主要由 客户端 JS 决定:改 DOM / 换组件树,不一定再向服务器要一整份 HTML。
精确点:SPA 仍然可以 按需 fetch JSON;只是 「换 URL 栏」 与 「换视图」 解耦了——解耦方式由路由模式决定。
// 语法要点:SPA 里「换页」常 = 客户端路由匹配 + 渲染
//正确示例——心智:视图是函数,输入是 location pathname
function render(pathname) {
if (pathname === '/about') return <AboutPage />;
if (pathname === '/orders') return <OrdersPage />;
return <NotFound />;
}
//错误示例——以为 SPA 永远不会再请求服务器
// SPA 换页 = JS 状态机变了,但可能同时 fetch N 个 API
SPA 的优势:
- 换页无整页刷新 → 体验连贯,状态/滚动位置(可控制)保留
- 首包加载后,数据交互全走 API → 服务器只返回 JSON,减轻服务端渲染压力
- 客户端状态管理 → 复杂交互逻辑更容易(React/Vue 生态)
SPA 的劣势:
- 首包通常较大 → 首屏可能慢(需要下载 JS、解析、执行)
- SEO 需要额外处理 → SSR/预渲染/动态渲染三选一
- bfcache 恢复有陷阱 →
beforeunload不一定跑,副作用要自检 - 监控复杂 → PV/UV 不能从 CDN 日志直算,要前端主动上报
啥时候用
- 重交互后台、复杂状态机 — 管理后台、SaaS 仪表盘、社交 Feed;成本在首包、SEO、bfcache。
3 选 MPA 还是 SPA:一棵决策树
项目类型?
│
├── 静态内容为主(博客/官网/商品页)
│ └── 选 MPA(或 SSG),SEO 和首屏速度优先
│
├── 需要强 SEO + 强交互(如知乎、淘宝商品页)
│ └── 选 SSR(如 Next.js、Nuxt)+ 客户端水化
│
├── 纯工具类后台(数据分析、监控系统)
│ └── 选 SPA,首屏慢一点但换页体验好
│
└── 移动端网页(预算有限、不想装 App)
├── 内容为主 → PWA + SSR
└── 功能为主 → SPA + Service Worker
一句话选型:
- 要快、要 SEO、要简单 → MPA/SSG
- 要体验、要状态、要复杂交互 → SPA(或 SSR + 客户端水化)
- 要又快又 SEO 又交互 → SSR(Next.js/Nuxt)+ 客户端渐进增强
三、Hash 路由:# 后的世界
1 # 默认不进 HTTP 请求体
定义:URI fragment(# 及之后)在 普通导航 中不会作为 HTTP 请求路径发给服务器(你在《网络层》里已见过这一点)。因此 https://app.example/#/user/1 对服务器来说请求路径常是 /——刷新时服务端只看到根路径。
// 语法要点:hashchange 事件 —— 仅 hash 变时触发(含用户改地址栏)
window.addEventListener('hashchange', () => {
console.log(location.hash); // "#/user/1"
// 但浏览器实际请求的是 https://app.example/(不包含 #/user/1)
});
//正确示例——静态服务器零配置即可部署 hash 路由 SPA
// GitHub Pages、微信 H5、任何只提供静态文件的 CDN
//错误示例——以为改 hash 会触发服务器端 /user/1 路由
// 服务器根本不知道用户访问的是 #/user/1,只知道 /
//DevTools 验证:Network 面板里看不到 /user/1 的请求
为什么 hash 模式「能省服务器配置」:
- 用户刷新
/#/orders/123→ 浏览器只请求/ - 服务器只需确保
/返回 200 + 包含 SPA 壳子 JS - 对比:History 模式刷新
//orders/123→ 浏览器请求/orders/123 - 如果服务器没配 fallback,就 404
啥时候用
- 静态托管、无法改 nginx 回退规则 — GitHub Pages、微信 H5、任何只提供静态文件的 CDN。
- 内部工具、快速原型 — 不需要 SEO、不需要分享到外部。
2 Hash 路由的四大局限(不只是「丑」)
定义:hash 路由虽然部署简单,但有四个实质性局限,在产品决策前要想清楚:
局限一:SEO 不友好
# Google 对 hash 路由的索引支持历史坎坷
- 2019 年前:Googlebot 完全忽略 hash 路由
- 2019 年后:Googlebot 支持,但需要配合 History API(Google 称为「AJAX 爬取」)
- 但其他搜索引擎(Bing、百度)支持更差
- 结论:如果你在乎百度/搜狗 SEO,hash 路由基本等于放弃
局限二:分享链接体验差
<!-- hash 模式的分享链接 -->
https://app.example/#/orders/123
<!-- 用户分享到微信 -->
- 微信会抓取链接摘要,但显示的是 #/orders/123
- 诱导分享会被拦截(腾讯政策)
- 在一些老设备上,复制粘贴后 # 可能丢失
<!-- 对比 History 模式 -->
https://app.example/orders/123
- 分享卡片显示正常路径,SEO 友好
- 微信/微博/钉钉都支持良好
局限三:Referer 头不包含 hash
// 场景:用户从 hash 路由页面跳转到一个外部服务
// 外部服务收到的 Referer 头只有 "https://app.example/"(不含 #/xxx)
// 这会影响:
// - 流量来源分析(GA/神策/GrowingIO 的来源统计不准确)
// - 内部安全风控(无法区分用户从哪个业务页跳转)
// - 第三方 API 的权限校验(有些服务依赖 Referer 做白名单)
//验证方法:打开 Network 面板 → 点击外链 → 看 Referer
局限四:部分浏览器 API 受限
// 1. WebSocket、SSE、RTC 等不受 hash 影响
// const ws = new WebSocket('wss://api.example.com/ws'); // 正常
// 2. opener.postMessage 不受 hash 影响
// window.opener?.postMessage('hello', '*'); // 正常
// 3. 但 window.name 受限(History 模式可以利用这个特性做跨页数据传递,hash 不行)
// 4. PDF、CSP、混合内容等与 hash 无直接关系
hash 路由的「安全」误区
//错误示例——把 token 放 hash 里
location.hash = '#access_token=' + accessToken;
// 为什么错误:
// - 用户复制 URL 时会把 token 粘贴到聊天记录/邮件
// -Referer 会把 hash 泄露给第三方(现代浏览器已修复,但老浏览器仍可能泄露)
// - 历史记录里明文保存 token
// - 浏览器插件/扩展可能读取 hash
//正确示例——鉴权走 HttpOnly Cookie / Authorization Header / 短期 code 交换
// 见《安全》篇的 token 存储规范
什么时候 hash 路由是合理选择:
- 微信/QQ 内嵌 H5(无法配置服务器)
- 内部管理系统(不需要 SEO、不分享外部)
- Electron / Cordova 打包的桌面/移动 App(URL 对用户不可见)
- 老旧项目迁移(避免动服务器配置)
四、History API:pushState / replaceState
1 它们改的是「会话历史条目」,不是自动发请求
定义:history.pushState(data, title, url) 在当前文档内压入一条历史记录,并(在允许时)更新地址栏 URL——不会像 <a href> 那样自动发起导航请求换 Document。
因此:你必须自己 fetch 数据 + 更新 DOM/框架状态;否则用户看到「地址变了,内容没变」。
// 语法要点:pushState / replaceState —— 不加载新文档(同源 URL 前提下)
history.pushState({ page: 'about', ts: Date.now() }, 'About', '/about');
// 地址栏变成 https://app.example/about
// 但页面内容不变(除非你手动 render)
//接下来你要自己做:
// 1. fetch('/api/about') 获取数据
// 2. render(<AboutPage />) 更新 DOM
// 3. document.title = '关于我们' 更新标题
//错误示例——pushState 后啥也不干,指望服务器已经把 HTML 塞进来
// 用户会看到地址变了但页面内容完全没变,一脸懵
//正确示例——pushState + render 封装成导航函数
function navigate(pathname, title = document.title) {
const matched = matchRoute(pathname); // 路由匹配
if (!matched) return navigate('/404', '页面不存在');
history.pushState(matched.state, title, pathname);
document.title = title;
// 取消上一个路由的进行中请求(避免竞态)
previousController?.abort();
// 获取数据 + 渲染
previousController = new AbortController();
renderPage(matched.component, matched.params, previousController.signal);
}
pushState vs replaceState 的区别:
| 方法 | 是否新增历史条目 | 典型场景 |
|---|---|---|
pushState |
✅ 新条目(后退能回到上一页) | 正常「换页」导航 |
replaceState |
❌ 替换当前条目 | 跳转到一个登录页(不要用户按后退回到上一个业务页) |
go(n) |
前进/后退 n 步 | 代码里控制导航(如表单提交后跳转) |
back() |
后退一步,等价于 go(-1) |
实现「返回」按钮 |
啥时候用
- History 模式 SPA — 与 Vue Router
createWebHistory、React RoutercreateBrowserRouter同源。
2 同源与 URL 合法性
定义:pushState 的 URL 不能跨源;非法 URL 会 抛 SecurityError。相对路径会相对 当前 location 解析。
//正确示例——用 URL 构造器避免拼接错误
history.pushState(null, '', new URL('/orders', location.origin).pathname);
// new URL('/orders', 'https://app.example/user/123') → 'https://app.example/orders'
//正确示例——用 import.meta.env.BASE_URL 做基路径(Vite 环境)
history.pushState(null, '', import.meta.env.BASE_URL + '/orders');
//错误示例
history.pushState(null, '', 'https://evil.com/'); // SecurityError: Blocked by CORS
history.pushState(null, '', '/../../../etc/passwd'); // 浏览器会修正或抛 SecurityError
一个常见的坑:相对路径解析
// 场景:用户在 /user/123 页面执行 pushState
history.pushState(null, '', 'orders'); // 相对路径
// 结果:地址变成 /user/orders(不是 /orders)
// 如果你本意是 /orders,应该用绝对路径
history.pushState(null, '', '/orders');
3 popstate:用户点「后退/前进」时
定义:用户通过 浏览器 UI 或 history.back() 在历史间移动时,若目标条目由 pushState/replaceState 产生,会触发 popstate 事件;event.state 即当时塞入的 data(注意首次加载与 null 边界)。
精确:pushState 本身不触发 popstate——只有历史导航才触发。
// 语法要点:popstate —— 历史条目切换
window.addEventListener('popstate', (e) => {
console.log('state', e.state); // 就是 pushState 时塞入的第一个参数
console.log('pathname', location.pathname); // 当前 URL
// 正确的做法:根据当前 URL 重新渲染,不是根据 state
// 因为 state 可能为 null(首次加载或刷新后直接访问)
const matched = matchRoute(location.pathname);
if (matched) renderPage(matched.component, matched.params);
});
// 首次加载页面时:popstate 不会触发(直接访问 /about 时 event.state 为 null)
// 此时需要在初始化时手动调用一次路由匹配
function init() {
const matched = matchRoute(location.pathname);
if (matched) renderPage(matched.component, matched.params);
}
window.addEventListener('DOMContentLoaded', init);
//错误示例——在 pushState 同一同步块里期待立刻收到 popstate
history.pushState({ page: 'about' }, '', '/about');
console.log('state:', history.state); // 这里不是 null!pushState 塞进去的数据在这里
// 很多新手误以为 popstate 会在 pushState 后立即触发 —— 不会!
// popstate 只在用户按「后退/前进」或调用 history.back() 时触发
三个容易踩的 popstate 坑
// 坑 1:刷新页面时 popstate 也可能触发(Safari/Firefox 行为)
// 解决:用 state 或 location.pathname 判断,不要假设 popstate 只在后退时触发
window.addEventListener('popstate', (e) => {
const isBackNavigation = e.state !== null; // 有 state = 后退;null = 直接访问/刷新
});
// 坑 2:replaceState 不产生历史条目,但 replaceState 后立即 popstate 查不到
// 解决:replaceState 通常用于不需要后退的场景,如权限过期跳登录页
// 坑 3:连续多次 pushState 后快速按后退,可能只触发一次 popstate
// 解决:用 location.pathname 做路由匹配,而不是依赖 popstate 的顺序
啥时候用
- 实现后退恢复滚动位置、重跑数据加载 — 与 bfcache 合读(见下)。
五、History 模式 + 刷新:为什么 nginx 要 try_files
1 根因一句话
定义:用户直接访问 https://spa.example/orders/123 或 刷新时,浏览器向服务器 GET /orders/123;若服务器没有 fallback 到 index.html,就 404——因为磁盘上根本没有名为 orders/123 的物理文件。
# 根因:用户访问 /orders/123,但服务器磁盘上没有这个文件
# 浏览器:GET /orders/123
# 服务器:磁盘上没有 orders/123 这个文件 → 404
# JS 路由:用户看到 404 页面,但 URL 还是 /orders/123
为什么本地 dev server 通常没问题:
- Vite / webpack-dev-server 默认配置了 fallback(用
connect-history-api-fallback中间件) - 你本地跑
vite时访问/orders/123,dev server 会自动返回index.html - 但线上 CDN/OSS 静态桶不会默认有这个行为
啥时候用
- 首次接入 CDN/OSS 静态托管 — 把 fallback 写进验收清单。
2 常见服务器的 SPA fallback 配置
Nginx
# 正确配置
location / {
try_files $uri $uri/ /index.html;
}
# 解释:
# 1. 先找 $uri(精确文件路径),如 /orders/123.js
# 2. 再找 $uri/(目录),如 /orders/123/index.html
# 3. 最后 fallback 到 /index.html(SPA 壳子)
# 错误配置 1:忘了 fallback
location / {
try_files $uri $uri/ =404; # 没有 /index.html fallback → 404
}
# 错误配置 2:fallback 路径写错
location / {
try_files $uri $uri/ /index.html/; # 多了斜杠!访问 /orders/123 → 找 /index.html/orders/123
}
Vercel(vercel.json)
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Netlify(netlify.toml)
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
阿里云 OSS(静态网站托管)
# OSS 控制台配置:
# 静态页面类型:index.html
# 默认 404 页面:index.html(SSR 模式下可以设)
# 注意:OSS 的「强制跳转 HTTPS」和「SPA fallback」是分开的开关,都要开
Cloudflare Pages
# 在 Cloudflare Pages 设置里:
# Build configuration → Build command: npm run build
# Build output directory: dist
# 在 Pages → Settings → Functions → Configuration 里:
# (Cloudflare Pages 默认支持 SPA fallback,不需要额外配置)
AWS S3 + CloudFront
{
"OriginRequestPolicy": {
"ViewerProtocolPolicy": "redirect-to-https",
"TTL": 0
},
"CacheBehavior": {
"TargetOriginId": "S3-website",
"ViewerRequestFunction": "if (uri startsWith '/api') return pass;"
}
}
# S3 静态网站托管:Error document → index.html(Catch-all 错误都返回 index.html)
3 三个常见配置错误及排查
# 错误 1:只配了根路径,其他路径 404
server {
listen 80;
root /var/www/spa;
index index.html;
# 错误:没有 try_files,所有非根路径都 404
location / {
# try_files $uri $uri/ =404; # 没有这行
}
}
# 错误 2:API 路由也被 fallback 了
server {
listen 80;
root /var/www/spa;
index index.html;
location / {
try_files $uri $uri/ /index.html; # 问题:/api/orders 也被 fallback 了
}
# 正确做法:API 路由要单独处理,不要 fallback
location /api/ {
proxy_pass http://backend:8080;
# 不需要 try_files,直接代理
}
}
# 错误 3:SPA 文件引用路径问题
# 现象:直接访问 /orders/123 能加载,但 JS/CSS 路径 404
# 原因:HTML 里引用的是相对路径
# <script src="bundle.js"></script>
# 访问 /orders/123 时,浏览器找的是 /orders/bundle.js,不是 /bundle.js
# 解决:用绝对路径或 public path 配置
排查步骤:
- 打开 Network 面板 → 刷新页面 → 看哪个请求是 404
- 如果
/orders/123404 → nginx 没有 fallback - 如果
/bundle.js404 → HTML 引用路径问题(通常是<base href="/">或 public path 没配) - 如果
/api/orders404 → API 路由被错误 fallback
4 SPA 文件引用路径的最佳实践
<!-- 错误:相对路径在 /orders/123 下会找 /orders/bundle.js -->
<script src="bundle.js"></script>
<!-- 正确 1:HTML 用 / 绝对路径(推荐 Vite 默认行为) -->
<script src="/bundle.js"></script>
<!-- 正确 2:在 <head> 加 <base href="/"> -->
<head>
<base href="/" />
<script src="bundle.js"></script>
</head>
<!-- 正确 3:打包工具配置 public path(Vite) -->
// vite.config.ts
export default defineConfig({
base: '/', // 或 './'(相对路径模式,兼容性更好)
});
// 框架里的 public path 配置
// Vue CLI / webpack
// vue.config.js
module.exports = {
publicPath: '/', // 或 'auto'
};
// React(Create React App / Vite)
// Vite 默认 base: '/' 就够了
// CRA 需要 homepage: '/'
为什么 public path 用 '/' vs './':
| 配置 | 访问 / |
访问 /orders/123 |
访问 /orders/123/ |
适用场景 |
|---|---|---|---|---|
base: '/' |
✅ | ✅ | ✅ | SPA 部署在域名根路径 |
base: './' |
✅ | ✅ | ✅ | SPA 部署在子路径(如 /my-app/) |
base: '/my-app/' |
❌404 | ✅ | ✅ | 微前端子应用场景 |
六、与 fetch 的关系:路由跳转 ≠ 自动拉数
1 路由与请求必须显式关联
定义:路由变化与 接口请求 是两件独立的事。常见模式:
- 进入路由 →
fetch列表 → 渲染。 - 离开路由 →
AbortController.abort()(见《网络层》)取消进行中的请求,避免竞态。
//正确示例——路由切换时显式取消请求
let currentController = null;
async function enterOrders() {
// 取消上一个路由的请求(如果有)
currentController?.abort();
currentController = new AbortController();
try {
const res = await fetch('/api/orders', {
signal: currentController.signal,
});
const data = await res.json();
renderOrders(data);
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消(正常)'); // 不要把这个当错误处理
} else {
console.error('请求失败', err);
}
}
}
function leaveOrders() {
currentController?.abort();
}
//错误示例——快速切换 tab,旧请求后返回覆盖新列表(无取消、无请求 id)
// 场景:用户快速从 /orders 切到 /users,再切回 /orders
// 问题:/orders 的旧请求可能在新请求之后返回,导致显示错误的数据
2 请求序号防竞态:更严谨的方案
// 请求序号方案:每个路由进入时递增序号,只处理最新序号的结果
let requestVersion = 0;
async function enterUserProfile(userId) {
const version = ++requestVersion; // 当前版本号
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
// 只处理最新请求的结果
if (version !== requestVersion) {
console.log('请求已过时,忽略结果');
return;
}
renderUserProfile(data);
}
// 升级版:AbortController + 序号双保险
let currentController = null;
let currentVersion = 0;
async function enterUserProfile(userId) {
// 取消上一个
currentController?.abort();
const version = ++currentVersion;
currentController = new AbortController();
try {
const res = await fetch(`/api/users/${userId}`, {
signal: currentController.signal,
});
const data = await res.json();
if (version !== currentVersion) return; // 已经切到其他路由了
renderUserProfile(data);
} catch (err) {
if (err.name === 'AbortError') return; // 被取消,正常
throw err; // 其他错误要抛出
}
}
3 路由切换时的数据预取与缓存
// 正确示例——路由切换前预取数据
function prefetch(pathname) {
const urls = {
'/orders': '/api/orders',
'/users': '/api/users',
};
const url = urls[pathname];
if (url) {
fetch(url, { priority: 'low' }); // 低优先级预取
}
}
// hover 时预取(用户体验好,但要注意不要预取太多)
document.querySelectorAll('a[data-prefetch]').forEach((link) => {
link.addEventListener('mouseenter', () => {
prefetch(new URL(link.href).pathname);
}, { passive: true });
});
// 路由切换前预取下一个路由的数据
function navigate(pathname) {
// 预取
prefetch(pathname);
// 同时触发导航
history.pushState(null, '', pathname);
renderPage(pathname);
}
啥时候用
- 列表页筛选、路由快速切换 — 竞态是高频 bug。
- 预取优化 — 在 hover 时预取下一个路由的数据,减少路由切换后的等待时间。
七、bfcache(往返缓存):SPA 的「隐形页」
1 是什么
定义:bfcache(back-forward cache)允许浏览器在用户 后退/前进 时整页冻结复用(含 JS 堆、DOM),以极快恢复。对 SPA 意味着:beforeunload 不一定按你想的跑、副作用可能「复活」。
// 语法要点:pageshow 事件的 persisted
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
console.log('从 bfcache 恢复'); // 页面从缓存恢复,不是重新加载
// 可能需要重新订阅 WebSocket、刷新 token、重跑轻量校验
} else {
console.log('首次加载或从磁盘加载'); // 正常首次访问
}
});
//错误示例——假设每次返回都会完整 reload,因而在 pageshow 里疯狂重复注册监听
// 正确:bfcache 恢复时,JS 状态是冻结的,不要重复初始化
bfcache 对 SPA 的具体影响
| 场景 | 没有 bfcache | 有 bfcache |
|---|---|---|
| 后退到上一页 | 重新加载 HTML、JS,重新 fetch 数据 | 直接显示缓存的 DOM,恢复 JS 堆 |
beforeunload 触发 |
✅ 每次离开都触发 | ❌ bfcache 恢复时不触发 |
| 定时器(setInterval) | 页面卸载时停止 | bfcache 冻结时也停止(恢复时继续) |
| WebSocket 连接 | 页面卸载时断开 | bfcache 冻结时连接可能还活着 |
| 表单数据 | 可能丢失(看浏览器) | 通常保留(因为 DOM 被缓存) |
2 SPA 在 bfcache 场景下的自检清单
// 1. WebSocket 重连
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
ws.close(); // 关闭可能已冻结的连接
ws = new WebSocket(url); // 重建连接
}
});
// 2. 定时轮询重启
let pollTimer;
function startPoll() {
pollTimer = setInterval(fetchData, 5000);
}
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
clearInterval(pollTimer); // 先清掉可能冻结的定时器
startPoll(); // 重启轮询
}
});
// 3. Token 有效性检查
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
const token = localStorage.getItem('access_token');
if (token && isExpired(token)) {
// token 可能在 bfcache 期间过期
redirectToLogin();
}
}
});
// 4. 表单数据检查(可能需要重置)
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
// 检查表单是否有未保存的数据
const form = document.querySelector('form');
if (form && hasUnsavedData(form)) {
// 提示用户或自动保存草稿
}
}
});
禁用 bfcache(不推荐,特殊情况才用)
<!-- 在 HTML 里加 HTTP 头,禁止缓存(性能会变差)-->
<!-- <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> -->
// 或用 JS 强制页面不缓存
window.addEventListener('beforeunload', () => {
// 强制浏览器不缓存
});
// 注意:这只在 beforeunload 触发时才生效,bfcache 恢复时不触发
啥时候用
- WebSocket / 定时轮询 / 全局单例 — 从 bfcache 回来时自检。
- 涉及登录态、Token 过期的业务 — bfcache 期间 token 可能已过期。
八、滚动与锚点:路由要管的「隐形状态」
1 滚动行为:SPA 路由的隐形状态
定义:滚动位置、焦点、侧边栏展开 都是 UI 状态。pushState 后若你整树替换,默认会从顶部开始——要显式 scrollTo 或框架的 scrollBehavior。
原生实现
// 正确示例——路由切换时手动控制滚动
function navigate(pathname) {
history.pushState(null, '', pathname);
window.scrollTo(0, 0); // 回到顶部
renderPage(pathname);
}
// 正确示例——恢复上次滚动位置
const scrollPositions = new Map(); // 用 pathname 做 key 存滚动位置
function navigate(pathname) {
scrollPositions.set(location.pathname, { x: window.scrollX, y: window.scrollY });
history.pushState(null, '', pathname);
const saved = scrollPositions.get(pathname);
if (saved) {
window.scrollTo(saved.x, saved.y);
} else {
window.scrollTo(0, 0);
}
}
window.addEventListener('popstate', () => {
const saved = scrollPositions.get(location.pathname);
if (saved) {
window.scrollTo(saved.x, saved.y);
}
});
框架的 scrollBehavior(Vue Router / React Router)
// Vue Router
const router = createRouter({
history: createWebHistory(),
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition; // 后退时恢复到上次滚动位置
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }; // 锚点定位
}
return { top: 0 }; // 其他情况回到顶部
},
});
// React Router v6(使用 view transitions API)
function App() {
return (
<Router>
<ScrollRestoration />
<Routes>{/* routes */}</Routes>
</Router>
);
}
2 锚点路由:SPA 里的 #id 定位
// SPA 里用锚点的正确方式
function navigateToSection(sectionId) {
// 方式 1:用 hash(不触发页面刷新)
history.pushState(null, '', `#${sectionId}`);
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth' });
// 方式 2:直接操作 hash(会触发 hashchange 事件)
// location.hash = `#${sectionId}`;
// 方式 3:用 View Transitions API(现代浏览器)
if (document.startViewTransition) {
document.startViewTransition(() => {
document.getElementById(sectionId)?.scrollIntoView();
});
}
}
// 监听 hashchange 做锚点定位
window.addEventListener('hashchange', () => {
const hash = location.hash.slice(1);
if (hash) {
document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' });
}
});
3 可访问性:路由切换后焦点管理
定义:路由切换后把 焦点移到 h1 或主容器(tabIndex="-1"),让读屏用户知道「换页了」。
// 正确示例——路由切换后把焦点移到主容器
function navigate(pathname) {
history.pushState(null, '', pathname);
// 渲染完成后
requestAnimationFrame(() => {
const main = document.querySelector('main') || document.querySelector('h1');
if (main) {
main.setAttribute('tabIndex', '-1');
main.focus();
// 读屏用户会听到「主内容区 h1:xxx」(取决于页面结构)
}
});
}
// 错误示例——路由切换后焦点还在上一个页面的按钮上
// 读屏用户不知道页面已经切换,会继续在旧页面的上下文里操作
ARIA Live Regions 辅助路由切换通知
<!-- 在 index.html 里加一个隐藏的 ARIA live region -->
<div id="route-announcer" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<script>
// 路由切换时通知读屏用户
function navigate(pathname, title) {
history.pushState(null, '', pathname);
renderPage(pathname);
// 通知读屏用户
const announcer = document.getElementById('route-announcer');
if (announcer) {
announcer.textContent = `导航到 ${title || pathname}`;
}
}
</script>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
可访问性:详见《可访问性》篇——路由切换的 ARIA 标签、焦点管理、Skip Link 都是前端路由的特殊考量。
九、与 SSR / SSG 的边界(只点破)
1 为什么 SPA 需要 SSR
定义:SPA 首屏问题是 CSR(客户端渲染)的原罪:用户下载 HTML → 下载 JS → 解析执行 → fetch 数据 → 渲染内容。「白屏」时间 = 下载 HTML + 下载 JS + 解析执行 + fetch 数据 + 首帧渲染。
SSR 做了什么:服务器先把 HTML + 首屏数据填好返回给浏览器,浏览器直接渲染(无 JS 时也能显示内容)。hydration 后,JS 接管,进入 SPA 模式。
CSR 首屏路径:
GET / → HTML → GET /bundle.js → 解析执行 → GET /api/data → 渲染
时间线: [-------HTML-------][-------JS-------][--fetch--][-render-]
SSR 首屏路径:
GET / → HTML(含首屏内容)→ 直接渲染 → hydration → SPA 模式
时间线: [-------HTML(含内容)-------][--hydration--]
SSR 的代价:
- 服务器要能运行 Node.js(Next.js/Nuxt/Remix)
- 服务器有计算成本(高并发下比静态文件贵)
- 复杂度增加( hydration 边界要处理)
- 缓存策略要设计(不然每次请求都 SSR,服务器压力大)
2 SSR / SSG / ISR 各选型对比
| 方案 | 首屏 | SEO | 实时性 | 成本 | 适用场景 |
|---|---|---|---|---|---|
| CSR(SPA) | 慢 | 差(需额外处理) | 实时 | 低(纯静态) | 强交互后台、不需要 SEO |
| SSG(静态生成) | 快 | 好 | 差(需重新构建) | 低 | 博客、文档、官网(内容不频繁更新) |
| SSR(服务端渲染) | 快 | 好 | 好 | 中高 | 需要 SEO + 实时数据的页面(商品详情、新闻) |
| ISR(增量静态再生) | 快 | 好 | 中(定时失效) | 中 | 内容更新频率适中的页面(博客、电商列表) |
| ** PPR(渐进式增强)** | 中 | 好 | 好 | 中 | Next.js 13+ App Router,默认行为 |
SSG 示例(Next.js)
// pages/about.tsx(构建时生成 /about.html)
export async function getStaticProps() {
return { props: { content: await fetchAboutContent() } };
}
// 用户访问时:服务器直接返回静态 HTML,不需要 Node.js 运行时
ISR 示例(Next.js)
// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
return {
props: { post: await fetchPost(params.slug) },
revalidate: 60, // 60 秒内重复访问返回缓存,之后重新生成
};
}
// 适合:博客文章、商品详情页(更新不频繁但有时效性)
SSR 示例(Next.js)
// pages/orders/[id].tsx
export async function getServerSideProps({ params, req }) {
// 每次请求都执行,可以访问 Cookie 做鉴权
const session = await getSession(req);
if (!session) return { redirect: { destination: '/login' } };
return { props: { order: await fetchOrder(params.id, session) } };
}
3 一句话选型总结
内容不变(博客/文档/官网)
→ SSG(静态生成,构建一次,CDN 缓存,永久不变)
→ 优点:最快、最便宜、最简单
内容频繁更新,但不需要实时(新闻/电商列表)
→ ISR(定时失效,用户访问时触发重新生成)
→ 优点:兼顾性能和新鲜度
内容需要个性化(用户相关数据、实时数据)
→ SSR 或 PPR(Per-request 渲染)
→ 优点:首屏快 + 数据实时
→ 缺点:服务器成本
内容不需要 SEO(后台管理、内部工具)
→ CSR(纯 SPA)
→ 优点:最简单、最便宜
→ 缺点:首屏慢、SEO 差
4 SSR + SPA hydration 的常见坑
// 坑 1:hydration mismatch
// 现象:SSR 输出的 HTML 和 CSR 首次渲染的结果不一致 → 页面抖动
// 原因:SSR 时用了 Date.now()、Math.random()、localStorage 等服务端没有的数据源
// 解决:保证 SSR 和 CSR 的初始状态一致,或使用 suppressHydrationWarning
// 坑 2:hydration 时事件监听重复绑定
// 现象:点击按钮触发两次
// 原因:SSR 阶段 DOM 已存在,CSR hydration 又执行了一遍 addEventListener
// 解决:用框架的事件绑定(React 的 onClick、Vue 的 @click),不要自己手动 addEventListener
// 坑 3:window 对象在 SSR 阶段不存在
// 现象:ReferenceError: window is not defined
// 错误示例
const isMobile = window.innerWidth < 768;
// 正确示例
const isMobile = typeof window !== 'undefined' ? window.innerWidth < 768 : false;
// 或在 useEffect 里访问(useEffect 只在客户端执行)
精确:若你只有 CSR,却想要「像 MPA 一样被搜索引擎完整索引」——要 SSR/预渲染/动态渲染 之一,不是单靠
pushState魔法。
十、View Transitions API:SPA 的页面切换动画
1 是什么
定义:View Transitions API 是现代浏览器提供的新 API,允许在同一个文档内做视图切换动画——以前 SPA 路由切换时想要平滑动画,只能自己用 CSS/JS 实现,现在浏览器原生支持。
// 基础用法
function navigate(pathname) {
if (document.startViewTransition) {
// 新版浏览器:用 View Transitions API
document.startViewTransition(() => {
history.pushState(null, '', pathname);
renderPage(pathname);
});
} else {
// 旧版浏览器:降级
history.pushState(null, '', pathname);
renderPage(pathname);
}
}
2 View Transitions 在 SPA 路由中的实际效果
默认效果:旧页面 fade out,新页面 fade in(浏览器默认)
自定义动画(CSS)
/* 在 index.html 里加 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
animation-timing-function: ease-out;
}
/* 可以针对不同路由做不同动画 */
::view-transition-old(page-left),
::view-transition-new(page-right) {
animation: slide-out 300ms ease-out;
}
::view-transition-old(page-right),
::view-transition-new(page-left) {
animation: slide-in 300ms ease-out;
}
@keyframes slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-30px); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(30px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
给特定元素加动画(不用整页切)
function navigateWithSharedElement(pathname) {
if (!document.startViewTransition) {
history.pushState(null, '', pathname);
renderPage(pathname);
return;
}
// 给某个特定元素加 view-transition-name
const hero = document.querySelector('.hero-image');
if (hero) hero.style.viewTransitionName = 'hero';
document.startViewTransition(() => {
hero.style.viewTransitionName = '';
history.pushState(null, '', pathname);
renderPage(pathname);
});
}
/* 对特定元素加动画 */
::view-transition-old(hero) {
animation: scale-down 300ms ease-out;
}
::view-transition-new(hero) {
animation: scale-up 300ms ease-out;
}
3 Pending Navigation API:解决路由切换时的闪烁
定义:Pending Navigation API(React Router v6+ 支持)是 View Transitions 的补充,在路由切换时显示加载状态,避免白屏闪烁。
// React Router v6 + View Transitions
import { useNavigate, ViewTransition } from 'react-router-dom';
function App() {
return (
<Router>
<PendingNavigation />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/orders" element={<Orders />} />
</Routes>
</Router>
);
}
// PendingNavigation 组件:路由切换时显示 loading
function PendingNavigation() {
const navigation = useNavigation();
if (navigation.state === 'loading') {
return (
<div className="global-loading">
<div className="spinner" />
</div>
);
}
return null;
}
配合 View Transitions 的完整示例
import { useNavigate, useNavigation, ViewTransition } from 'react-router-dom';
function Navigation() {
const navigate = useNavigate();
const navigation = useNavigation();
const handleNavigate = (path) => {
if (document.startViewTransition) {
document.startViewTransition(() => {
navigate(path);
});
} else {
navigate(path);
}
};
return (
<>
{navigation.state === 'loading' && (
<div className="nav-loading" aria-label="加载中" />
)}
<nav>
<button onClick={() => handleNavigate('/')}>首页</button>
<button onClick={() => handleNavigate('/orders')}>订单</button>
</nav>
</>
);
}
4 兼容性现状
| API | Chrome | Edge | Safari | Firefox |
|---|---|---|---|---|
| View Transitions API | 111+ | 111+ | 18+ | ❌ 未支持 |
document.startViewTransition |
111+ | 111+ | 18+ | ❌ 未支持 |
降级策略:始终用 feature detection,不支持时降级到普通路由切换。
function navigate(pathname) {
if (document.startViewTransition) {
document.startViewTransition(() => {
history.pushState(null, '', pathname);
renderPage(pathname);
});
} else {
// 降级:直接切换
history.pushState(null, '', pathname);
renderPage(pathname);
}
}
十一、框架对比:Vue Router / React Router 6/7
1 Vue Router(Vue 3)
// main.ts
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: Home },
{ path: '/orders/:id', component: OrderDetail, props: true },
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.hash) return { el: to.hash };
return { top: 0 };
},
});
app.use(router);
// 组件里使用
const router = useRouter(); // 编程式导航
const route = useRoute(); // 读取当前路由信息
router.push('/orders/123');
Vue Router 特点:
- 声明式路由:
path+component映射,简单直观 - 嵌套路由:
children数组,适合布局嵌套 - 路由守卫:
beforeEach、beforeRouteEnter,适合权限校验 - 懒加载:
() => import('./OrderDetail.vue')
2 React Router 6/7
// App.tsx
import { BrowserRouter, Routes, Route, useNavigate, useParams } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/orders/:id" element={<OrderDetail />} />
</Routes>
</BrowserRouter>
);
}
// 编程式导航
function OrderList() {
const navigate = useNavigate();
return (
<button onClick={() => navigate('/orders/123')}>
查看订单
</button>
);
}
// 读取路由参数
function OrderDetail() {
const { id } = useParams();
return <div>订单ID: {id}</div>;
}
// React Router 7 新增:View Transitions 支持
import { ViewTransition } from 'react-router-dom';
function App() {
return (
<ViewTransition>
<Routes>{/* ... */}</Routes>
</ViewTransition>
);
}
React Router 特点:
- React 18+ Suspense 集成:懒加载友好
- View Transitions API 集成(v7):路由切换动画更简单
- 数据路由(v7):用
loader/action做数据获取,更接近 SSR 框架 - 嵌套路由:用
<Outlet />渲染子路由
3 对比总结
| 特性 | Vue Router | React Router 6/7 |
|---|---|---|
| 路由模式 | Hash / History / Abstract | Hash / Browser / Memory / Static |
| 嵌套路由 | children + <router-view> |
<Outlet /> |
| 路由守卫 | beforeEach / beforeRouteEnter |
Loaders / Actions(v7) |
| 懒加载 | () => import() |
React.lazy() / lazy |
| 类型安全 | Vue 本身的 TS 支持好 | v6/v7 对 TS 支持非常好 |
| View Transitions | 需自己实现 | 原生支持(v7) |
| 数据获取 | 组件里 onMounted + fetch |
Loaders(v7) |
十二、实战清单
| 项 | 检查 | 验证方式 |
|---|---|---|
| History 模式 | 服务器 fallback index.html |
刷新 /orders/123,Network 里应看到 / 返回 200,/orders/123 不请求或返回 200 |
| 深链 | 分享 URL 刷新 可打开 | 复制 URL 到新标签页,应显示正确页面内容 |
| 竞态 | 路由切换 abort 或 请求序号 | 快速切换列表页,Network 面板里旧请求应被取消(灰色) |
| 后退 | bfcache 下副作用是否安全 | 后退操作后 WebSocket/轮询/Token 是否正常 |
| 埋点 | history.replaceState 是否吞掉 referrer |
跳转后 Network 的 Referer 头是否包含上一页路径 |
| SEO | History 模式 + SSR/预渲染 | Google Search Console 里看页面是否被正确索引 |
| 文件路径 | public path 配置正确 | 直接访问 /about,JS/CSS 应正常加载(Network 无 404) |
| API 路由 | API 不被 SPA fallback 捕获 | GET /api/orders 应返回 JSON,不是 index.html 内容 |
十三、结语
路由是 URL 与视图的契约:hash 把契约藏在 fragment;history 把契约抬到 pathname,于是你必须用 服务器回退规则 接住它。把它和《BOM》《网络层》《异步》连读,你会少掉一半「为什么刷新没了」「为什么后退怪」的工单。
一句话总结各路由模式适用场景:
| 模式 | 适用场景 | 服务器配置 |
|---|---|---|
| Hash | 微信 H5、内部工具、静态托管 | 无需配置 |
| History | 需 SEO 的正式站点 | 必须配置 fallback |
| SSR + History | 需 SEO + 强交互 | SSR + fallback |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)