返回首页

PWA离线缓存

布莱克2026-02-06 18:38已编辑
Tip:文章封面与内容无关,作者旅游时拍摄,因为没什么值得把四季都错过!

最近在做一些优化,关于页面如何实现秒开,对图片做了懒加载,还有一个重要的优化点,就是实现离线缓存,这样在没有网络的情况下依然可以访问页面,另外在有网络的情况下访问缓存,加载速度会大幅提升

什么是PWA?

PWA(Progressive Web App,渐进式 Web 应用) 是一种结合了 Web 和原生应用优势的新型应用模式。使用现代 Web 技术,让网站具备类似原生应用的用户体验

他的功能有很多,先介绍两个,其余的后续研究一下再补充

📲 可安装 - 添加到主屏幕,全屏体验

📶 离线可用 - 在网络不稳定或离线时也能使用

目前该系统添加了安装以及离线缓存功能,该文章的重点在于离线缓存

讲离线缓存之前绕不开的就是Service Worker

什么是Service Worker(SW)?

在没有 SW 之前,浏览器请求资源是直线的:浏览器 -> 网络 -> 服务器。

有了 SW 之后,请求变成了折线

  1. 独立生存:SW 运行在浏览器后台,独立于你的网页。即使你关闭了网页标签,SW 依然可以存活(这就是为什么它能处理推送通知)。
  2. 全权代理:它会拦截网页发出的每一个网络请求。
  3. 可编程性:你可以写代码告诉它:如果这个资源在缓存里有,就直接给网页;如果没有,再去联网找。

离线缓存的灵魂——存储策略

离线缓存不只是把文件存起来,更重要的是怎么拿。在 sw.js 配置中,用到了几种经典策略:

1. 网络优先 (Network First)

  • 逻辑:先联网,断网了再看缓存。
  • 适用:你的 _payload.json 或 API 请求。你希望用户看到最新的博客内容,只有没网时才看旧的。

2. 缓存优先 (Cache First)

  • 逻辑:先看缓存,没有再联网。
  • 适用:图片、字体。这些资源几乎不更新,直接从本地拿能让页面秒开。

3. 重新验证时失效 (Stale-While-Revalidate)

  • 逻辑:先给用户看缓存(快!),同时后台偷偷联网下个新的,下次用。
  • 适用:CSS 样式表。这在速度和新鲜度之间取得了完美平衡。

PWA 离线缓存,本质上是理解如何通过 Service Worker (SW) 在浏览器和网络之间架设一个“智能代理层”

以Nuxt项目为例,讲解一下如何实现离线缓存

@vite-pwa/nuxt

安装该插件,也是Nuxt官方推荐使用,以实现PWA离线缓存,文档 https://nuxt.com/modules/vite-pwa-nuxt

在config的配置文件中添加配置:

import { defineNuxtConfig } from 'nuxt/config'

export default defineNuxtConfig({
  modules: [
    '@vite-pwa/nuxt'
  ],
  //里面进行相关配置
  pwa: {
    /* PWA options */
  }
})

核心配置sw.js

从workbox中引入核心模块,Workbox 是 Google 开发的一套 JavaScript 库,旨在简化 Service Worker 的开发

// 当安装 @vite-pwa/nuxt 时,它已经作为“底层依赖”自动帮你下载好了这些以 workbox- 开头的包
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response'; // 注意这里
import { ExpirationPlugin } from 'workbox-expiration';

// self 指代的是 Service Worker 的全局作用域对象
// 强制让当前正在安装的 Service Worker 跳过“等待”状态,直接上位
self.addEventListener('install', () => self.skipWaiting());
// 新激活的 SW 会立刻接管现有的网页,不需要用户手动刷新
self.addEventListener('activate', (event) => event.waitUntil(clients.claim()));

//构建工具注入静态资源列表,SW 会在安装时下载这些资源并永久存入 Cache Storage
//根据你在 nuxt.config.ts 中的 pwa 配置自动扫描出来的:
//扫描范围:它会去扫描 .output/public 文件夹。
//过滤条件:取决于你的 globPatterns 配置(如 **/*.{js,css,html,png})。
//排除条件:取决于你的 globIgnores(如你配置的 **/_nuxt/builds/meta/dev.json 会被踢出清单,不会出现在这里)
precacheAndRoute(self.__WB_MANIFEST || []);
cleanupOutdatedCaches();

registerRoute 负责匹配与执行

A. 匹配 (Matching)

它会监听网页发出的每一个请求。你可以告诉它:“嘿,只要是去 /api/ 的请求,或者所有的 .png 图片,你都把它拦下来。”

B. 执行策略 (Handling)

一旦拦截成功,它会按照你指定的“套路”(策略)去处理。比如:

  • “这个请求很急,直接去仓库(Cache)拿。”
  • “这个请求需要最新数据,先去网上(Network)看,不行再回仓库。”

通常接受三个参数:

registerRoute(
  match,    // 1. 匹配条件:谁该被拦截?
  handler,  // 2. 处理器/策略:拦下来后怎么处理?
  method    // 3. (可选) 请求方法:是拦截 GET 还是 POST?(默认 GET)
);

当你的浏览器发起任何请求(比如加载一张图片、跳转一个页面或请求一个接口)时,这个请求都会经过 registerRoute。Workbox 会把这个请求封装成两个对象传给你:request 和 url

1.request 是一个原生的 Request 对象。它代表了浏览器想做什么以及怎么做

request.destination:最实用的属性。它告诉 Service Worker 这个请求是用来干什么的。

  • 可能的值:document (页面), script (JS文件), style (CSS), image (图片), font (字体) 等。
  • 用途:你可以根据类型一键分类,比如“所有的图片都用缓存优先策略”

2.url 是一个标准的 URL 对象。它代表了这个请求去哪里

  • url.pathname:路径部分(不含域名)。示例:/api/v1/posts 或 /_nuxt/entry.js。
  • url.origin:域名部分(如 https://yourblog.com)。用途:判断是自己的请求还是第三方 CDN(如 Google Fonts)的请求

NetworkFirst(网络优先)

是 Workbox 中最常用、也是最能平衡“内容新鲜度”与“离线可用性”的策略

核心逻辑是:“先联网要最新的,如果网不行,再回仓库拿旧的。”

NetworkFirst 是最“昂贵”的策略(因为它总是产生网络延迟),所以必须用在刀刃上:

A. 动态数据 API

  • 例子:博客的文章列表、用户的个人中心数据、实时评论。
  • 理由:用户无法忍受刷新后看到的还是半小时前的评论,但在没网时,看到半小时前的评论总比白屏强。

B. 导航 HTML(App Shell 之外的内容)

  • 例子:/articles/123 的 HTML 页面。
  • 理由:这是网页的“元数据”,它决定了页面引用哪个版本的 JS 和 CSS。如果 HTML 过旧,可能会导致整个页面逻辑失效。

C. 临时性、时效性极强的配置

  • 例子:运营活动开关、弹窗配置。
  • 理由:需要尽可能保证实时下发

深入:手机显示有信号但实际网速极慢,NetworkFirst 策略会卡死用户,应该如何处理?

  • 使用 networkTimeoutSeconds 参数。
  • 在 NetworkFirst 中设置一个阈值(如 3-4 秒)。如果网络在限定时间内没响应,立即切换到缓存模式。这比等待浏览器自身的网络超时(30s+)体验要好得多
  • NavigationRoute

    Workbox 路由系统中专门为**“页面跳转”**(即导航请求)定制的高级工具

    它能精准识别“改变页面主体”的行为,并提供最高优先级的离线保护

    A. 消除“小恐龙”:实现离线兜底 (Offline Fallback)

    B. 解决“刷新即死”:SPA 的刷新支持

    registerRoute(
      ({ request, url }) => 
        request.destination === 'script' || 
        url.pathname.includes('/_payload.json') ||
        url.pathname.startsWith('/_nuxt/'),
      new NetworkFirst({
        cacheName: 'nuxt-assets',
        networkTimeoutSeconds: 2, 
        plugins: [
          new CacheableResponsePlugin({ statuses: [200] }), // 只缓存状态码为 200 的正常响应
        ]
      })
    )
    
    // 导航请求:解决“无法访问此网站” ERR_FAILED 问题
    const navigationHandler = new NetworkFirst({
      cacheName: 'pages-cache',
      networkTimeoutSeconds: 2,
      plugins: [
        new CacheableResponsePlugin({ statuses: [200] })
      ]
    });
    
    registerRoute(
      new NavigationRoute(async (params) => {
        try {
          const response = await navigationHandler.handle(params);
          // 只要缓存里有这个页面的备份,就直接返回
          if (response) return response;
          throw new Error('Response not found');
        } catch (error) {
          // 离线访问一个“从来没打开过”且“没有预缓存”的新页面时,显示 offline.html
          // 这里的兜底逻辑顺序:offline.html -> index.html (SPA入口) -> 抛出错误
          return (await caches.match('/offline.html')) || 
                (await caches.match('/')) || 
                new Response('Offline content not available', { status: 503 });
        }
      })
    );

    StaleWhileRevalidate(从缓存读取时重新验证)

    如果说 NetworkFirst 是为了保证“绝对新”,CacheFirst 是为了“绝对快”,那么 StaleWhileRevalidate 就是在不牺牲用户体验速度的情况下,尽可能保证数据更新

    维度NetworkFirstCacheFirstStaleWhile
    Revalidate
    首选来源网络缓存缓存 (立即返回)
    备选来源缓存网络 (仅当缓存无)网络 (后台异步)
    用户感知慢 (取决于网速)极快极快
    数据新鲜度最新可能非常陈旧次新 (落后一个版本)
    离线体验
    //在断网时依然能保持页面切换逻辑
    registerRoute(
      ({ url }) => url.pathname.includes('/_nuxt/builds/'),
      // 使用 StaleWhileRevalidate,保证离线能用,有网自动更新
      new StaleWhileRevalidate({
        cacheName: 'nuxt-manifest-cache',
        plugins: [
          new CacheableResponsePlugin({ statuses: [0, 200] })
        ]
      })
    );
    // API 请求:网络优先 (修改为 NetworkFirst 实例,避免手动 fetch)
    registerRoute(
      ({ url }) => url.pathname.includes('/api/'),
      new NetworkFirst({
        cacheName: 'api-cache',
        // 网络优先,有网优先返回新数据,等待4s没网自动切换本地缓存
        networkTimeoutSeconds: 4, 
        plugins: [
          new CacheableResponsePlugin({ statuses: [200] }),
          //防止你的 API 缓存无限膨胀。它只会保留最近请求的 50 条记录,设定有效期为 24 小时
          new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }),
          {
            cacheWillUpdate: async ({ response }) => {
              // 只有 200 的 API 响应才允许存入缓存,防止 502/503 污染缓存
              return response.status === 200 ? response : null;
            }
          }
        ]
      })
    )

    缓存策略的选择

    推荐策略理由
    首屏 HTML / API 接口NetworkFirst保证内容新鲜,断网时才用旧数据兜底。
    图片 / 字体 / 库文件CacheFirst资源几乎不更新,直接读缓存性能最高。
    CSS / JS / 配置文件StaleWhileRevalidate保证加载速度(秒开),同时在后台更新以备下次使用。


    assistant