Hoppa till innehåll

Offline med Cache API

En service worker som bara finns gör ingen skillnad. Det är när den svarar på förfrågningarna, och har svaren sparade, som applikationen börjar fungera offline. Den här artikeln bygger färdigt service workern för memoryspelet med Cache API, och den bygger på föregående artikel, Service workers.

Tidsåtgång: Ungefär 15 minuter.

Cache API sparar svar på förfrågningar. Det är ett asynkront API, och det finns både i service workern och på sidan, under namnet caches:

  • caches håller ordning på namngivna cachar. caches.open(name) öppnar en cache och skapar den om den inte finns, caches.keys() ger namnen och caches.delete(name) tar bort en.
  • En cache lagrar par av ett Request och ett Response, samma objekt som du arbetade med i Nätverksanrop med fetch. cache.put(request, response) sparar ett par, cache.match(request) letar upp svaret och cache.addAll(urls) hämtar och sparar flera adresser på en gång.
const cache = await caches.open('memory-v1')
await cache.addAll(['./', './manifest.webmanifest'])
const response = await cache.match('./')
console.log(response.status) // 200

cache.match() ger undefined om inget svar finns. Cacharna räknas in i samma kvot som IndexedDB, den du kan fråga om med navigator.storage.estimate() från Lagring i webbläsaren.

I fetch-händelsen bestämmer service workern hur förfrågan ska besvaras. Den anropar event.respondWith() med ett svar, eller ett löfte om ett svar:

self.addEventListener('fetch', (event) => {
const { request } = event
if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) {
return
}
event.respondWith(networkFirst(request))
})

Anropar lyssnaren inte respondWith() går förfrågan till nätverket som vanligt, som om service workern inte fanns. Det utnyttjar villkoret överst:

  • Bara GET. Ett POST ändrar något på servern och ska inte besvaras från en cache. cache.put() vägrar dessutom att spara det.
  • Bara samma origin. Förfrågningar till andra webbplatser, som ett API, lämnas åt nätverket. Att visa gammal data från ett API när användaren är offline är ett beslut om applikationen, inte något som ska hända av sig självt.

respondWith() måste anropas direkt i lyssnaren, inte efter en await. Därför får den ett löfte från networkFirst(), i stället för att lyssnaren väntar på svaret själv.

WebSocket-anslutningen i Messages passerar aldrig service workern. Den är inget fetch-anrop, och den behöver en server i andra änden. Offline kan Messages starta, men inte skicka något.

Den första strategin frågar nätverket först. Går det bra sparas en kopia av svaret, och går det inte används kopian:

async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME)
try {
const response = await fetch(request)
if (response.ok) {
await cache.put(request, response.clone())
}
return response
} catch (error) {
const cached = await cache.match(request)
if (cached) {
return cached
}
throw error
}
}
Sekvensdiagram med sidan, service workern, cachen memory-v1 och nätverket. Sidan ger en fetch-händelse för /assets/index-DpQX1ODm.js, och service workern anropar fetch mot nätverket. Om uppkopplad: nätverket svarar med status 200, service workern sparar response.clone() i cachen och skickar svaret till sidan. Om offline: fetch-löftet avvisas med ett TypeError, service workern letar upp svaret i cachen med match och skickar det cachade svaret till sidan.

fetch() avvisar sitt löfte bara när det inte går att nå servern alls. Ett svar med status 404 är ett svar, och därför kontrollerar response.ok att bara lyckade svar sparas. Annars kunde en tillfällig 404 ersätta en fungerande kopia. Saknas både nätverk och kopia kastas felet vidare, och sidan får ett nätverksfel, precis som utan service worker.

Vad händer om networkFirst() sparar och returnerar samma svar, utan clone()?

await cache.put(request, response)
return response

Sidan får ett nätverksfel. Kroppen i ett Response är en ström som bara kan läsas en gång, på samma sätt som du bara kan anropa response.json() en gång. cache.put() läser den för att spara den, och svaret som sedan returneras har en förbrukad kropp, som respondWith() inte kan använda.

clone() skapar ett andra svar med en egen kopia av kroppen, så att ett kan sparas och ett skickas till sidan.

Network first ger alltid den senaste versionen när användaren är uppkopplad, och den senaste kopian när användaren inte är det. Priset är att varje förfrågan går till nätverket först, och på en uppkoppling som finns men inte fungerar kan det dröja innan fetch() ger upp. Mer om det i Online, offline och Lie-Fi i kursvecka 16.

Den andra strategin vänder på ordningen: finns en kopia används den, och nätverket frågas bara om den saknas.

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

Det är snabbare, men ger aldrig en nyare version av en fil som redan finns i cachen. Det passar för filer vars innehåll aldrig ändras under samma adress, och sådana skapar Vite åt dig. När du kör npm run build får varje fil som byggs ett namn med en hash av innehållet:

dist/
├── index.html
├── sw.js
├── manifest.webmanifest
└── assets/
├── index-DpQX1ODm.js
├── index-CGINoPm8.css
├── 1-ADx4FO_M.png
└── ...

Ändrar du koden får index.js ett nytt namn, och index.html pekar på det nya namnet. En fil under /assets/ ändras alltså aldrig, den ersätts av en fil med ett annat namn. Service workern kan välja strategi efter adressen:

event.respondWith(
new URL(request.url).pathname.startsWith('/assets/') ? cacheFirst(request) : networkFirst(request)
)

Det går ju snabbare. Du byter till cacheFirst() för alla förfrågningar, också för dokumentet. Applikationen fungerar fint offline. Sedan rättar du en bugg, bygger och publicerar. Vad ser användarna?

Den gamla versionen, med buggen. Dokumentet, index.html, finns i cachen och används utan att nätverket frågas. Det pekar på de gamla filerna i /assets/, som också finns i cachen. Den nya versionen hämtas aldrig.

Det ändras först om sw.js ändras, så att en ny version installeras och rensar cachen. Dokumentet, och andra filer vars namn inte ändras, behöver network first, så att användaren får den senaste versionen när nätverket finns.

Med network first och cache first sparas en fil första gången den hämtas genom service workern. Det kallas att cacha under körning. Det går också att cacha i förväg, i install, så att filerna finns redan innan någon har bett om dem:

const PRECACHE_URLS = ['./', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png']
self.addEventListener('install', (event) => {
event.waitUntil(precache())
})
async function precache() {
const cache = await caches.open(CACHE_NAME)
await cache.addAll(PRECACHE_URLS)
}

Listan innehåller bara filer från public/ och dokumentet, vars namn är kända. Filerna under /assets/ kan inte stå där. Deras namn bestäms av Vite vid varje bygge, och sw.js kopieras från public/ utan att Vite ändrar i den.

Det får en följd som är värd att känna till. Vid första besöket styr service workern inte sidan, som du såg i föregående artikel. Skripten, stilmallen och bilderna hämtas förbi den och hamnar inte i cachen. Applikationen fungerar alltså offline från och med andra besöket, när allt hämtas genom service workern.

För B3 räcker det. Kravet för högre betyg är att applikationen fungerar offline, inklusive underapplikationer som har öppnats minst en gång medan användaren var uppkopplad, och det är precis vad cachning under körning ger. Vill du att första besöket ska räcka behöver service workern veta byggets filnamn. Det är vad verktyg som Workbox, och insticksprogrammet vite-plugin-pwa som bygger på det, gör: de skriver listan i service workern när du bygger.

Cachens namn, memory-v1, har två delar: ett prefix för applikationen och en version. Ändrar du hur service workern cachar, till exempel vad som cachas i förväg, byter du version. Den nya versionen av service workern skapar då en ny cache, och tar bort de gamla när den aktiveras:

const CACHE_PREFIX = 'memory-'
const CACHE_NAME = `${CACHE_PREFIX}v2`
self.addEventListener('activate', (event) => {
event.waitUntil(deleteOldCaches())
})
async function deleteOldCaches() {
const names = await caches.keys()
const oldNames = names.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
await Promise.all(oldNames.map((name) => caches.delete(name)))
}

Prefixet är till för samma sak som prefixet på nycklarna i Web Storage: cacharna delas av allt på samma origin. En service worker som tar bort alla cachar utom sin egen kan ta bort en annan applikations.

Byt bara version när du behöver. Med network first uppdateras kopiorna ändå varje gång nätverket finns, och en ny version börjar med en cache som bara innehåller det som cachas i förväg. Resten fylls på under körning.

public/sw.js
const CACHE_PREFIX = 'memory-'
const CACHE_NAME = `${CACHE_PREFIX}v1`
const PRECACHE_URLS = ['./', './manifest.webmanifest', './icons/icon-192.png', './icons/icon-512.png']
self.addEventListener('install', (event) => {
event.waitUntil(precache())
})
self.addEventListener('activate', (event) => {
event.waitUntil(deleteOldCaches())
})
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
if (request.method !== 'GET' || url.origin !== self.location.origin) {
return
}
event.respondWith(url.pathname.startsWith('/assets/') ? cacheFirst(request) : networkFirst(request))
})
/**
* Caches the files whose names are known before the application has run.
*
* @returns {Promise<void>} Fulfilled when every file is cached.
*/
async function precache() {
const cache = await caches.open(CACHE_NAME)
await cache.addAll(PRECACHE_URLS)
}
/**
* Deletes this application's caches from earlier versions.
*
* @returns {Promise<void>} Fulfilled when the old caches are deleted.
*/
async function deleteOldCaches() {
const names = await caches.keys()
const oldNames = names.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
await Promise.all(oldNames.map((name) => caches.delete(name)))
}
/**
* Answers from the network and keeps a copy, or from the copy when offline.
*
* @param {Request} request - The request to answer.
* @returns {Promise<Response>} The response.
*/
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME)
try {
const response = await fetch(request)
if (response.ok) {
await cache.put(request, response.clone())
}
return response
} catch (error) {
const cached = await cache.match(request)
if (cached) {
return cached
}
throw error
}
}
/**
* Answers from the cache, and from the network only when nothing is cached.
*
* @param {Request} request - The request to answer.
* @returns {Promise<Response>} The response.
*/
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
if (cached) {
return cached
}
const response = await fetch(request)
if (response.ok) {
await cache.put(request, response.clone())
}
return response
}

Funktionerna kan anropas i lyssnarna ovanför sina deklarationer, eftersom funktionsdeklarationer lyfts. Det är först när en händelse kommer som de körs.

  1. Kör npm run build och npm run serve, och öppna http://localhost:4173 i Chrome eller Edge.
  2. Ladda om sidan en gång, så att allt hämtas genom service workern.
  3. Kontrollera under Application → Cache storage att memory-v1 innehåller dokumentet och filerna under /assets/.
  4. Kryssa i Offline under Application → Service workers, eller välj Offline i fliken Network, och ladda om. Spelet ska starta och gå att spela, med alla bilder.

Bilderna i memoryspelet importeras från koden och hamnar under /assets/, så de cachas när de visas första gången. I B3 betyder det att en underapplikation fungerar offline när den har öppnats en gång, som kravet säger. En bild som bara hämtas när något särskilt händer i spelet finns inte i cachen förrän det har hänt.

  • Cache API sparar par av Request och Response i namngivna cachar, och finns både i service workern och på sidan.
  • event.respondWith() besvarar en förfrågan. Låt bli att anropa den för det som inte ska cachas, som POST och förfrågningar till andra origins.
  • Network first passar för det som kan ändras under samma adress, som dokumentet. Cache first passar för Vites filer med hash i namnet.
  • Spara bara lyckade svar, och clone() svaret som både sparas och returneras.
  • Filerna under /assets/ cachas under körning, och applikationen fungerar offline från andra besöket.
  • Ge cachen ett prefix och en version, och ta bara bort applikationens egna gamla cachar.
CCBY