前端路由与 SPA

一、导读

定义: 先分清「地址栏里是什么」与「屏幕上画什么」谁说了算,再谈 historypopstatebfcache 与 SPA 常见坑。
代码块 // 语法要点//正确示例//错误示例;示例以原生 API 为主,框架是「同一套约束下的语法糖」。
啥时候用 每条对应「联调、SEO、回退、埋点」里会踩的线。
姊妹篇 《BOM》写过 history / location;《网络层》写过 URL 与 fetch;《异步》写过 popstate 与任务;《性能》写过 INP 与主线程——本篇把它们拧成一条路由线

目标:能说清 hash 路由 vs History 路由 的语义差异;知道 history.pushState 不会自动请求网络;能解释 「刷新 404」 的根因与 nginx 配置思路;知道 bfcachepageshow / 副作用的影响。
边界:本文不写某一框架 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 Router createBrowserRouter 同源。

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:用户点「后退/前进」时

定义:用户通过 浏览器 UIhistory.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 配置

排查步骤

  1. 打开 Network 面板 → 刷新页面 → 看哪个请求是 404
  2. 如果 /orders/123 404 → nginx 没有 fallback
  3. 如果 /bundle.js 404 → HTML 引用路径问题(通常是 <base href="/"> 或 public path 没配)
  4. 如果 /api/orders 404 → 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 数组,适合布局嵌套
  • 路由守卫beforeEachbeforeRouteEnter,适合权限校验
  • 懒加载() => 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
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐