Skip to content

用 Service Worker 缓存游戏资源

Service Worker 让你控制资源如何缓存和服务。对游戏来说,这意味着重复访问时即时加载和离线支持。

1)Service worker 基础

在根目录创建 sw.js

js
// sw.js
self.addEventListener('install', (event) => {
  console.log('Service worker installing')
})

self.addEventListener('activate', (event) => {
  console.log('Service worker activated')
})

self.addEventListener('fetch', (event) => {
  // 拦截所有网络请求
})

注册它:

js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered'))
    .catch(err => console.error('SW registration failed:', err))
}

2)缓存优先策略(游戏资源最适合)

从缓存读,没缓存才发请求:

js
const CACHE_NAME = 'game-assets-v1'

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        return cached
      }
      
      return fetch(event.request).then((response) => {
        // 不缓存非 ok 响应或非 GET 请求
        if (!response.ok || event.request.method !== 'GET') {
          return response
        }
        
        // 克隆响应用于缓存
        const clone = response.clone()
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, clone)
        })
        
        return response
      })
    })
  )
})

3)预缓存关键资源

install 事件是预缓存的好时机:

js
const CACHE_NAME = 'game-v1'

const PRECACHE = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
  '/assets/sprites.png',
  '/assets/tileset.png',
  '/assets/player.png',
]

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(PRECACHE))
      .then(() => self.skipWaiting())
  )
})

4)动态内容用网络优先

对会变化的东西(排行榜、玩家数据)用网络优先:

js
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // API 调用:网络优先
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request))
    return
  }
  
  // 资源:缓存优先
  event.respondWith(cacheFirst(event.request))
})

async function networkFirst(request) {
  try {
    const response = await fetch(request)
    const cache = await caches.open('api-cache')
    cache.put(request, response.clone())
    return response
  } catch {
    return caches.match(request)
  }
}

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

5)Stale-while-revalidate

立刻返回缓存,后台更新:

js
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAME)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  })
  
  return cached || fetchPromise
}

6)缓存版本化与清理

在 activate 时清理旧缓存:

js
const CACHE_VERSION = 'v2'
const CACHE_NAME = `game-${CACHE_VERSION}`

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => {
      return Promise.all(
        keys
          .filter(key => key.startsWith('game-') && key !== CACHE_NAME)
          .map(key => {
            console.log('Deleting old cache:', key)
            return caches.delete(key)
          })
      )
    }).then(() => self.clients.claim())
  )
})

7)按类型拆分缓存

按类型组织缓存,更好管理:

js
const CACHES = {
  static: 'static-v1',    // HTML、JS、CSS
  images: 'images-v1',    // 精灵、纹理
  audio: 'audio-v1',      // 音乐、音效
  api: 'api-v1',          // API 响应
}

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  if (url.pathname.match(/\.(png|jpg|webp)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.images))
  } else if (url.pathname.match(/\.(mp3|ogg|wav)$/)) {
    event.respondWith(cacheFirst(event.request, CACHES.audio))
  } else if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, CACHES.api))
  } else {
    event.respondWith(cacheFirst(event.request, CACHES.static))
  }
})

8)音视频的 Range 请求

正确处理 range 请求(音频跳转需要):

js
self.addEventListener('fetch', (event) => {
  if (event.request.headers.has('range')) {
    event.respondWith(handleRangeRequest(event.request))
    return
  }
  // ... 正常处理
})

async function handleRangeRequest(request) {
  const cache = await caches.open(CACHES.audio)
  const cached = await cache.match(request.url)
  
  if (cached) {
    return cached
  }
  
  // 拉取完整资源并缓存
  const response = await fetch(request.url)
  cache.put(request.url, response.clone())
  return response
}

9)更新通知

新版本就绪时告诉玩家:

js
// 在主应用中
let refreshing = false

navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return
  refreshing = true
  
  showUpdateBanner()
})

function showUpdateBanner() {
  const banner = document.createElement('div')
  banner.className = 'update-banner'
  banner.innerHTML = `
    <span>New version available!</span>
    <button onclick="location.reload()">Update</button>
  `
  document.body.appendChild(banner)
}

10)完整的游戏 service worker

js
const VERSION = 'v1'
const STATIC_CACHE = `static-${VERSION}`
const ASSET_CACHE = `assets-${VERSION}`

const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/game.js',
  '/style.css',
]

// install:预缓存关键资源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(() => self.skipWaiting())
  )
})

// activate:清理旧缓存
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys
        .filter(key => !key.endsWith(VERSION))
        .map(key => caches.delete(key))
    )).then(() => self.clients.claim())
  )
})

// fetch:把请求路由到合适的策略
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url)
  
  // 仅同源
  if (url.origin !== location.origin) {
    return
  }
  
  // API:网络优先 + 缓存兜底
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request, 'api-cache'))
    return
  }
  
  // 资源:缓存优先
  if (url.pathname.startsWith('/assets/')) {
    event.respondWith(cacheFirst(event.request, ASSET_CACHE))
    return
  }
  
  // HTML/JS/CSS:stale while revalidate
  event.respondWith(staleWhileRevalidate(event.request, STATIC_CACHE))
})

async function cacheFirst(request, cacheName) {
  const cached = await caches.match(request)
  if (cached) return cached
  
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return new Response('Offline', { status: 503 })
  }
}

async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request)
    if (response.ok) {
      const cache = await caches.open(cacheName)
      cache.put(request, response.clone())
    }
    return response
  } catch {
    return caches.match(request) || new Response('Offline', { status: 503 })
  }
}

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName)
  const cached = await cache.match(request)
  
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) {
      cache.put(request, response.clone())
    }
    return response
  }).catch(() => cached)
  
  return cached || fetchPromise
}

相关阅读

外部资源