缓存相关学习笔记(一):Service Worker 缓存

1、Service Worker 缓存是什么?

Service Worker 缓存

Service Worker 是浏览器在主线程之外 运行的一段 JavaScript 脚本,可以拦截网络请求、操作缓存,实现完全由前端控制的缓存策略

它与 HTTP 缓存(强缓存/协商缓存)是两套独立体系,互不干扰。


核心能力

能力 说明
拦截请求 像代理服务器一样,捕获所有同域及子域的 fetch 请求
离线访问 没网时从缓存返回资源,实现 PWA 离线功能
精细控制 不依赖服务器响应头,前端自己决定缓存什么、怎么更新
后台同步 网络恢复后自动重发失败的请求

生命周期

scss 复制代码
安装 (install) → 激活 (activate) → 空闲等待 → 拦截 fetch
     ↓                ↓
  预缓存核心资源      清理旧缓存
  (如 app shell)

两种缓存对象

对象 用途 特点
CacheStorage (caches) 存储 HTTP 请求的 Request/Response 容量大(通常几十到几百 MB),可精确控制
IndexedDB 存储结构化数据 适合存 API 返回的 JSON 数据

典型缓存策略(Cache Strategies)

javascript 复制代码
// service-worker.js

const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = ['/index.html', '/app.js', '/style.css'];

// 1. 安装时预缓存核心资源
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS); // 一次性写入缓存
    })
  );
  self.skipWaiting(); // 立即激活
});

// 2. 激活时清理旧版本缓存
self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then((names) => {
      return Promise.all(
        names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
      );
    })
  );
  self.clients.claim(); // 立即控制页面
});

// 3. 拦截请求,决定缓存策略
self.addEventListener('fetch', (e) => {
  const { request } = e;

  // 策略1: Cache First(优先读缓存,没有则网络请求并缓存)
  if (isStaticAsset(request)) {
    e.respondWith(
      caches.match(request).then((cached) => {
        return cached || fetch(request).then((response) => {
          return caches.open(CACHE_NAME).then((cache) => {
            cache.put(request, response.clone());
            return response;
          });
        });
      })
    );
  }

  // 策略2: Network First(优先网络,失败回退缓存)
  if (isAPI(request)) {
    e.respondWith(
      fetch(request).catch(() => caches.match(request))
    );
  }

  // 策略3: Stale While Revalidate(先返回缓存,同时后台更新)
  if (isImage(request)) {
    e.respondWith(
      caches.match(request).then((cached) => {
        const fetchPromise = fetch(request).then((response) => {
          caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
          return response;
        });
        return cached || fetchPromise; // 有缓存先给缓存,同时后台更新
      })
    );
  }
});

常见缓存策略对比

策略 行为 适用场景
Cache First 有缓存直接用,没有才网络请求 静态资源(JS/CSS/字体)
Network First 先网络,失败回退缓存 API 数据、实时性要求高的内容
Cache Only 只用缓存,不发网络 离线模式下的纯静态页面
Network Only 只用网络,不读缓存 实时性极强的数据(如股票行情)
Stale While Revalidate 先给缓存,后台静默更新 新闻列表、图片,平衡速度与新鲜度

与 HTTP 缓存的关系

markdown 复制代码
┌─────────────────────────────────────────┐
│           浏览器发起请求                 │
└─────────────────────────────────────────┘
                   ↓
┌─────────────────────────────────────────┐
│  Service Worker 拦截 fetch 事件          │
│  ├─ 命中 CacheStorage → 直接返回         │ ← 完全绕过 HTTP 缓存
│  └─ 未命中 → 继续发真实网络请求           │
│              ↓                         │
│      ┌─────────────┐                   │
│      │  HTTP 缓存层  │  ← 强缓存/协商缓存  │
│      │  (浏览器默认) │                   │
│      └─────────────┘                   │
└─────────────────────────────────────────┘

关键点

  • Service Worker 的 fetch 拦截发生在 HTTP 缓存之前
  • 如果 SW 从 CacheStorage 返回了响应,HTTP 缓存层完全不会参与
  • 只有 SW 决定继续发网络请求时,才会走到强缓存/协商缓存的逻辑

实际应用场景

场景 做法
PWA 离线应用 预缓存 HTML/JS/CSS,无网也能打开
首屏加速 先返回缓存的骨架屏,再请求真实数据
弱网优化 网络差时自动降级到缓存版本
版本更新 修改 CACHE_NAME(如 app-v2),触发新 SW 安装,清理旧缓存

一句话总结

HTTP 缓存 是浏览器按服务器响应头自动管理的,被动

Service Worker 缓存 是前端代码主动控制的,主动------你可以决定缓存什么、什么时候更新、没网时怎么降级,甚至完全绕过 HTTP 缓存。

2、实际生产环境中,service worker使用的多吗?什么情况下会使用呢?

实际生产环境中 Service Worker 的使用情况

总体现状:用得不多,但特定场景是标配

场景 使用频率 说明
普通管理后台 / 企业内网系统 很少用 用户在线使用,离线需求弱,接入成本高
电商 / 内容型 C 端站点 中等 主要用于首屏加速、弱网降级
PWA 应用(如 Twitter Lite、饿了么 H5) 必用 离线访问是核心能力
微信小程序 / 快应用 底层原理 框架内部使用,开发者无感知

为什么"用得不多"?

成本端:

  1. 调试困难 --- SW 有独立生命周期,更新不生效、缓存清理不干净是常见问题
  2. HTTPS 强制要求 --- 本地开发也要配证书
  3. 兼容性包袱 --- 虽然现代浏览器都支持,但老旧环境要兜底
  4. 更新机制反直觉 --- 用户可能一直用着旧版本 SW,新版本"装上了但不激活"

收益端:

  • 很多场景 HTTP 缓存 + CDN 已经够用了
  • 管理后台用户不会"离线使用",SW 价值有限
  • 接入和维护成本 > 实际收益

什么情况下值得使用?

条件 典型场景
离线可用是刚需 现场作业 App(如外卖骑手端、巡检系统)、弱网环境(地铁、地下室)
首屏速度是生死线 电商大促 H5、新闻资讯类,先给缓存再刷新
流量/带宽敏感 发展中国家、按流量计费网络,减少重复下载
需要后台能力 推送通知(Push API)、后台同步(Background Sync)
构建成 PWA/桌面应用 用 Electron/Tauri 替代方案时,SW 是 PWA 的核心

实际案例

产品 SW 用途
Twitter Lite 离线发推、弱网浏览、推送通知
饿了么 H5 离线查看订单、首屏秒开
Vue/React 文档站 离线阅读文档(VitePress/Docusaurus 默认支持)
网易云音乐 Web 版 缓存歌曲、离线播放

你的场景判断

项目类型 建议
普通管理后台 不需要 SW,HTTP 缓存 + CDN 足够

一句话

SW 是特定问题的特定解 ,不是前端标配。大部分项目用不上,但面试问到要答得出来;遇到离线、首屏、弱网场景时,它是唯一解

3、举一个实际使用的例子

实际案例:外卖骑手端 H5(饿了么/美团类似场景)

这是 Service Worker 的经典落地场景,离线刚需 + 弱网高频


业务背景

  • 骑手在商场地下室、电梯、老旧小区,网络时断时续
  • 必须能离线查看已分配订单提交配送完成
  • 不能因没网就白跑一趟或无法履约

SW 实现的核心逻辑

javascript 复制代码
// sw.js
const CACHE_NAME = 'rider-app-v2';
const PRECACHE = [
  '/index.html',
  '/static/js/app.js',
  '/static/css/app.css',
  '/offline.html'  // 兜底离线页面
];

// 1. 安装时预缓存核心壳资源
self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE))
  );
  self.skipWaiting();
});

// 2. 激活时清旧缓存
self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then(names => 
      Promise.all(names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)))
    )
  );
  self.clients.claim();
});

// 3. 请求拦截:不同资源不同策略
self.addEventListener('fetch', (e) => {
  const { request } = e;
  const url = new URL(request.url);

  // 策略A:API 数据 → Network First,失败回退缓存
  if (url.pathname.startsWith('/api/')) {
    e.respondWith(
      fetch(request)
        .then(res => {
          // 网络成功,更新缓存
          const clone = res.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
          return res;
        })
        .catch(() => {
          // 网络失败,读缓存(如之前加载过的订单列表)
          return caches.match(request).then(cached => {
            if (cached) return cached;
            // 连缓存都没有,返回离线兜底
            return new Response(JSON.stringify({ offline: true, msg: '网络异常,数据可能不是最新' }), {
              headers: { 'Content-Type': 'application/json' }
            });
          });
        })
    );
    return;
  }

  // 策略B:静态资源 → Cache First
  if (request.destination === 'script' || request.destination === 'style') {
    e.respondWith(
      caches.match(request).then(cached => 
        cached || fetch(request).then(res => {
          caches.open(CACHE_NAME).then(cache => cache.put(request, res.clone()));
          return res;
        })
      )
    );
    return;
  }

  // 策略C:图片 → Stale While Revalidate
  if (request.destination === 'image') {
    e.respondWith(
      caches.match(request).then(cached => {
        const fetchPromise = fetch(request).then(res => {
          caches.open(CACHE_NAME).then(cache => cache.put(request, res.clone()));
          return res;
        }).catch(() => cached); // 网络失败无所谓,有缓存就行
        return cached || fetchPromise;
      })
    );
  }
});

// 4. 后台同步:网络恢复后自动重发失败请求
self.addEventListener('sync', (e) => {
  if (e.tag === 'sync-orders') {
    e.waitUntil(syncPendingOrders());
  }
});

async function syncPendingOrders() {
  const db = await openDB('pending-db', 1);
  const pending = await db.getAll('orders');
  for (const req of pending) {
    try {
      await fetch('/api/order/complete', {
        method: 'POST',
        body: JSON.stringify(req.data),
        headers: { 'Content-Type': 'application/json' }
      });
      await db.delete('orders', req.id); // 成功删除
    } catch (e) {
      // 继续保留,下次再试
    }
  }
}

前端页面里的配合

javascript 复制代码
// 骑手点击"确认送达"
async function completeOrder(orderId) {
  try {
    await fetch('/api/order/complete', {
      method: 'POST',
      body: JSON.stringify({ orderId })
    });
    showToast('送达成功');
  } catch (err) {
    // 网络失败,注册后台同步
    if ('serviceWorker' in navigator && 'SyncManager' in window) {
      const reg = await navigator.serviceWorker.ready;
      // 先存 IndexedDB
      await saveToPendingDB({ orderId, time: Date.now() });
      await reg.sync.register('sync-orders');
      showToast('网络异常,将在恢复后自动同步');
    } else {
      showToast('提交失败,请检查网络');
    }
  }
}

实际效果

场景 无 SW 有 SW
地下室打开订单页 白屏/报错 显示缓存的订单列表,提示"数据可能不是最新"
电梯里点击"送达" 提交失败,骑手焦虑 提示"已保存,联网后自动同步",骑手继续送下一单
弱网加载图片 长时间转圈 先显示模糊缓存图,清晰图后台加载
App 更新 用户可能一直用旧版 下次打开时静默激活新版

另一个你更熟悉的例子:Vue/Vite 文档站

如果你用过 Vite 文档Vue 文档,它们默认就是 PWA:

  • 第一次访问后,断网也能看文档
  • 用的是 vite-plugin-pwa,底层就是 Service Worker
  • 更新时右下角会出现"新版本可用,点击刷新"的提示

这就是 "离线阅读"场景 的轻量化应用,比你从零写 SW 简单得多。


总结

骑手端是 "生存刚需" 型 SW 应用------没它业务跑不通;

文档站是 "体验加分" 型 SW 应用------有它更好,没它也能活。

管理后台项目,如果要做技术展示,可以往 "文档站式" 方向靠:加个 PWA 插件,实现离线看页面、安装到桌面。

4、静态文件动态导入

'/index.html', '/static/js/app.js', '/static/css/app.css' 生产环境的文件名带 hash 写法:

实际做法是通过构建工具自动生成清单,SW 里动态读取。


正确做法:构建时注入缓存清单

方案一:Webpack/Vite 插件自动生成(推荐)

Vite 示例(vite-plugin-pwa

javascript 复制代码
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa'

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        // 自动扫描构建产物,不用你写文件名
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        // 运行时缓存 API
        runtimeCaching: [
          {
            urlPattern: /^\/api\/.*/,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }
            }
          }
        ]
      }
    })
  ]
}

构建后自动生成 sw.js,内容大概这样:

javascript 复制代码
// 自动生成的 sw.js,不是手写的
const assets = [
  "/index.html",
  "/assets/index-a3f2b1c.js",    // 带 hash,构建时自动替换
  "/assets/vendor-7d8e9f2.js",
  "/assets/style-4b5c6d7.css"
];
// ...

Webpack 用 workbox-webpack-plugin,原理一样。


方案二:构建脚本生成清单文件

如果不方便用插件,自己写一个:

javascript 复制代码
// scripts/generate-sw-manifest.js
const fs = require('fs');
const path = require('path');

const distDir = path.resolve(__dirname, '../dist');
const files = fs.readdirSync(distDir);

const assets = files
  .filter(f => /\.(js|css|html)$/.test(f))
  .map(f => `/${f}`);

const swTemplate = fs.readFileSync('./sw-template.js', 'utf8');
const swContent = swTemplate.replace(
  '/* ASSETS_PLACEHOLDER */',
  JSON.stringify(assets)
);

fs.writeFileSync('./dist/sw.js', swContent);
javascript 复制代码
// sw-template.js
const PRECACHE = /* ASSETS_PLACEHOLDER */;  // 构建时替换为 ["index.html","app.a3f2b1.js",...]

self.addEventListener('install', (e) => {
  e.waitUntil(
    caches.open('app-v1').then(cache => cache.addAll(PRECACHE))
  );
});

package.json 里加一步:

json 复制代码
"build": "vite build && node scripts/generate-sw-manifest.js"

方案三:运行时从 HTML 解析(兜底)

如果构建产物变了但 SW 没更新,可以运行时动态发现:

javascript 复制代码
// sw.js
self.addEventListener('install', async (e) => {
  const cache = await caches.open('app-v1');
  
  // 先缓存 HTML
  await cache.add('/index.html');
  
  // 从缓存的 HTML 里解析出引用的 js/css
  const htmlRes = await cache.match('/index.html');
  const htmlText = await htmlRes.text();
  
  const assets = [];
  // 正则匹配 <script src="/assets/xxx.js"> 和 <link href="/assets/xxx.css">
  htmlText.replace(/src="(\/assets\/[^"]+\.js)"/g, (_, url) => assets.push(url));
  htmlText.replace(/href="(\/assets\/[^"]+\.css)"/g, (_, url) => assets.push(url));
  
  await cache.addAll(assets);
});

缺点:依赖 HTML 结构,不太稳定,只是兜底思路。


一句话

生产环境 SW,要么用 vite-plugin-pwa/workbox-webpack-plugin 自动生成,要么自己写构建脚本注入清单。hash 文件名是构建工具管的,SW 只负责按清单执行缓存策略。

4、 SW 的数据是存储在哪里的?

Service Worker 本身不存储数据,它通过以下独立存储机制存数据:


三种存储方式

存储 API 存什么 容量 特点
CacheStorage caches HTTP 请求/响应对(Request/Response 通常几十~几百 MB 专为 SW 设计,匹配请求 URL
IndexedDB indexedDB 结构化数据(对象、JSON) 通常几百 MB~几 GB 事务型数据库,支持索引
localStorage 不能用 --- --- SW 是独立线程,无法访问主线程的 localStorage

具体说明

1. CacheStorage(缓存静态资源)

javascript 复制代码
// sw.js
const cache = await caches.open('app-v1');

// 存:把 HTTP 响应缓存下来
await cache.add('/api/user');           // 自动发请求,缓存响应
await cache.put(req, response.clone()); // 手动存入

// 取:按请求匹配
const matched = await cache.match('/api/user');

存的是 Response 对象,不是原始数据。适合缓存:

  • HTML/JS/CSS 文件
  • API 接口返回
  • 图片资源

2. IndexedDB(存业务数据)

javascript 复制代码
// sw.js 里可以用 IndexedDB
const db = await openDB('my-db', 1, {
  upgrade(db) {
    db.createObjectStore('orders', { keyPath: 'id' });
  }
});

// 存
await db.put('orders', { id: 1, status: 'pending', data: {...} });

// 取
const order = await db.get('orders', 1);

适合场景

  • 离线时暂存用户操作(如待提交的表单)
  • 大量结构化数据缓存
  • 需要查询、索引的业务数据

3. 为什么 localStorage 不能用?

javascript 复制代码
// sw.js 里这样写会报错
localStorage.setItem('key', 'value');  // ❌ ReferenceError: localStorage is not defined

原因 :Service Worker 运行在独立的 Worker 线程 ,与主页面线程隔离。localStorage 属于主线程的同步 API,SW 访问不到。


存储位置(物理层面)

这些数据最终存在浏览器的用户数据目录

浏览器 路径(大致)
Chrome %LocalAppData%\Google\Chrome\User Data\Default\ 下的 IndexedDB/Cache 目录
Edge 类似 Chrome(同内核)
Firefox %AppData%\Mozilla\Firefox\Profiles\...\

开发者工具查看

  • F12 → Application 面板 → Cache Storage / IndexedDB
  • 可以手动查看、删除

一句话总结

SW 的数据存在 CacheStorage (缓存 HTTP 响应)和 IndexedDB (存结构化业务数据)里,不能用 localStorage。两者都是浏览器独立的持久化存储,与主页面线程隔离。

5、const cache = await caches.open('app-v1'); 这里的caches,app-v1是什么?分别从哪来的?

cachesapp-v1 分别是什么

caches --- 全局对象,浏览器内置

javascript 复制代码
const cache = await caches.open('app-v1');
//      ↑ 这个 caches 是浏览器提供的全局变量
属性 说明
来源 浏览器原生 API,不是你自己定义的,也不是 import 来的
全称 CacheStorage 接口的实例
作用域 同源(same-origin)------ 每个域名有自己的独立 caches
可用位置 Service Worker 线程、主页面线程都能用

主页面里也能访问

javascript 复制代码
// 普通 JS 里也能操作,但通常交给 SW 管理
const cache = await caches.open('my-cache');
await cache.add('/some.png');

app-v1 --- 你自己起的缓存名称(字符串)

javascript 复制代码
await caches.open('app-v1');
//              ↑ 这个字符串完全由你定义
属性 说明
本质 缓存仓库的名字,类似数据库名
作用 隔离不同版本的缓存,方便管理和清理
自定义 爱叫什么叫什么,通常带版本号

常见命名方式

javascript 复制代码
const CACHE_NAME = 'app-v1';        // 简单版本号
const CACHE_NAME = 'app-20240619';  // 日期版本
const CACHE_NAME = 'app-' + __BUILD_HASH__;  // 构建哈希(推荐)

完整流程

csharp 复制代码
浏览器全局对象 caches
    ↓
caches.open('app-v1')   ← 你起的名字
    ↓
返回一个 Cache 实例(这个"仓库"的操作句柄)
    ↓
cache.add('/index.html')   // 往这个仓库里放东西
cache.match('/index.html') // 从这个仓库里取东西

为什么叫 app-v1 而不是固定名字?

为了更新

javascript 复制代码
// 旧版本
const OLD_CACHE = 'app-v1';

// 新版本上线,换名字
const NEW_CACHE = 'app-v2';

// 激活时清理旧缓存
self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys().then((names) => {
      return Promise.all(
        names
          .filter((name) => name !== NEW_CACHE)  // 只保留当前版本
          .map((name) => caches.delete(name))      // 删掉 app-v1 等旧缓存
      );
    })
  );
});

如果不换名字,新 SW 安装后,旧资源还在缓存里,用户永远拿不到更新。


一句话

东西 哪来的 谁控制
caches 浏览器原生全局对象 浏览器提供
'app-v1' 你自己写的字符串 你定义,通常带版本号用于更新
相关推荐
假如让我当三天老蒯1 小时前
前端跨域解决方案(学习用)
前端·javascript·面试
阡陌Jony1 小时前
关于前端路由中的参数问题的学习(二)
前端
IT_陈寒2 小时前
SpringBoot自动配置这个坑,我踩进去又爬出来了
前端·人工智能·后端
runnerdancer11 小时前
LLM是怎么处理messages数组的,提示词缓存又是什么
前端·agent
陈随易12 小时前
VSCode的Copilot扩展支持接入DeepSeek,Kimi了!
前端·后端·程序员
我不是外星人13 小时前
有了 Harness Engineering ,真的还需要研发工程师吗?
前端·后端·ai编程
IT_陈寒16 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
Jackson__17 小时前
分享一个横向滚动案例,带悬停暂停,通用性很强
前端
MariaH18 小时前
git rebase的使用
前端