Skip to content

离线与缓存策略 ​

缓存策略 ​

1. Cache First(缓存优先) ​

js
// 先查缓存,有则返回;没有则请求并缓存
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached

      return fetch(event.request).then((response) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone())
          return response
        })
      })
    })
  )
})
// 先查缓存,有则返回;没有则请求并缓存
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached

      return fetch(event.request).then((response) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone())
          return response
        })
      })
    })
  )
})

适用: 静态资源(图片、字体、CSS、JS) 特点: 速度快,离线可用

2. Network First(网络优先) ​

js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone())
          return response
        })
      })
      .catch(() => caches.match(event.request))
  )
})
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        return caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, response.clone())
          return response
        })
      })
      .catch(() => caches.match(event.request))
  )
})

适用: API 请求、需要实时数据 特点: 在线时取最新数据,离线时用缓存

3. Stale While Revalidate(stale 时重新验证) ​

js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone())
          return response
        })
        // 返回缓存数据,同时在后台更新
        return cached || fetchPromise
      })
    })
  )
})
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone())
          return response
        })
        // 返回缓存数据,同时在后台更新
        return cached || fetchPromise
      })
    })
  )
})

适用: 非关键数据,用户不期望即时更新(头像、列表) 特点: 极快响应,后台更新

4. Network Only(仅网络) ​

js
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request))
})
self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request))
})

适用: 支付、表单提交等需要实时验证的操作

5. Cache Only(仅缓存) ​

js
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request))
})
self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request))
})

适用: 预缓存资源(应用 Shell)

策略选择表 ​

资源类型推荐策略说明
应用 ShellCache FirstHTML/CSS/JS 骨架
图片/字体Cache First不常变化
API 数据Network First需要最新数据
图片列表Stale While Revalidate快速展示,后台更新
支付Network Only不信任缓存
图标/LogoCache Only预缓存

Workbox(Google 官方库) ​

大幅简化 SW 编写。

安装 ​

bash
npm install workbox-webpack-plugin
# 或 Vite
npm install vite-plugin-pwa
npm install workbox-webpack-plugin
# 或 Vite
npm install vite-plugin-pwa

Workbox 配置 ​

js
import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 预缓存(注入清单)
precacheAndRoute(self.__WB_MANIFEST)

// 图片 — 缓存优先
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
      }),
    ],
  })
)

// API — 网络优先
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
)

// 静态资源 — stale while revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script' ||
    request.destination === 'font',
  new StaleWhileRevalidate({
    cacheName: 'static',
  })
)
import { precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
import { ExpirationPlugin } from 'workbox-expiration'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'

// 预缓存(注入清单)
precacheAndRoute(self.__WB_MANIFEST)

// 图片 — 缓存优先
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
      }),
    ],
  })
)

// API — 网络优先
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
)

// 静态资源 — stale while revalidate
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script' ||
    request.destination === 'font',
  new StaleWhileRevalidate({
    cacheName: 'static',
  })
)

Vite PWA 插件 ​

ts
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico'],
      manifest: {
        name: 'My App',
        short_name: 'App',
        theme_color: '#ffffff',
        icons: [
          { src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
            },
          },
        ],
      },
    }),
  ],
})
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico'],
      manifest: {
        name: 'My App',
        short_name: 'App',
        theme_color: '#ffffff',
        icons: [
          { src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
            },
          },
        ],
      },
    }),
  ],
})

IndexedDB + Service Worker ​

js
// 在 SW 中使用 IndexedDB 存储离线数据
import { openDB } from 'idb'

self.addEventListener('sync', async (event) => {
  if (event.tag === 'sync-posts') {
    const db = await openDB('offline-db', 1)
    const tx = db.transaction('pending-posts', 'readonly')
    const posts = await tx.store.getAll()

    for (const post of posts) {
      await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post),
      })
    }
  }
})
// 在 SW 中使用 IndexedDB 存储离线数据
import { openDB } from 'idb'

self.addEventListener('sync', async (event) => {
  if (event.tag === 'sync-posts') {
    const db = await openDB('offline-db', 1)
    const tx = db.transaction('pending-posts', 'readonly')
    const posts = await tx.store.getAll()

    for (const post of posts) {
      await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(post),
      })
    }
  }
})