Hoppa till innehåll

Lazy loading

Allt som sidan importerar hämtas innan den kan visas, också det användaren kanske aldrig använder. Lazy loading, fördröjd inläsning, är att vänta med det som inte behövs direkt, tills det behövs. Den här artikeln visar hur du gör det med bilder och med JavaScript-moduler. Det är också ett av kraven för högre betyg i examinationsuppgift B3: underapplikationerna ska inte läsas in förrän användaren startar dem.

Exemplen bygger på memoryspelet, som får en vy över de senaste omgångarna, historiken från Web Storage. Den laddas först när spelaren ber om den.

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

Öppna fliken Network i utvecklarverktygen och ladda om sidan. Allt i listan har hämtats innan användaren hunnit göra något. Fliken Coverage, som du hittar genom att skriva coverage i Command Menu (Ctrl+Shift+P), visar dessutom hur stor del av varje skript och stilmall som faktiskt har körts. På en stor webbplats är det ofta mindre än hälften.

Det som laddas i onödan kostar på tre sätt. Det tar tid innan sidan går att använda, särskilt på en långsam uppkoppling. Det förbrukar data för användare som betalar för den. Och webbläsaren måste tolka och köra koden, vilket märks på en enklare telefon.

Lazy loading är inte ett mål i sig. En liten modul som alltid används ska laddas direkt, och varje modul som laddas för sig blir en förfrågan till. Frågan att ställa är vad användaren behöver innan sidan visas, och vad som kan vänta.

För bilder och <iframe> räcker ett attribut:

<img src="./images/team.jpg" alt="Kursledningen" width="800" height="600" loading="lazy" />

Med loading="lazy" hämtar webbläsaren bilden först när den närmar sig viewporten, den del av sidan som syns, när användaren scrollar. Attributet finns i alla stora webbläsare. En webbläsare som inte känner till det hämtar bilden som vanligt, så det går aldrig sönder.

Två saker att tänka på:

  • Ange width och height. Annars vet webbläsaren inte hur stor plats bilden tar innan den har hämtats, och innehållet hoppar när den dyker upp.
  • Inte på det som syns direkt. En bild högst upp på sidan ska hämtas så tidigt som möjligt. Med loading="lazy" väntar webbläsaren tills den vet var bilden hamnar, och sidan känns långsammare.

Är JavaScript avstängt hämtas bilderna direkt, som om attributet saknades. Det är medvetet. Annars skulle servern, genom att se vilka bilder som hämtas och när, kunna följa hur långt användaren scrollar, också på en sida där användaren har stängt av skript för att slippa just sådant.

En import-sats överst i en fil laddas innan filen körs. Det gäller hela kedjan: allt index.js importerar, och allt de modulerna i sin tur importerar. I memoryspelet hämtas memory-game och flipping-tile innan något visas, och det är rätt, eftersom spelet är det första användaren ser.

Historiken är annorlunda. Den visas bara om spelaren klickar på en knapp. Den kan laddas med import(), som ser ut som ett funktionsanrop men är en del av språket, precis som import-satsen. Den kan stå var som helst i koden, och ger ett promise som blir fulfilled med modulen när den är hämtad och har körts:

js/index.js
import './components/memory-game/index.js'
import { loadHistory } from './game-history.js'
const historyButton = document.querySelector('#history-button')
const historyElement = document.querySelector('game-history')
const status = document.querySelector('#status')
historyButton.addEventListener('click', async () => {
try {
await import('./components/game-history/index.js')
} catch (error) {
status.textContent = 'Historiken kunde inte laddas. Försök igen.'
console.error(error)
return
}
historyElement.showHistory(loadHistory())
historyElement.hidden = false
})

<game-history> är en liten komponent som visar listan den får via metoden showHistory(), enligt samma regel som topplistan i B2: sammansatt data går ner via en metod. loadHistory() är funktionen från kursvecka 14. Den är liten och behövs ändå, så den importeras som vanligt.

Promiset ger ett modulobjekt, med det modulen exporterar som egenskaper: const { GameHistory } = await import('./components/game-history/game-history.js'). Här behövs inget från modulen. Det räcker att index.js i komponentens mapp har körts, eftersom det är där customElements.define() anropas.

Importerar du samma modul igen, vid nästa klick, hämtas och körs den inte på nytt. Webbläsaren håller reda på varje modul den har laddat, och promiset blir fulfilled direkt med samma modul.

När du kör npm run build blir varje modul som laddas med import() en egen chunk, en fil med en hash i namnet, precis som resten av Vites filer:

dist/assets/
├── index-DszDTf9w.js
├── memory-game-DO-TomNQ.js
├── flipping-tile-DzCmyuX5.js
├── game-history-BKcdOjZJ.js
└── ...

index.html pekar på de tre första, och game-history-BKcdOjZJ.js hämtas först när spelaren klickar. Behöver modulen i sin tur andra moduler hämtar Vite dem samtidigt, i stället för en i taget. Du ser det i fliken Network: kör npm run build och npm run serve, ladda om och klicka på knappen.

Under npm run dev ser du också att modulen hämtas vid klicket, men där hämtas varje källfil för sig, och det är bygget som visar vad användaren faktiskt får.

<game-history hidden></game-history> står i index.html från början. Vad är det, innan modulen har laddats?

Någon tycker att try och await är onödiga, och skriver lyssnaren så här:

historyButton.addEventListener('click', () => {
import('./components/game-history/index.js')
historyElement.showHistory(loadHistory())
historyElement.hidden = false
})

Vad händer första gången spelaren klickar?

TypeError: historyElement.showHistory is not a function. import() startar hämtningen, men lyssnaren väntar inte på den. När nästa rad körs har customElements.define() inte anropats än.

En tagg som webbläsaren inte känner igen, men som har ett bindestreck i namnet, blir ett vanligt HTMLElement, utan komponentens metoder. När define() anropas uppgraderar webbläsaren alla sådana element i dokumentet direkt, och först då finns showHistory(). Därför måste lyssnaren vänta på import() med await. Vid andra klicket fungerar koden, eftersom modulen redan är laddad.

Fram till uppgraderingen är elementet tomt, och det går att välja i CSS med :not(:defined). Det är användbart för att ge platsen en storlek, eller visa att något laddas, så att sidan inte hoppar när komponenten dyker upp:

game-history:not(:defined) {
display: block;
min-height: 10rem;
}

Behöver kod vänta på att ett element blir definierat, utan att själv importera modulen, finns customElements.whenDefined('game-history'), som ger ett promise.

En import-sats överst i en fil kan inte misslyckas på ett sätt som din kod märker: går den inte att hämta körs ingenting. import() kan däremot misslyckas medan applikationen är igång, och då blir promiset rejected med ett TypeError:

TypeError: Failed to fetch dynamically imported module:
http://localhost:4173/assets/game-history-BKcdOjZJ.js

Det händer i två lägen:

  • Användaren är offline, och modulen har aldrig hämtats.
  • Du har driftsatt en ny version medan användaren hade sidan öppen. Den gamla sidan ber om game-history-BKcdOjZJ.js, men på servern finns bara filen med det nya namnet. Vite utlöser då också händelsen vite:preloadError på window, som du kan lyssna på om du vill ladda om sidan.

Därför fångar lyssnaren felet och säger något till användaren, i stället för att knappen tyst slutar fungera. I Chrome hämtas modulen på nytt nästa gång import() anropas, så när nätverket är tillbaka fungerar knappen igen.

Service workern från Offline med Cache API använder cache first för filerna under /assets/, och sparar varje fil första gången den hämtas. Det gäller också game-history-BKcdOjZJ.js.

En spelare har öppnat memoryspelet två gånger, och service workern styr sidan. Spelaren har aldrig klickat på knappen för historiken. Sedan försvinner uppkopplingen, och spelaren klickar. Vad händer? Vad händer om spelaren hade klickat en gång tidigare, medan uppkopplingen fanns?

Första fallet: meddelandet Historiken kunde inte laddas. Modulen har aldrig hämtats, så den finns inte i cachen, och nätverket svarar inte.

Andra fallet: historiken visas. Modulen hämtades genom service workern när spelaren klickade första gången, och sparades i cachen. Nu svarar service workern med kopian.

Det är precis vad kravet i B3 säger: applikationen ska fungera offline, inklusive underapplikationer som har öppnats minst en gång medan användaren var uppkopplad. Lazy loading och cachning under körning passar ihop.

Med flera vyer, eller flera underapplikationer, behöver koden välja vilken modul som ska laddas. Det enklaste är ett objekt med en funktion per modul:

const views = {
history: () => import('./components/game-history/index.js'),
stats: () => import('./components/game-stats/index.js')
}
await views[name]()

Varje import() har en fast sträng, så det syns direkt vilka moduler som finns, och Vite vet vilka filer det ska bygga. Funktionerna gör att ingenting hämtas förrän en av dem anropas.

Det går också att bygga strängen, som import(`./components/${name}/index.js`). Vite hittar då alla filer som kan matcha, och bygger en fil för varje. Men strängen måste börja med ./ eller ../, sluta med filändelsen och variabeln får bara stå för en nivå, ett fil- eller mappnamn. Det syns inte heller i koden vilka moduler som kan laddas. Objektet ovan är tydligare.

loading="lazy" väntar tills en bild närmar sig viewporten. Vill du göra samma sak med något annat, till exempel en tung komponent långt ner på sidan, finns IntersectionObserver. Den anropar en callback när ett element kommer in i, eller lämnar, viewporten, med en marginal om du vill börja lite innan:

const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect()
import('./components/game-stats/index.js')
}
}, { rootMargin: '300px' })
observer.observe(document.querySelector('game-stats'))

I B3 behövs den inte. Där är det användarens klick på en ikon som avgör när en underapplikation ska laddas, inte vad som syns.

  • Lazy loading är att vänta med det som inte behövs direkt. Fliken Network och Coverage visar vad som laddas i onödan.
  • loading="lazy" på bilder och <iframe> som inte syns direkt. Ange width och height.
  • import() laddar en modul när den behövs, och ger ett promise. Vite bygger modulen till en egen fil.
  • Ett custom element är ett vanligt HTMLElement tills define() har körts. Vänta på import() innan du anropar komponentens metoder.
  • import() kan misslyckas, offline eller efter en ny driftsättning. Fånga felet och berätta det för användaren.
  • En modul som har laddats en gång finns i service workerns cache, och fungerar offline.
CCBY