Hoppa till innehåll

Online, offline och Lie-Fi

En pålitlig applikation fungerar utan uppkoppling, och det har du ordnat med service workern i kursvecka 15. Men det svåra är sällan att nätverket saknas helt. Det svåra är när det finns, men inte fungerar. Den här artikeln går igenom hur applikationen kan märka det, hur den slutar vänta och hur den berättar för användaren vad som händer.

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

Webbläsaren har en egenskap som ser ut att svara på frågan direkt:

console.log(navigator.onLine) // true
window.addEventListener('offline', () => console.log('offline'))
window.addEventListener('online', () => console.log('online'))

navigator.onLine är false när webbläsaren tror att enheten saknar nätverk, och händelserna offline och online utlöses när det ändras. Men webbläsaren vet bara om enheten är ansluten till ett nätverk, inte om det går att nå din server:

  • true betyder inte att något kommer fram. En dator ansluten till ett wifi utan internetförbindelse, eller med ett virtuellt nätverkskort som alltid är anslutet, räknas som online.
  • false kan också vara fel. På Windows avgörs värdet av om datorn når en server hos Microsoft, och en brandvägg eller VPN kan göra att datorn räknas som offline trots att den når internet.

MDN sammanfattar det med att egenskapen är opålitlig, och att den inte ska användas för att stänga av funktioner, bara för att ge användaren en ledtråd. Den är bra till att visa Du verkar vara offline, men inte till att låta bli att försöka.

Telefonen visar full mottagning, men ingenting laddas. Begreppet Lie-Fi kommer från Jake Archibald, då på Google, och beskriver uppkopplingen som ljuger: den finns, men inget kommer fram, eller så långsamt att det inte spelar någon roll. Det är vanligt på tåg, på en överbelastad mobilmast och på ett hotell-wifi som väntar på att användaren ska logga in.

Lie-Fi är värre än att vara offline. När nätverket saknas misslyckas fetch() direkt, och service workern svarar från cachen. Med Lie-Fi är navigator.onLine true, fetch() misslyckas inte, och promiset förblir pending. fetch() har ingen egen timeout, och det kan dröja minuter innan webbläsaren ger upp. Under tiden ser användaren en tom sida.

Memoryspelet har service workern från kursvecka 15, med network first för dokumentet. Spelaren har spelat flera gånger och allt finns i cachen. Nu sitter spelaren på ett tåg där telefonen har full mottagning, men inga svar kommer fram. Spelaren öppnar spelet. Vad händer?

Ingenting, länge. Service workern frågar nätverket först, och fetch() väntar på ett svar som inte kommer. Det är först när webbläsaren ger upp och fetch() misslyckas som service workern svarar med kopian i cachen.

Utan uppkoppling alls hade spelet startat direkt, eftersom fetch() då misslyckas på en gång. Network first är pålitligt när nätverket saknas, men inte när det ljuger.

Lösningen är att inte vänta hur länge som helst. AbortSignal.timeout() ger en signal som avbryter efter ett visst antal millisekunder, och den ges till fetch() på samma sätt som signalen från en AbortController i Nätverksanrop med fetch:

try {
const response = await fetch(url, { signal: AbortSignal.timeout(8000) })
// ...
} catch (error) {
if (error.name === 'TimeoutError') {
status.textContent = 'Servern svarar inte. Försök igen om en stund.'
}
}

När tiden har gått blir promiset rejected med ett DOMException som heter TimeoutError, inte AbortError. Skillnaden är användbar. AbortError betyder att din egen kod har avbrutit, och det ska ignoreras, som i kursvecka 12. TimeoutError betyder att servern inte svarade, och det ska användaren få veta.

Har komponenten redan en AbortController för förfrågningar som kan bli inaktuella, kombinerar du signalerna med AbortSignal.any(). Förfrågan avbryts av den som kommer först:

const signal = AbortSignal.any([this.#abortController.signal, AbortSignal.timeout(8000)])
const response = await fetch(url, { signal })

Hur lång timeouten ska vara beror på vad användaren väntar på. En sökning som tar mer än några sekunder känns trasig. En uppladdning av en stor fil kan ta minuter, och behöver ingen timeout alls, eller en mycket generös.

Samma signal fungerar i service workern. Det är den enda ändringen som behövs i networkFirst() från kursvecka 15:

public/sw.js
const NETWORK_TIMEOUT_MS = 3000
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME)
try {
const response = await fetch(request, { signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS) })
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
}
}

Svarar nätverket inte inom tre sekunder avbryts fetch(), och felet hamnar i catch, precis som när nätverket saknas. Har du lagt till navigate() från Routing i en SPA gäller timeouten också för navigeringarna, eftersom navigate() anropar networkFirst():

Sekvensdiagram med sidan, service workern, cachen memory-v1 och nätverket. Sidan ger en fetch-händelse för en navigering till /?size=4x2, och service workern anropar fetch med signalen AbortSignal.timeout(3000). Om svar kommer inom 3 sekunder: nätverket svarar med status 200, service workern sparar response.clone() i cachen och skickar svaret till sidan. Om Lie-Fi och inget svar inom 3 sekunder: signalen avbryter fetch med ett TimeoutError, service workern letar upp förfrågan i cachen och får undefined, letar sedan upp ./ och får det cachade dokumentet, som skickas till sidan.

Med Lie-Fi startar spelet nu efter tre sekunder, med kopian i cachen.

Tre sekunder är länge att vänta. Varför inte sätta NETWORK_TIMEOUT_MS till 500?

På en långsam men fungerande uppkoppling hinner svaret sällan komma inom en halv sekund. Service workern svarar då nästan alltid från cachen, och användaren får den gamla versionen även när den nya gick att hämta. Network first blir i praktiken cache first, med problemet du såg i Offline med Cache API.

Timeouten är en avvägning mellan att vänta på nätverket och att visa något direkt. Ett par sekunder är vanligt. Workbox, som du mötte i kursvecka 15, kallar inställningen networkTimeoutSeconds.

Eftersom sw.js har ändrats installeras den nya versionen av sig själv, och väntar som vanligt tills ingen flik använder den gamla. Timeouten ändrar inte vad som finns i cachen, så CACHE_NAME behöver inte byta version för den här ändringens skull.

En WebSocket, som i Messages i B3, är inget fetch()-anrop. Den passerar inte service workern, och den kan inte svaras på från en cache. Det som går att göra är att märka när anslutningen bryts, och försöka igen.

När anslutningen stängs, av vilket skäl det än är, utlöses close på socketen. Föregås det av ett fel utlöses error först, men close kommer alltid efteråt, så det räcker att lyssna på den. Att försöka igen direkt, om och om igen, belastar servern och telefonens batteri. Vänta i stället allt längre mellan försöken, och börja om när det lyckas:

const MIN_DELAY_MS = 1000
const MAX_DELAY_MS = 30000
let delay = MIN_DELAY_MS
/**
* Opens a connection to the server, and opens a new one whenever it closes.
*/
function connect() {
const socket = new WebSocket('wss://example.com/chat')
socket.addEventListener('open', () => {
delay = MIN_DELAY_MS
status.textContent = ''
})
socket.addEventListener('close', () => {
status.textContent = 'Anslutningen bröts. Försöker igen …'
setTimeout(connect, delay)
delay = Math.min(delay * 2, MAX_DELAY_MS)
})
}

Väntetiden fördubblas för varje misslyckat försök, en sekund, två, fyra och så vidare upp till 30 sekunder. Det kallas exponential backoff. I en komponent lägger du anslutningen i connectedCallback() och stänger den i disconnectedCallback(), och då ska den inte öppnas igen, så komponenten behöver hålla reda på om den fortfarande är ansluten till DOM:en.

Händelsen online kan användas som en ledtråd att försöka direkt, i stället för att vänta ut väntetiden. Men sluta inte försöka bara för att navigator.onLine är false.

Återanslutning är inget krav på Messages i B3, men ett bra exempel på en egen funktion. Servern i B3 skickar dessutom ett heartbeat var 40:e sekund. Har inget kommit på betydligt längre tid än så är anslutningen troligen död, även om close aldrig har utlösts, och det är Lie-Fi för en WebSocket.

Vad applikationen än gör när nätverket sviktar, ska användaren förstå vad som händer. Några riktlinjer:

  • Visa läget, dölj det inte. Anslutningen bröts, försöker igen är bättre än en knapp som inte gör något. Meddelandet som inte gick att skicka ska synas som osänt, inte försvinna.
  • Bygg på det som faktiskt händer. Ett fetch() som misslyckas eller en WebSocket som stängs är säkra besked. navigator.onLine är en gissning.
  • Låt användaren fortsätta där det går. Memory behöver inget nätverk. Det ska inte gå att spela sämre för att chatten i fönstret bredvid är frånkopplad.
  • Gör texten tillgänglig. Ett element med role="status" läses upp av skärmläsare när texten ändras, utan att flytta fokus.
<p id="status" role="status"></p>
  • navigator.onLine vet bara om enheten är ansluten till ett nätverk. Använd den som en ledtråd, inte för att stänga av funktioner.
  • Lie-Fi är en uppkoppling som finns men inte fungerar. fetch() väntar då utan timeout.
  • AbortSignal.timeout() sätter en timeout och ger ett TimeoutError. Kombinera med din egen signal med AbortSignal.any().
  • Ge network first i service workern en timeout på ett par sekunder, så att applikationen startar från cachen när nätverket ljuger.
  • En WebSocket som stängs öppnas igen med allt längre väntetid mellan försöken.
  • Visa vad som händer, och bygg det på det som faktiskt har hänt.
CCBY