用 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
}相关阅读
- 离线游戏的 PWA
- 流式资源加载
- 发布一个快速加载的网页游戏
- 游戏的 Web Audio API —— 为离线播放缓存音频资源
- IndexedDB 游戏存档 —— 在缓存资源之外持久化游戏状态
外部资源
- MDN: Service Worker API —— 完整 API 参考
- MDN: Cache API —— Cache 接口参考
- web.dev: Service workers —— 缓存策略与模式
- Workbox —— Google 的 service worker 工具库