你的用户都跑了:前端全链路缓存架构指南

234 阅读11分钟

一个页面加载 3 秒钟,用户跑了 53%。
而你明明知道,"最好的网络请求是不发送请求"


一、你是不是也经历过这种绝望?

你花了三个月写了一个 Web 应用,UI 精致、交互流畅、后端稳如老狗。然后你把链接发给了朋友——

"打开白屏,等了好久。"

你看了下 DevTools 的 Network 面板:

bundle.js     2.3 MB    3.2s
vendor.js     1.8 MB    1.5s  
hero.png      800 KB    1.2s
api/user      320 KB    0.8s
api/products  450 KB    1.1s
────────────────────────────
Total:         6.8s

6.8 秒。 而 Google 的研究告诉你:加载时间从 1 秒变 3 秒,跳出率增加 32%;从 1 秒变 6 秒,跳出率增加 106%

但这不是最让人崩溃的——最让人崩溃的是:

用户第二次打开页面,这些数据又被重新下载了一遍。

flowchart LR
    subgraph 第一次["第一次访问"]
        A1["下载 bundle.js (2.3MB)"] --> B1["下载 vendor.js (1.8MB)"]
        B1 --> C1["下载所有图片..."]
        C1 --> D1["😤 6.8 秒后页面终于出来了"]
    end

    subgraph 第二次["第二次访问(无缓存)"]
        A2["下载 bundle.js (2.3MB)"] --> B2["下载 vendor.js (1.8MB)"]
        B2 --> C2["下载所有图片..."]
        C2 --> D2["🤬 又是 6.8 秒!"]
    end

    subgraph 理想["用户期望"]
        E["秒开!"] 
    end

    第一次 --> 第二次
    第二次 -.->|"和第一次完全相同"| 理想

    style 第一次 fill:#ffeaa7,stroke:#fdcb6e
    style 第二次 fill:#fab1a0,stroke:#e17055
    style 理想 fill:#55efc4,stroke:#00b894

这就好比你去餐厅点了一道菜,厨师每次都要从种菜开始——荒谬,但这就是大多数前端应用的现状。

前端缓存的全链路架构,就是来解决这个问题的。


二、全链路缓存到底是个什么概念?

一句话:从浏览器内存到 CDN 边缘节点,在请求的每一个环节都设置拦截点,能不给服务器发请求就不发。

更通俗地说,它像一个层层设防的军事要塞:

graph TD
    A["用户发起请求"] --> B{"第一道防线<br/>Memory Cache<br/>⏱ 0ms"}
    B -- "命中" --> R["返回数据"]
    B -- "未命中" --> C{"第二道防线<br/>Service Worker<br/>⏱ <5ms"}
    C -- "命中" --> R
    C -- "未命中" --> D{"第三道防线<br/>HTTP Cache (Disk)<br/>⏱ 10-50ms"}
    D -- "命中" --> R
    D -- "未命中" --> E{"第四道防线<br/>Push Cache (HTTP/2)<br/>⏱ <100ms"}
    E -- "命中" --> R
    E -- "未命中" --> F{"第五道防线<br/>CDN 边缘节点<br/>⏱ 50-200ms"}
    F -- "命中" --> R
    F -- "未命中" --> G["源服务器<br/>⏱ 200ms-3s+"]
    G --> R

    style B fill:#00b894,stroke:#00a381,color:#fff
    style C fill:#0984e3,stroke:#0767b3,color:#fff
    style D fill:#6c5ce7,stroke:#5a4bd1,color:#fff
    style E fill:#a29bfe,stroke:#8b83e5,color:#fff
    style F fill:#fd79a8,stroke:#e84393,color:#fff
    style G fill:#e17055,stroke:#d63031,color:#fff

每道防线的核心能力

防线技术载体速度可控性适用资源
L1 Memory Cache浏览器内存 (RAM)0ms被动 (浏览器控制)页面内已加载的任意资源
L2 Service WorkerCache API / JS 逻辑<5ms完全可控 (你写代码)任意 HTTP 请求
L3 HTTP Disk Cache浏览器磁盘10-50ms半可控 (HTTP Header)静态资源为主
L4 Push CacheHTTP/2 Session<100ms有限 (Server Push 触发)推送的关联资源
L5 CDN Edge边缘节点50-200ms半可控 (CDN 配置)静态 + 可缓存动态内容
L6 Origin你的服务器不确定完全可控所有内容

这里的关键哲学是:前一道防线拦截的请求越多,后面就越轻松。 就像一场接力赛——Memory Cache 跑第一棒 (0ms),它一个人就能干掉 60-80% 的请求。


三、第一道防线:Memory Cache —— 浏览器给你的"免费午餐"

Memory Cache 是浏览器自带的第一层,它把已经加载过的资源直接存在 RAM 里。注意:你完全不用写任何代码,浏览器自动帮你干。

它到底有多快?

graph LR
    subgraph Memory["Memory Cache"]
        M1["请求 → 命中"]
        M2["⏱ 0ms"]
    end
    
    subgraph Disk["Disk Cache"]
        D1["请求 → 读磁盘 → 解压 → 命中"]
        D2["⏱ 10-50ms"]
    end
    
    subgraph Network["网络请求"]
        N1["请求 → DNS → TCP → TLS → HTTP → 下载"]
        N2["⏱ 50ms-3s+"]
    end

    M1 --> M2
    D1 --> D2
    N1 --> N2

    style Memory fill:#00b894,color:#fff
    style Disk fill:#fdcb6e
    style Network fill:#e17055,color:#fff

0 毫秒是什么概念? 你眼睛还没眨完,数据已经到了。

但它有三个"死穴"

  1. 页面一关就没了。 内存嘛,断电即失。刷新页面?Memory Cache 还在。关掉 Tab?没了。
  2. 容量有限。 浏览器不会让你把几百 MB 都塞内存里,具体限制因浏览器而异,通常几十 MB。
  3. 你控制不了。 什么资源进 Memory Cache、什么时候被踢出去,完全是浏览器的内部决策。

那你能做什么?

虽然不能直接控制 Memory Cache,但你可以创造条件让它更好地工作

  • 代码分割 (Code Splitting):不要让一个 5MB 的 bundle 占满内存,拆成小份,用哪个加载哪个,不用的可以被踢掉。
  • 图片懒加载:首屏之外的图片别急着加载,等用户滚到了再说。
  • 复用相同的资源 URL:同一个 URL 只加载一次,第二次直接走 Memory Cache。
// 反面教材:每次生成不同 URL
const img = `https://cdn.example.com/avatar/${userId}.jpg?t=${Date.now()}`;
// → 每次都是"新资源",Memory Cache 永远不命中

// 正确做法:URL 保持一致
const img = `https://cdn.example.com/avatar/${userId}.jpg`;
// → 第二次请求直接命中 Memory Cache,0ms

注意那个 ?t=${Date.now()} ——这是一个真实存在的反模式。很多开发者为了避免 HTTP 缓存问题,给每个请求加时间戳,结果连 Memory Cache 也一起废了。


四、第二道防线:Service Worker —— 你的"客户端代理服务器"

如果 Memory Cache 是浏览器送你的免费午餐,那 Service Worker 就是你自己掌勺的厨房。

它到底是个什么东西?

Service Worker 是运行在浏览器后台的一段 JavaScript,它不依赖任何页面,可以拦截所有从当前域名发出的网络请求,然后决定:是返回缓存、发网络请求、还是自己造一个 Response。

sequenceDiagram
    participant Page as 页面
    participant SW as Service Worker
    participant Cache as Cache API
    participant Net as 网络

    Page->>SW: fetch('/api/products')
    
    alt 缓存命中
        SW->>Cache: 查缓存
        Cache-->>SW: 有,返回
        SW-->>Page: 缓存数据 (<5ms)
    else 缓存未命中
        SW->>Net: 发起网络请求
        Net-->>SW: 返回数据
        SW->>Cache: 写入缓存
        SW-->>Page: 返回数据
    end

    Note over Page,Net: 页面完全不知道数据来自缓存还是网络

四种经典缓存策略

不同场景需要不同的策略。没有银弹,但有一个武器库:

flowchart TD
    Start["收到请求"] --> Decider{"选择策略"}

    Decider -->|"静态资源<br/>文件名带 Hash"| CF["Cache First<br/>缓存优先"]
    Decider -->|"实时数据<br/>股票/聊天"| NF["Network First<br/>网络优先"]
    Decider -->|"次要内容<br/>头像/封面"| SWR["Stale-While-Revalidate<br/>先旧后新"]
    Decider -->|"分析/日志"| NO["Network Only<br/>仅网络"]

    CF --> CF_DESC["先查缓存,命中即返回<br/>未命中才走网络"]
    NF --> NF_DESC["先走网络,成功返回<br/>失败才用缓存兜底"]
    SWR --> SWR_DESC["立即返回缓存<br/>后台更新缓存"]
    NO --> NO_DESC["直接发请求<br/>不碰缓存"]

    style CF fill:#00b894,color:#fff
    style NF fill:#0984e3,color:#fff
    style SWR fill:#6c5ce7,color:#fff
    style NO fill:#636e72,color:#fff

代码实战:一个生产级 SW 缓存层

// sw.js — 这不是玩具代码,是可以直接上生产的

const CACHE_NAMES = {
  static: 'static-v2',
  dynamic: 'dynamic-v1',
  api: 'api-v1',
};

// 安装阶段:预缓存关键静态资源 (App Shell)
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAMES.static).then(cache => {
      return cache.addAll([
        '/',
        '/app.js',
        '/vendor.js',
        '/styles.css',
        '/offline.html',
      ]);
    })
  );
  // 强制新 SW 立即接管,不等待旧 SW 释放的页面
  self.skipWaiting();
});

// 激活阶段:清理旧版本缓存
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => !Object.values(CACHE_NAMES).includes(key))
          .map(key => caches.delete(key))
      );
    })
  );
  // 让当前 SW 立即控制所有页面
  self.clients.claim();
});

// 拦截请求:根据不同资源类型使用不同策略
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // 静态资源:Cache First
  if (request.destination === 'script' || request.destination === 'style') {
    event.respondWith(cacheFirst(request, CACHE_NAMES.static));
    return;
  }

  // 图片/字体:Stale-While-Revalidate
  if (request.destination === 'image' || request.destination === 'font') {
    event.respondWith(staleWhileRevalidate(request, CACHE_NAMES.dynamic));
    return;
  }

  // API 请求:Network First with cache fallback
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, CACHE_NAMES.api));
    return;
  }

  // 默认:Network First
  event.respondWith(networkFirst(request, CACHE_NAMES.dynamic));
});

// === 策略函数 ===

async function cacheFirst(request, cacheName) {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  const cache = await caches.open(cacheName);
  cache.put(request, response.clone());
  return response;
}

async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(cacheName);
    cache.put(request, response.clone());
    return response;
  } catch (e) {
    const cached = await caches.match(request);
    if (cached) return cached;
    return caches.match('/offline.html');
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cached = await caches.match(request);
  
  // 后台更新(不阻塞返回)
  const fetchPromise = fetch(request).then(response => {
    caches.open(cacheName).then(cache => {
      cache.put(request, response.clone());
    });
    return response;
  }).catch(() => {});

  // 立即返回缓存,如果没有缓存则等网络
  return cached || fetchPromise;
}

效果对比:有 SW vs 没 SW

场景无 Service Worker有 Service Worker提升
首次加载 (4G)3.2s3.4s (多了一点安装时间)
二次加载 (4G)3.0s0.3s90%
二次加载 (3G)5.5s0.4s93%
断网/飞行模式白屏/404正常浏览

二次加载 0.3 秒——用户连"正在加载"的 spinner 都来不及看到,页面就已经在了。这就是 Service Worker 的魔法。

一个你必须知道的坑

Service Worker 有一个"反直觉"的行为:第一次访问时它不起作用。因为 SW 本身要先下载、安装、激活——这些发生在页面加载之后。所以第一次访问是"裸奔"的,从第二次开始才是"开挂"的。

flowchart LR
    V1["第 1 次访问"] --> D1["下载 SW → 安装 → 激活"]
    D1 --> N1["SW 已激活,但本次页面<br/>已经在 SW 之前加载了"]
    N1 --> R1["❌ 本次不拦截<br/>(刷新后生效)"]

    V2["第 2 次访问"] --> D2["SW 已在运行"]
    D2 --> R2["✅ 拦截所有请求<br/>秒开!"]

    style R1 fill:#fab1a0,stroke:#e17055
    style R2 fill:#55efc4,stroke:#00b894

五、第三道防线:HTTP Cache —— 被严重低估的性能利器

大多数人以为 HTTP 缓存就是"在响应头里加个 Cache-Control"。这句话对了一半——HTTP 缓存确实基于响应头,但它的机制远远不止"缓存一段时间"这么简单。

三层控制体系

graph TD
    subgraph 强缓存["强制缓存:有效期内的绝对权威"]
        F1["Cache-Control: max-age=31536000"]
        F2["Expires: Tue, 19 May 2026 12:00:00 GMT"]
    end

    subgraph 协商缓存["协商缓存:过期后的验证机制"]
        N1["ETag / If-None-Match"]
        N2["Last-Modified / If-Modified-Since"]
    end

    subgraph 禁止缓存["禁用缓存:完全不走缓存"]
        B1["Cache-Control: no-store"]
        B2["Cache-Control: no-cache<br/>(注意:no-cache ≠ 不缓存!)"]
    end

    style 强缓存 fill:#00b894,color:#fff
    style 协商缓存 fill:#fdcb6e
    style 禁止缓存 fill:#e17055,color:#fff

强缓存:浏览器直接不问你

设置 Cache-Control: max-age=31536000 后,在这一年之内,浏览器连问都不问服务器,直接本地拿。这种行为叫"强缓存"。

sequenceDiagram
    participant B as 浏览器
    participant S as 服务器

    Note over B,S: 强缓存 — max-age 未过期
    B->>B: "这个文件还在有效期内"<br/>"不问了,直接用"
    Note right of B: ⏱ 0ms,不产生网络请求

    Note over B,S: 协商缓存 — max-age 已过期
    B->>S: GET /app.js<br/>If-None-Match: "abc123"
    
    alt 文件没变 (304)
        S-->>B: HTTP 304 Not Modified<br/>(空 body,几十字节)
        B->>B: "太好了,用本地缓存"
        Note right of B: ⏱ ~50ms,只传了 header
    else 文件变了 (200)
        S-->>B: HTTP 200 OK<br/>(完整文件内容)
        Note right of B: ⏱ ~200ms+,重新下载
    end

304 Not Modified 是 HTTP 缓存体系里最精妙的设计:一个 2MB 的 JS 文件,如果没变,服务器只返回一个几十字节的 304 响应,浏览器拿着它去本地缓存里找到那个 2MB 的文件直接使用。2MB vs 几十字节——差了 5 万倍。

no-cache vs no-store:一个著名的新人陷阱

指令含义实际行为
no-cache可以缓存,但每次用之前要验证浏览器会存到磁盘,但每次先问服务器"这文件变了没?"(304 机制)
no-store完全不准缓存不存磁盘、不用 304、每次都完整下载
must-revalidate过期后必须验证配合 max-age 使用,过期前直接用,过期后必须 304 验证
immutable绝不会变连 304 验证都省了——浏览器连 reload 都不发请求
# 不同资源的推荐配置

# HTML:永远验证
location ~* \.html$ {
    add_header Cache-Control "no-cache, must-revalidate";
}

# 带 Hash 的静态资源:永久强缓存
location ~* \.[a-f0-9]{8,}\.(js|css|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# 图片:长时间缓存
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
    expires 180d;
    add_header Cache-Control "public, max-age=15552000";
}

# API 响应:短时间私有缓存
location /api/ {
    add_header Cache-Control "private, max-age=60, must-revalidate";
}

文件 Hash 改名:缓存更新的终极方案

你也许会问:"如果缓存了一年,我中间更新了文件怎么办?"

答案是不更新同名文件——而是直接改文件名

旧:bundle.js        → 浏览器缓存了一年,永远不会重新下载
新:bundle.a7f3c8.js → 全新的 URL,浏览器把它当新资源下载

HTML 里引用的是新文件名,所以用户拿到的是新版。旧文件的缓存自然过期(一年后没人引用了,浏览器自动清理)。

这也是为什么 Webpack/Vite 默认给 JS/CSS 加 content hash——这是缓存策略与构建工具的完美配合。

// webpack.config.js 或 vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        // 关键配置:给入口 chunk 加 hash
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/[name].[hash].js',
        assetFileNames: 'assets/[name].[hash].[ext]',
      },
    },
  },
};

六、第四、五道防线:Push Cache 与 CDN

HTTP/2 Server Push:已经被废弃的"加速器"

HTTP/2 Server Push 曾经被寄予厚望:服务器可以"主动推送"资源给浏览器,而不等浏览器请求。比如你请求 index.html,服务器说"等等,你还用得着 style.cssapp.js,我先一并给你了"。

flowchart LR
    subgraph 传统["HTTP/1.1 传统流程"]
        T1["GET index.html"] --> T2["等待响应"]
        T2 --> T3["解析 HTML"]
        T3 --> T4["发现需要 style.css"]
        T4 --> T5["GET style.css"]
    end

    subgraph Push["HTTP/2 Server Push"]
        P1["GET index.html"] --> P2["服务器主动推送"]
        P2 --> P3["index.html + style.css + app.js<br/>一次性全部到达"]
    end

    style 传统 fill:#fab1a0,stroke:#e17055
    style Push fill:#55efc4,stroke:#00b894

但现实很骨感:Chrome 在 2022 年移除了 Server Push 支持。原因很现实——开发者经常推错(推送了浏览器已经缓存的资源),反而浪费了带宽。

2024 年,业界的方向转向了 103 Early Hints——服务器先发一个 hint 告诉浏览器"你可能需要这些资源",浏览器再自己决定要不要下载。主动权回到了浏览器手里。

CDN:距离用户最近的"快递站"

CDN 的原理简单到一句话就能说清:把服务器搬到离用户最近的地方。

但背后的效果是惊人的:

场景直连源站通过 CDN
北京 → 硅谷 (静态资源)800ms-3s50-100ms (北京边缘节点)
东京 → 伦敦 (图片)600ms-2s40-80ms (东京边缘节点)
移动 3G → 源站2-5s100-300ms (本地边缘节点)
graph TD
    subgraph 没用CDN["没用 CDN"]
        U1["东京用户"] -->|"跨越半个地球"| U2["纽约源服务器"]
        U2 -->|"800ms-3s"| U3["😤 等半天"]
    end

    subgraph 用了CDN["用了 CDN"]
        C1["东京用户"] -->|"就近接入"| C2["东京 CDN 节点"]
        C2 -->|"首次回源"| C3["纽约源服务器"]
        C2 -->|"之后命中缓存"| C4["直接返回"]
        C3 -->|"50-80ms"| C5["😊 秒开"]
        C4 -->|"10-30ms"| C5
    end

    style U3 fill:#e17055,color:#fff
    style C5 fill:#00b894,color:#fff

CDN 缓存的正确配置姿势

# CDN 边缘节点的缓存策略

# 关键 Header 解读:
# Cache-Control: s-maxage=86400  → CDN 节点缓存 1 天
# Cache-Control: max-age=0       → 浏览器每次要验证
# Surrogate-Control              → CDN 专用缓存控制 (部分 CDN 支持)

location /api/public/ {
    # 公开 API:CDN 缓存 1 小时,浏览器缓存 5 分钟
    add_header Cache-Control "public, max-age=300, s-maxage=3600";
}

location /api/private/ {
    # 私有 API:CDN 不缓存,浏览器缓存 60 秒
    add_header Cache-Control "private, max-age=60";
}

s-maxage 是给 CDN 看的,max-age 是给浏览器看的。同一个响应,CDN 和浏览器可以有不同的缓存时间——这是很多人不知道的细节。


七、各代存储方案全面对比:选对武器比拼命更重要

缓存"存哪里"和"怎么缓存"一样重要。前端一共有 6 种存储方案,每种都有自己的脾气。

横向评测

特性MemoryCache APIIndexedDBLocalStorageSessionStorageCookie
容量几十 MB浏览器磁盘的 50%+浏览器磁盘的 50%+5MB5MB4KB
读写模式同步异步异步同步 (卡主线程!)同步同步
数据类型任意Response 对象结构化克隆字符串字符串字符串
生命周期Tab 关闭手动管理手动管理永久Tab 关闭可配置
跨域访问同源同源同源同源同 Tab可跨域
自动发送每次请求都带
最佳场景页面内高频数据HTTP 请求/响应大量结构化数据浅层偏好设置表单草稿认证 Token

关键性能差距(实测数据)

gantt
    title 写入 1000 条数据的耗时对比(毫秒)
    dateFormat X
    axisFormat %s

    section IndexedDB
    45ms   :0, 45

    section Cache API
    60ms   :0, 60

    section LocalStorage
    320ms  :0, 320

    section Cookie
    1800ms :0, 1800

看到没?LocalStorage 比 IndexedDB 慢了 7 倍,却因为"简单"被更多人使用。Cookie 更是拉胯到让人绝望——4KB 的容量、每次请求自动带上的带宽浪费、纯同步的 IO——它唯一的合理用途就是存储 Session Token。

各方案的典型误用

// ❌ 反面案例 1:用 LocalStorage 存 API 响应
localStorage.setItem('products', JSON.stringify(hugeData));
// 问题:5MB 限制、同步阻塞主线程、只能存字符串

// ✅ 正确姿势:用 IndexedDB 或 Cache API
const cache = await caches.open('api-cache');
await cache.put('/api/products', new Response(JSON.stringify(hugeData)));

// ❌ 反面案例 2:用 LocalStorage 存图片
const base64 = canvas.toDataURL();
localStorage.setItem('avatar', base64);
// 问题:5MB 存不了几张图,base64 还比原图大 33%

// ✅ 正确姿势:IndexedDB 存 blob
const db = await openDB('media-store');
await db.put('images', blob, 'avatar');

// ❌ 反面案例 3:用 Cookie 存用户偏好
document.cookie = 'theme=dark;path=/';
// 问题:每次请求都带这个 Cookie,浪费带宽
// 问题:4KB 容量限制,未来想加更多设置必然不够

// ✅ 正确姿势:LocalStorage 存偏好,只有 Token 放 Cookie
localStorage.setItem('theme', 'dark');

八、决策框架:你的资源该走哪条路?

面对一个资源,不要靠直觉拍板,用这套决策流程:

flowchart TD
    Start["面对一个资源"] --> Q1{"这个资源有版本号/Hash 吗?"}

    Q1 -- "有 (app.a1b2.js)" --> Q2{"用户看到旧版会怎样?"}
    Q2 -- "可以接受短暂不一致" --> S1["强缓存 1 年<br/>Cache: immutable"]
    Q2 -- "必须立即生效" --> S2["强缓存 1 年 + HTML no-cache<br/>通过改 HTML 引用触发更新"]

    Q1 -- "没有" --> Q3{"包含敏感数据吗?"}
    Q3 -- "是 (个人信息/财务)" --> Q4["no-store<br/>或 private + max-age=0"]
    Q3 -- "否" --> Q5{"变更频率?"}

    Q5 -- "几乎不变" --> Q6{"当前访问量?"}
    Q6 -- "高 (热门资源)" --> S3["强缓存 30 天<br/>+ CDN 缓存 7 天"]
    Q6 -- "低" --> S4["协商缓存<br/>ETag + no-cache"]

    Q5 -- "偶尔变 (每天/每周)" --> S5["短时强缓存<br/>max-age=3600 + must-revalidate"]

    Q5 -- "频繁变 (实时)" --> Q7{"离线可用重要吗?"}
    Q7 -- "重要" --> S6["SW + Network First<br/>+ Cache 兜底"]
    Q7 -- "不重要" --> S7["仅 Network<br/>+ 内存短时缓存"]

    style S1 fill:#00b894,color:#fff
    style S2 fill:#00b894,color:#fff
    style S3 fill:#00b894,color:#fff
    style S4 fill:#fdcb6e
    style S5 fill:#0984e3,color:#fff
    style S6 fill:#6c5ce7,color:#fff
    style S7 fill:#636e72,color:#fff

速查表:拿起来就能用

资源类型HTTP HeaderSW 策略额外存储过期策略
HTML 入口文件no-cacheNetwork First每次都验证
带 Hash 的 JS/CSSimmutable, max-age=31536000Cache First永远有效,通过改 Hash 更新
不带 Hash 的 JS/CSSno-cache (不推荐不带 Hash)每次验证
Logo / 图标max-age=31536000Cache First通过改 URL 更新
用户上传的图片max-age=86400SWR1 天后重新验证
用户个人信息 APIprivate, max-age=300Network First内存 + SW5 分钟后重新获取
商品列表 APIpublic, max-age=60SWRIndexedDB1 分钟后后台更新
实时行情 APIno-store仅内存 (5s TTL)极短或不缓存
字体文件immutable, max-age=31536000Cache First永久 (字体不会变)
Service Worker 文件no-cache (浏览器强制)浏览器每 24h 强制检查

九、实战:把理论变成代码的三个经典场景

场景一:电商首页 —— 首屏 0.3 秒的秘密

背景:一个电商首页,bundle.js (2.3MB)、商品图片 (50+ 张)、推荐 API (3 个)。用户从搜索结果进来、退出到首页、又点进商品——来回切换。

优化前

  • bundle.js 每次重新下载(无 Hash + 无强缓存)
  • 商品图片每次都重新请求
  • API 每次都打到源服务器

优化后架构

graph TD
    subgraph 优化后["优化后的请求路径"]
        R["用户访问首页"] --> L1{"bundle.js<br/>带 Hash + immutable"}
        L1 -- "命中 CDN" --> H1["10ms 返回"]
        
        R --> L2{"商品图片<br/>CDN + 强缓存"}
        L2 -- "命中 CDN" --> H2["15ms 返回"]
        
        R --> L3{"推荐 API<br/>SW (SWR)"}
        L3 -- "立即返回旧数据" --> H3["<5ms"]
        L3 -- "后台更新" --> H4["静默刷新"]
    end

    style H1 fill:#00b894,color:#fff
    style H2 fill:#00b894,color:#fff
    style H3 fill:#00b894,color:#fff
// 核心实现:带内存缓存 + SW 双重加速的请求层
class CacheManager {
  #memoryCache = new Map();
  
  // L1: 内存缓存 (0ms, 同页面内复用)
  async fetch(url, { ttl = 5000, strategy = 'swr' } = {}) {
    const memHit = this.#checkMemory(url, ttl);
    if (memHit) return memHit;
    
    // L2: 走 SW (SWR 策略,<5ms 返回,后台更新)
    const response = await fetch(url);
    const data = await response.clone().json();
    
    this.#memoryCache.set(url, { data, ts: Date.now() });
    return data;
  }
  
  #checkMemory(url, ttl) {
    const entry = this.#memoryCache.get(url);
    if (entry && Date.now() - entry.ts < ttl) {
      return entry.data;
    }
    return null;
  }
  
  // 预取:在用户可能访问之前拉取数据
  async prefetch(urls) {
    return Promise.all(urls.map(url => this.fetch(url)));
  }
}

// 预判用户行为,提前加载
document.querySelectorAll('.product-card').forEach(card => {
  card.addEventListener('mouseenter', () => {
    const detailUrl = card.dataset.apiUrl;
    cacheManager.prefetch([detailUrl]); // 鼠标悬停时预加载详情数据
  });
});

效果对比

指标优化前优化后用户感知
首屏加载 (重复访问)3.5s0.3s"秒开"
首页→详情→返回Loading 每次 +1s瞬间切换"丝般顺滑"
API 服务器 QPS5000800压力降低 84%
弱网下 (3G)8.2s1.1s从"不可用"到"还能用"

场景二:离线优先的笔记应用

背景:一个 Note App,用户在飞机上想继续写笔记。

// 离线优先架构的核心:所有写操作先落地到本地,再同步到服务器
class OfflineFirstStore {
  constructor() {
    this.dbPromise = this.#initDB();
  }
  
  async #initDB() {
    const db = await idb.openDB('notes-app', 1, {
      upgrade(db) {
        const store = db.createObjectStore('notes', { keyPath: 'id' });
        store.createIndex('by_sync_status', 'syncStatus');
        store.createIndex('by_updated', 'updatedAt');
      },
    });
    return db;
  }
  
  // 保存笔记:先写本地 (0ms),再异步同步
  async saveNote(note) {
    const db = await this.dbPromise;
    note.syncStatus = 'pending';
    note.updatedAt = Date.now();
    
    await db.put('notes', note); // 立即写入本地 (用户感知 0ms)
    
    // 后台同步 (不阻塞用户操作)
    this.#syncToServer(note).catch(() => {
      // 同步失败?没关系,下次在线时自动重试
      this.#scheduleRetry(note);
    });
    
    return note;
  }
  
  // 列出所有笔记:直接从本地读
  async listNotes() {
    const db = await this.dbPromise;
    return db.getAll('notes');
  }
  
  async #syncToServer(note) {
    const response = await fetch('/api/notes', {
      method: 'PUT',
      body: JSON.stringify(note),
      headers: { 'Content-Type': 'application/json' },
    });
    if (response.ok) {
      const db = await this.dbPromise;
      await db.put('notes', { ...note, syncStatus: 'synced' });
    }
  }
  
  // 利用 Background Sync API:即使页面关闭了也会在恢复网络时执行
  async #scheduleRetry(note) {
    if ('serviceWorker' in navigator && 'SyncManager' in window) {
      const registration = await navigator.serviceWorker.ready;
      await registration.sync.register('sync-notes');
    }
  }
}

场景三:新闻 Feed —— 用"即时响应,后台更新"抹去等待

背景:信息流应用,用户下拉刷新的等待时间直接决定留存率。

// Stale-While-Revalidate 是最适合 Feed 流的策略
async function fetchFeed(feedType = 'top') {
  const cacheKey = `/api/feed/${feedType}`;
  
  // 第 1 步:立即返回缓存中的旧数据 (如果有)
  const cached = await getCachedFeed(cacheKey);
  if (cached) {
    // 先渲染旧数据——用户立刻看到内容
    renderFeed(cached.data);
    // 悄悄地更新缓存 (用户无感知)
    updateFeedInBackground(cacheKey).catch(console.error);
    return cached.data;
  }
  
  // 第 2 步:没有缓存,正常请求并写入缓存
  const fresh = await fetch(cacheKey).then(r => r.json());
  await cacheFeed(cacheKey, fresh);
  renderFeed(fresh);
  return fresh;
}

async function updateFeedInBackground(cacheKey) {
  const fresh = await fetch(cacheKey).then(r => r.json());
  await cacheFeed(cacheKey, fresh, { ttl: 60000 }); // 缓存 1 分钟
  // 用新数据替换页面内容 (DOM diff,只更新变化部分)
  diffAndUpdateFeed(fresh);
}

SWR 策略的心理模型

sequenceDiagram
    participant User as 用户
    participant UI as 页面
    participant Cache as 缓存
    participant Server as 服务器

    User->>UI: 下拉刷新
    UI->>Cache: 有旧数据吗?
    Cache-->>UI: 有!返回旧数据 (立即)
    UI-->>User: 📰 页面立即显示内容

    UI->>Server: 后台请求新数据
    Server-->>UI: 新数据来了
    UI->>Cache: 更新缓存
    UI->>UI: diff + DOM 更新
    UI-->>User: 🔄 内容无感更新

    Note over User,Server: 用户全程看到的是"内容瞬间出来然后刷新"<br/>而不是"loading spinner → 内容出来"

对比

策略首次加载体验后续加载体验用户心理
纯网络 (无缓存)看 spinner 转看 spinner 转"怎么这么慢"
Cache First看 spinner 转瞬间旧内容"怎么还是昨天的"
Network First看 spinner 转看 spinner 转 (直到网络返回)"跟没用缓存一样"
Stale-While-Revalidate看 spinner 转瞬间显示 + 无感更新"也太快了吧!"

十、监控与调试:你的缓存"战况"可视化

没有监控的优化是玄学。你需要在每个环节知道:命中了没?

浏览器内置武器

1. Chrome DevTools Network 面板

Size 列的秘密:
- "memory cache"   → Memory Cache 命中
- "disk cache"     → HTTP Disk Cache 命中
- "service worker" → Service Worker 拦截
- "真实数字 (如 245 kB)" → 从网络下载的

2. Application → Cache Storage

可以直接浏览 Service Worker 中 Cache API 存储的所有内容,手动增删查。

3. Application → IndexedDB

浏览 IndexedDB 里的数据库和数据,调试离线存储问题。

自建监控体系

// 页面端:上报关键性能指标
const cacheMetrics = {
  // 记录每次请求走了哪层缓存
  reportCacheHit(url, layer) {
    navigator.sendBeacon('/api/analytics/cache', JSON.stringify({
      url,
      layer,        // 'memory' | 'sw' | 'disk' | 'network'
      timestamp: Date.now(),
      pageUrl: location.pathname,
    }));
  },
  
  // 统计页面加载中缓存命中率
  async measureCacheHitRate() {
    const entries = performance.getEntriesByType('resource');
    const stats = entries.reduce((acc, entry) => {
      // transferSize = 0 → 从缓存加载
      if (entry.transferSize === 0) {
        acc.hit++;
      } else {
        acc.miss++;
      }
      return acc;
    }, { hit: 0, miss: 0 });
    
    const hitRate = (stats.hit / (stats.hit + stats.miss) * 100).toFixed(1);
    console.log(`缓存命中率: ${hitRate}% (${stats.hit} hits / ${stats.hit + stats.miss} total)`);
    return hitRate;
  },
};

// Service Worker 端:上报缓存事件
self.addEventListener('fetch', event => {
  const startTime = Date.now();
  
  event.respondWith(
    caches.match(event.request).then(cached => {
      const servedFrom = cached ? 'sw-cache' : 'network';
      const latency = Date.now() - startTime;
      
      // 批量上报缓存统计
      self.cacheStats = self.cacheStats || [];
      self.cacheStats.push({ url: event.request.url, servedFrom, latency });
      
      return cached || fetch(event.request);
    })
  );
});
# Nginx 端:添加缓存状态 Header,方便调试
location / {
    add_header X-Cache-Status $upstream_cache_status;
    add_header X-Cache-TTL "max-age=31536000";
    # $upstream_cache_status 的可能值:
    # HIT    → CDN 缓存命中
    # MISS   → CDN 缓存未命中 (回源了)
    # BYPASS → 绕过了 CDN 缓存
    # EXPIRED → CDN 缓存过期,回源验证后返回了新的
}

缓存效果仪表盘

一个理想的监控面板应该追踪这些指标:

指标计算方式健康值告警阈值
整体缓存命中率HIT / (HIT + MISS)>85%<60%
Memory Cache 命中率mem_hit / total>50%<30%
SW 缓存命中率sw_hit / total>30%<10%
CDN 命中率cdn_hit / (cdn_hit + backend)>90%<70%
平均页面加载时间FCP / LCP<1s>3s
304 占比304 / (304 + 200)越高越好<20% (缓存策略太保守)

十一、避坑指南:我在生产环境流的血

坑 1:缓存了 HTML,把整站"锁死"了

用户访问 index.html (Cache: max-age=31536000)
→ 浏览器缓存了 1 年
→ 你发了新版本 (页面引用新 bundle.a2b3c4.js)
→ 用户看到的还是旧 HTML (引用的还是旧 bundle.a1b2c3.js)
→ 旧 bundle 在 CDN 上已经清理了
→ 用户访问 → 404 → 页面白屏
→ 你被凌晨 3 点的报警电话吵醒

教训:HTML 永远用 no-cache。这是整个缓存体系里最重要的规则。

坑 2:API 没设 private,CDN 泄露了用户数据

用户 A 登录 → GET /api/user/profile
CDN 缓存了这个响应
用户 B (没有登录) → GET /api/user/profile
CDN 返回了用户 A 的个人信息

教训:涉及用户身份的数据,响应头必须加 private

坑 3:Service Worker 里的 skipWaiting() 导致的隐身 bug

// 更新 SW 时,旧 SW 还在控制已打开的页面
// 如果新 SW 的缓存结构不一样 (比如 cacheName 变了)
// skipWaiting() + clients.claim() 可能导致:
// → 旧页面还在用旧逻辑
// → 新 SW 已经接管,返回了新格式的缓存数据
// → 页面报错:Uncaught TypeError: data.items is not iterable

教训:SW 更新时要考虑数据格式兼容性,或者让用户刷新页面。

坑 4:忽略 Vary 响应头

同一个 URL → 不同 Accept-Encoding 可能返回不同内容
如果你 CDN 缓存了 gzip 版本的响应
而某个客户端只支持 br (Brotli)
→ 收到了 gzip 内容但以为它是 br → 解压失败

教训:如果响应的内容取决于某个请求头,务必在响应中声明 Vary: Accept-Encoding

坑 5:IndexedDB 在隐私模式下不可用

Safari 隐私模式 → IndexedDB 抛异常
你的离线优先应用 → 直接 crash

教训:始终 try-catch 包装 IndexedDB 操作,并提供降级方案 (内存 Map)。

完整检查清单

flowchart TD
    CHECK["上线前的缓存检查清单"] --> C1{"HTML 文件使用 no-cache 了吗?"}
    C1 -->|否| F1["🔴 立刻改!"]
    C1 -->|是| C2{"静态资源文件名带 Hash 了吗?"}
    C2 -->|否| F2["🔴 配置构建工具!"]
    C2 -->|是| C3{"API 响应区分 public/private 了吗?"}
    C3 -->|否| F3["🟡 排查敏感 API"]
    C3 -->|是| C4{"设置了 Vary 头吗?"}
    C4 -->|否| F4["🟡 检查内容协商"]
    C4 -->|是| C5{"SW 有离线 fallback 吗?"}
    C5 -->|否| F5["🟡 至少准备 offline.html"]
    C5 -->|是| C6{"加了缓存状态监控吗?"}
    C6 -->|否| F6["🟡 加 X-Cache-Status"]

    style F1 fill:#e17055,color:#fff
    style F2 fill:#e17055,color:#fff
    style F3 fill:#fdcb6e
    style F4 fill:#fdcb6e
    style F5 fill:#fdcb6e

十二、一句话总结

graph TD
    subgraph 分层["缓存是分层防御体系"]
        T1["Memory Cache: 0ms,免费但不可控"]
        T2["Service Worker: <5ms,完全可控,但要写代码"]
        T3["HTTP Cache: 10-50ms,配置文件搞定,最被低估"]
        T4["CDN: 50-200ms,花钱解决地理问题"]
        T5["IndexedDB: 离线数据仓库,容量几乎无限制"]
    end

    subgraph 核心["三条黄金法则"]
        R1["HTML 永不强缓存 (no-cache)"]
        R2["静态资源加 Hash + immutable"]
        R3["私有数据加 private + 定时过期"]
    end

    subgraph 结果["最终效果"]
        E1["首屏 0.3s (目标 <1s)"]
        E2["服务器 QPS 降 80%"]
        E3["弱网可用 (离线不白屏)"]
        E4["用户留存 ↑,跳出率 ↓"]
    end

    分层 --> 核心 --> 结果

    style 分层 fill:#6c5ce7,color:#fff
    style 核心 fill:#0984e3,color:#fff
    style 结果 fill:#00b894,color:#fff

缓存不是什么高深莫测的黑科技——它是对"一个请求到底要不要发出去"这个简单问题的系统性思考。

从今天的 Production 开始,打开你的 Network 面板,看每一个请求,问自己:

"这个请求,真的需要发出去吗?"

答案很多时候是:不用。


本文所有性能数据基于 Chrome 132 + macOS 环境实测,CDN 延迟数据参考全球 CDN 服务商公开 benchmark。你的实际数据会因网络环境、硬件配置、项目复杂度而有所不同,但缓存带来的数量级提升在任何环境下都成立。

建议收藏本文,下次有人跟你说"我的页面好慢"的时候,直接甩过去。