Service worker untuk menyimpan aset game dalam cache
Service worker memungkinkan Anda mengontrol cara aset disimpan dalam cache dan disajikan. Untuk game, ini berarti pemuatan seketika pada kunjungan berikutnya dan dukungan offline.
1) Dasar-dasar service worker
Buat sw.js di direktori root Anda:
// sw.js
self.addEventListener('install', (event) => {
console.log('Service worker installing')
})
self.addEventListener('activate', (event) => {
console.log('Service worker activated')
})
self.addEventListener('fetch', (event) => {
// Intercept all network requests
})Daftarkan:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered'))
.catch(err => console.error('SW registration failed:', err))
}2) Strategi cache-first (terbaik untuk aset game)
Muat dari cache, dan hanya ambil jika belum disimpan dalam cache:
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) => {
// Don't cache non-ok responses or non-GET requests
if (!response.ok || event.request.method !== 'GET') {
return response
}
// Clone response for caching
const clone = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone)
})
return response
})
})
)
})3) Pra-cache aset penting
Event install sangat cocok untuk melakukan pra-cache:
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) Network-first untuk konten dinamis
Gunakan network-first untuk hal-hal yang berubah (papan peringkat, data pemain):
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// API calls: network first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request))
return
}
// Assets: cache first
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
Segera kembalikan versi yang tersimpan dalam cache, lalu perbarui di latar belakang:
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) Pembuatan versi dan pembersihan cache
Bersihkan cache lama saat aktivasi:
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) Pisahkan cache berdasarkan jenis
Atur cache agar lebih mudah dikelola:
const CACHES = {
static: 'static-v1', // HTML, JS, CSS
images: 'images-v1', // Sprites, textures
audio: 'audio-v1', // Music, SFX
api: 'api-v1', // API responses
}
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 request untuk audio/video
Satu hal yang perlu diketahui sejak awal: Cache API tidak akan menyimpan respons parsial. Jika respons 206 Partial Content sampai ke cache.put(), metode tersebut akan memunculkan TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported. Jadi, pola untuk media yang disimpan dalam cache selalu sama: lakukan pra-cache (atau ambil) file lengkap sebagai respons 200 biasa, lalu buat sendiri respons 206 dengan memotong byte yang tersimpan dalam cache setiap kali ada permintaan dengan header Range. Jika tidak ingin membuatnya sendiri, plugin range-requests dari Workbox melakukan pemotongan tersebut untuk Anda ketika dipasangkan dengan strategi CacheFirst.
Tangani range request dengan benar (diperlukan untuk berpindah posisi dalam audio):
self.addEventListener('fetch', (event) => {
if (event.request.headers.has('range')) {
event.respondWith(handleRangeRequest(event.request))
return
}
// ... normal handling
})
async function handleRangeRequest(request) {
const cache = await caches.open(CACHES.audio)
// Match the full resource, ignoring the Range header (which would never match a cached full file)
let cached = await cache.match(request.url)
if (!cached) {
// Fetch the FULL file (string URL drops the Range header) and cache it as a 200
cached = await fetch(request.url)
if (cached.ok) cache.put(request.url, cached.clone()) // cache.put rejects 206, so only store full 200s
}
// Build a 206 Partial Content response from the cached full body
const rangeHeader = request.headers.get('range') // e.g. "bytes=200-1000"
const buffer = await cached.arrayBuffer()
const total = buffer.byteLength
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-')
const start = Number(startStr)
const end = endStr ? Number(endStr) : total - 1
const slice = buffer.slice(start, end + 1)
return new Response(slice, {
status: 206,
statusText: 'Partial Content',
headers: {
'Content-Type': cached.headers.get('Content-Type') || 'application/octet-stream',
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(slice.byteLength),
'Accept-Ranges': 'bytes',
},
})
}9) Notifikasi pembaruan
Beri tahu pemain saat versi baru siap:
// In main app
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>Versi baru tersedia!</span>
<button onclick="location.reload()">Perbarui</button>
`
document.body.appendChild(banner)
}10) Service worker game yang lengkap
const VERSION = 'v1'
const STATIC_CACHE = `static-${VERSION}`
const ASSET_CACHE = `assets-${VERSION}`
const PRECACHE_URLS = [
'/',
'/index.html',
'/game.js',
'/style.css',
]
// Install: precache critical resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
)
})
// Activate: clean old caches
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: route requests to appropriate strategy
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// Same-origin only
if (url.origin !== location.origin) {
return
}
// API: network first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request, 'api-cache'))
return
}
// Assets: cache first
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
}Terkait
- PWA untuk game offline
- Pemuatan aset secara streaming
- Rilis game web yang dimuat dengan cepat
- Web Audio API untuk game — menyimpan aset audio dalam cache untuk permainan offline
- Penyimpanan game dengan IndexedDB — menyimpan status game secara persisten bersama aset yang tersimpan dalam cache
Sumber Eksternal
- MDN: Service Worker API — referensi API lengkap
- MDN: Cache API — referensi antarmuka Cache
- web.dev: Service worker — strategi dan pola penyimpanan cache
- Workbox — pustaka Google untuk alat bantu service worker