Meer dan SPA's: alternatieve architecturen voor je PWA

Laten we het hebben over... architectuur?

Ik ga een belangrijk, maar mogelijk verkeerd begrepen onderwerp behandelen: de architectuur die je gebruikt voor je web-app, en specifiek hoe je architectuurbeslissingen een rol spelen als je een progressive web-app bouwt.

'Architectuur' kan vaag klinken en het is misschien niet meteen duidelijk waarom dit belangrijk is. Een manier om over architectuur na te denken, is door jezelf de volgende vragen te stellen: Welke HTML wordt geladen als een gebruiker een pagina op mijn site bezoekt? En wat wordt er geladen als ze een andere pagina bezoeken?

De antwoorden op die vragen zijn niet altijd eenvoudig en als je over progressieve web-apps begint na te denken, kunnen ze nog ingewikkelder worden. Mijn doel is om je door een mogelijke architectuur te leiden die ik effectief vond. In dit artikel label ik de beslissingen die ik heb genomen als 'mijn aanpak' voor het bouwen van een progressive web-app.

Je kunt mijn aanpak gebruiken als je je eigen PWA maakt, maar er zijn altijd andere goede alternatieven. Ik hoop dat je geïnspireerd raakt als je ziet hoe alle stukjes in elkaar passen en dat je je gesterkt voelt om dit aan te passen aan je eigen behoeften.

Stack Overflow-PWA

Ter ondersteuning van dit artikel heb ik een Stack Overflow-PWA gemaakt. Ik besteed veel tijd aan het lezen van en bijdragen aan Stack Overflow. Ik wilde een web-app maken waarmee je makkelijk veelgestelde vragen over een bepaald onderwerp kunt doorzoeken. De app is gebaseerd op de openbare Stack Exchange API. Het is open source en je kunt meer informatie vinden door naar het GitHub-project te gaan.

Apps met meerdere pagina's (MPA's)

Voordat ik in de details duik, wil ik een aantal termen definiëren en de onderliggende technologie uitleggen. Eerst ga ik het hebben over wat ik 'apps met meerdere pagina's' of 'MPA's' noem.

MPA is een mooie naam voor de traditionele architectuur die al sinds het begin van het web wordt gebruikt. Elke keer dat een gebruiker naar een nieuwe URL navigeert, rendert de browser geleidelijk HTML die specifiek is voor die pagina. Er wordt niet geprobeerd de status van de pagina of de content tussen navigaties te behouden. Elke keer dat je een nieuwe pagina bezoekt, begin je met een schone lei.

Dit is in tegenstelling tot het model van de app met één pagina (SPA) voor het maken van web-apps, waarbij de browser JavaScript-code uitvoert om de bestaande pagina te updaten als de gebruiker een nieuw gedeelte bezoekt. Zowel SPA's als MPA's zijn even geldige modellen om te gebruiken, maar voor dit bericht wilde ik PWA-concepten verkennen in de context van een app met meerdere pagina's.

Betrouwbaar snel

Je hebt mij (en talloze anderen) de term 'progressive web-app' of PWA horen gebruiken. Je bent misschien al bekend met een deel van het achtergrondmateriaal elders op deze site.

Je kunt een PWA zien als een web-app die een eersteklas gebruikerservaring biedt en die echt een plek op het startscherm van de gebruiker verdient. Het acroniem FIRE, dat staat voor Fast (snel), Integrated (geïntegreerd), Reliable (betrouwbaar) en Engaging (aantrekkelijk), vat alle kenmerken samen die je in gedachten moet houden als je een PWA maakt.

In dit artikel richt ik me op een subset van die kenmerken: Snel en Betrouwbaar.

Snel: Hoewel 'snel' in verschillende contexten verschillende dingen betekent, ga ik het hebben over de snelheidsvoordelen van zo min mogelijk laden via het netwerk.

Betrouwbaar: Maar snelheid alleen is niet genoeg. Je web-app moet betrouwbaar zijn om als een PWA aan te voelen. De app moet veerkrachtig genoeg zijn om altijd iets te laden, zelfs als het maar een aangepaste foutpagina is, ongeacht de status van het netwerk.

Betrouwbaar snel: Tot slot ga ik de definitie van PWA iets anders formuleren en kijken wat het betekent om iets te bouwen dat betrouwbaar snel is. Het is niet genoeg om alleen snel en betrouwbaar te zijn als je een netwerk met een lage latentie gebruikt. Betrouwbaar snel betekent dat de snelheid van je web-app consistent is, ongeacht de onderliggende netwerkomstandigheden.

Mogelijk makende technologieën: Service Workers + Cache Storage API

PWA's leggen de lat hoog voor snelheid en veerkracht. Gelukkig biedt het webplatform een aantal bouwstenen om dat soort prestaties te realiseren. Ik heb het over service workers en de Cache Storage API.

Je kunt een serviceworker maken die luistert naar inkomende verzoeken, sommige doorstuurt naar het netwerk en een kopie van de reactie opslaat voor toekomstig gebruik via de Cache Storage API.

Een servicemedewerker die de Cache Storage API gebruikt om een kopie van een netwerkreactie op te slaan.

De volgende keer dat de web-app hetzelfde verzoek doet, kan de serviceworker de caches checken en de eerder gecachte reactie retourneren.

Een serviceworker die de Cache Storage API gebruikt om te reageren en het netwerk omzeilt.

Het netwerk zo veel mogelijk vermijden is een cruciaal onderdeel van het bieden van betrouwbare, snelle prestaties.

Isomorfe JavaScript

Nog een concept dat ik wil behandelen, is wat soms isomorfe of universele JavaScript wordt genoemd. Eenvoudig gezegd is het het idee dat dezelfde JavaScript-code kan worden gedeeld tussen verschillende runtime-omgevingen. Toen ik mijn PWA bouwde, wilde ik JavaScript-code delen tussen mijn back-endserver en de servicemedewerker.

Er zijn veel geldige manieren om code op deze manier te delen, maar mijn aanpak was om ES-modules te gebruiken als de definitieve broncode. Daarna heb ik die modules getranspileerd en gebundeld voor de server en de serviceworker met een combinatie van Babel en Rollup. In mijn project zijn bestanden met de bestandsextensie .mjs code die in een ES-module staat.

De server

Met deze concepten en terminologie in gedachten, gaan we nu kijken hoe ik mijn Stack Overflow-PWA heb gebouwd. Ik begin met onze backend-server en leg uit hoe die in de algehele architectuur past.

Ik zocht een combinatie van een dynamische backend en statische hosting. Mijn aanpak was om het Firebase-platform te gebruiken.

Firebase Cloud Functions start automatisch een Node-gebaseerde omgeving op als er een inkomend verzoek is en integreert met het populaire Express HTTP-framework, waarmee ik al bekend was. Het biedt ook kant-en-klare hosting voor alle statische bronnen van mijn site. Laten we eens kijken hoe de server verzoeken verwerkt.

Als een browser een navigatieverzoek naar onze server stuurt, doorloopt het de volgende stappen:

Een overzicht van hoe je een navigatiereactie genereert aan de serverzijde.

De server stuurt het verzoek door op basis van de URL en gebruikt templatelogica om een compleet HTML-document te maken. Ik gebruik een combinatie van gegevens uit de Stack Exchange API en gedeeltelijke HTML-fragmenten die de server lokaal opslaat. Zodra onze servicemedewerker weet hoe die moet reageren, kan die HTML terugstreamen naar onze web-app.

Er zijn 2 delen van deze afbeelding die we nader moeten onderzoeken: routing en templating.

Routing

Voor routing heb ik de native routing-syntaxis van het Express-framework gebruikt. De functie is flexibel genoeg om overeen te komen met eenvoudige URL-voorvoegsels en URL's die parameters bevatten als onderdeel van het pad. Hier maak ik een toewijzing tussen routenamen en het onderliggende Express-patroon dat moet worden vergeleken.

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

Ik kan dan rechtstreeks naar deze toewijzing verwijzen vanuit de code van de server. Als er een overeenkomst is voor een bepaald Express-patroon, reageert de juiste handler met templatinglogica die specifiek is voor de overeenkomende route.

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

Templates aan de serverzijde

En hoe ziet die templating-logica eruit? Ik koos voor een aanpak waarbij ik gedeeltelijke HTML-fragmenten achter elkaar zette. Dit model is zeer geschikt voor streaming.

De server stuurt onmiddellijk een eerste HTML-boilerplate terug en de browser kan die gedeeltelijke pagina meteen weergeven. Terwijl de server de rest van de gegevensbronnen samenvoegt, streamt deze ze naar de browser totdat het document compleet is.

Bekijk de Express-code voor een van onze routes om te zien wat ik bedoel:

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Door de methode write() van het object response te gebruiken en te verwijzen naar lokaal opgeslagen gedeeltelijke templates, kan ik de stream van de reactie onmiddellijk starten zonder een externe gegevensbron te blokkeren. De browser neemt deze eerste HTML en geeft meteen een betekenisvolle interface en een laadbericht weer.

Het volgende gedeelte van onze pagina gebruikt gegevens van de Stack Exchange API. Om die gegevens te krijgen, moet onze server een netwerkverzoek doen. De web-app kan niets anders weergeven totdat deze een reactie krijgt en verwerkt, maar gebruikers staren in ieder geval niet naar een leeg scherm terwijl ze wachten.

Nadat de web-app de reactie van de Stack Exchange API heeft gekregen, roept deze een aangepaste templatefunctie aan om de gegevens van de API te vertalen naar de bijbehorende HTML.

Templatetaal

Templating kan een verrassend controversieel onderwerp zijn en de aanpak die ik heb gekozen, is slechts een van de vele. Je kunt je eigen oplossing gebruiken, vooral als je een verouderde koppeling hebt met een bestaand templateframework.

Voor mijn use case was het logisch om gewoon te vertrouwen op de template literals van JavaScript, met een deel van de logica opgesplitst in helperfuncties. Een van de voordelen van het bouwen van een MPA is dat je geen statusupdates hoeft bij te houden en je HTML niet opnieuw hoeft te renderen. Een basisaanpak die statische HTML produceerde, werkte dus voor mij.

Hier is een voorbeeld van hoe ik het dynamische HTML-gedeelte van de index van mijn web-app in een template zet. Net als bij mijn routes wordt de templatinglogica opgeslagen in een ES-module die kan worden geïmporteerd in zowel de server als de servicemedewerker.

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

Deze templatefuncties zijn pure JavaScript en het is handig om de logica op te splitsen in kleinere helperfuncties als dat nodig is. Hier geef ik elk van de items die in de API-reactie worden geretourneerd door aan een van deze functies, die een standaard html-element maakt met alle juiste kenmerken ingesteld.

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

Wat vooral belangrijk is, is een gegevenskenmerk dat ik aan elke link toevoeg, data-cache-url, ingesteld op de Stack Exchange API-URL die ik nodig heb om de bijbehorende vraag te tonen. Houd daar rekening mee. Ik kom er later op terug.

Terug naar mijn routehandler: Zodra de template klaar is, stream ik het laatste deel van de HTML van mijn pagina naar de browser en beëindig ik de stream. Dit is het signaal voor de browser dat de progressieve weergave is afgerond.

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

Dat was een korte rondleiding door mijn serverinstellingen. Gebruikers die mijn web-app voor het eerst bezoeken, krijgen altijd een reactie van de server, maar als een bezoeker terugkeert naar mijn web-app, begint mijn servicemedewerker te reageren. Laten we daar eens naar kijken.

De servicemedewerker

Een overzicht van hoe je een navigatiereactie genereert in de serviceworker.

Dit diagram komt je misschien bekend voor. Veel van de onderdelen die ik eerder heb besproken, staan hier in een iets andere opstelling. Laten we de verzoekstroom doorlopen, rekening houdend met de servicemedewerker.

Onze servicemedewerker verwerkt een inkomend navigatieverzoek voor een bepaalde URL, en net als mijn server gebruikt deze een combinatie van routing en templating-logica om te bepalen hoe te reageren.

De aanpak is hetzelfde als voorheen, maar met andere primitieven op laag niveau, zoals fetch() en de Cache Storage API. Ik gebruik die gegevensbronnen om de HTML-reactie samen te stellen, die de servicemedewerker terugstuurt naar de web-app.

Workbox

In plaats van helemaal opnieuw te beginnen met primitieven op laag niveau, bouw ik mijn serviceworker op een set bibliotheken op hoog niveau, genaamd Workbox. Het biedt een solide basis voor de logica voor caching, routing en reactiegeneratie van elke serviceworker.

Routing

Net als bij mijn code aan de serverzijde moet mijn servicemedewerker weten hoe hij een inkomend verzoek moet matchen met de juiste reactielogica.

Mijn aanpak was om elke Express-route te vertalen naar een bijbehorende reguliere expressie, waarbij ik gebruikmaakte van een handige bibliotheek met de naam regexparam. Zodra die vertaling is uitgevoerd, kan ik gebruikmaken van de ingebouwde ondersteuning van Workbox voor routing met reguliere expressies.

Nadat ik de module met de reguliere expressies heb geïmporteerd, registreer ik elke reguliere expressie bij de router van Workbox. Binnen elke route kan ik aangepaste templatelogica bieden om een reactie te genereren. Templating in de servicemedewerker is iets meer betrokken dan in mijn backend-server, maar Workbox helpt met veel van het zware werk.

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

Caching van statische componenten

Een belangrijk onderdeel van het templating-verhaal is ervoor zorgen dat mijn gedeeltelijke HTML-templates lokaal beschikbaar zijn via de Cache Storage API en up-to-date worden gehouden als ik wijzigingen in de web-app implementeer. Cache-onderhoud kan foutgevoelig zijn als je het handmatig doet, dus ik gebruik Workbox om precaching te verwerken als onderdeel van mijn buildproces.

Ik vertel Workbox welke URL's vooraf moeten worden gecachet met een configuratiebestand. Dit bestand verwijst naar de directory met al mijn lokale items en een reeks patronen die moeten overeenkomen. Dit bestand wordt automatisch gelezen door de CLI van Workbox, die wordt uitgevoerd elke keer dat ik de site opnieuw opbouw.

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox maakt een momentopname van de inhoud van elk bestand en voegt die lijst met URL's en revisies automatisch in mijn uiteindelijke serviceworkerbestand in. Workbox heeft nu alles wat nodig is om de vooraf gecachte bestanden altijd beschikbaar en up-to-date te houden. Het resultaat is een service-worker.js-bestand dat iets bevat dat lijkt op het volgende:

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

Voor mensen die een complexer bouwproces gebruiken, heeft Workbox zowel een webpack-plug-in als een algemene node-module, naast de opdrachtregelinterface.

Streaming

Vervolgens wil ik dat de serviceworker die vooraf in het cachegeheugen opgeslagen gedeeltelijke HTML onmiddellijk terugstreamt naar de web-app. Dit is een essentieel onderdeel van 'betrouwbaar snel': ik krijg altijd meteen iets nuttigs op het scherm. Gelukkig is dat mogelijk met de Streams API in onze serviceworker.

Misschien heb je al eens van de Streams API gehoord. Mijn collega Jake Archibald is er al jaren lyrisch over. Hij deed de gedurfde voorspelling dat 2016 het jaar van de webstreams zou worden. En de Streams API is vandaag de dag nog net zo geweldig als twee jaar geleden, maar met een cruciaal verschil.

Toen ondersteunde alleen Chrome Streams, maar de Streams API wordt nu breder ondersteund. Het algemene verhaal is positief en met de juiste fallback-code is er niets dat je ervan weerhoudt om vandaag nog streams te gebruiken in je service worker.

Nou... er is misschien één ding dat je tegenhoudt, en dat is begrijpen hoe de Streams API eigenlijk werkt. De API biedt een zeer krachtige set primitieven. Ontwikkelaars die er goed mee overweg kunnen, kunnen complexe gegevensstromen maken, zoals de volgende:

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

Maar niet iedereen begrijpt de volledige implicaties van deze code. In plaats van deze logica te ontleden, wil ik het hebben over mijn aanpak voor service worker-streaming.

Ik gebruik een gloednieuwe wrapper op hoog niveau, workbox-streams. Hiermee kan ik een mix van streamingbronnen doorgeven, zowel uit caches als uit runtimegegevens die van het netwerk kunnen komen. Workbox zorgt voor de coördinatie van de afzonderlijke bronnen en voegt ze samen tot één streamingreactie.

Daarnaast herkent Workbox automatisch of de Streams API wordt ondersteund. Als dat niet het geval is, maakt Workbox een gelijkwaardige, niet-streaming reactie. Je hoeft je dus geen zorgen te maken over het schrijven van fallbacks, omdat streams steeds dichter bij 100% browserondersteuning komen.

Runtime-caching

Laten we eens kijken hoe mijn servicemedewerker omgaat met runtime-gegevens van de Stack Exchange API. Ik maak gebruik van de ingebouwde ondersteuning van Workbox voor een stale-while-revalidate-cachingsstrategie, samen met een vervaldatum om te zorgen dat de opslag van de web-app niet onbeperkt groeit.

Ik heb 2 strategieën ingesteld in Workbox om de verschillende bronnen te verwerken die de streamingreactie vormen. Met een paar functieaanroepen en configuratie kunnen we met Workbox doen waarvoor we anders honderden regels handgeschreven code nodig zouden hebben.

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

De eerste strategie leest gegevens die vooraf in het cachegeheugen zijn opgeslagen, zoals onze gedeeltelijke HTML-templates.

De andere strategie implementeert de stale-while-revalidate-cachelogica, samen met de vervaldatum van de minst recent gebruikte cache zodra we 50 items hebben bereikt.

Nu ik die strategieën heb ingesteld, hoef ik alleen nog maar Workbox te vertellen hoe deze moeten worden gebruikt om een volledige, streaming reactie op te bouwen. Ik geef een array van bronnen door als functies en elk van die functies wordt onmiddellijk uitgevoerd. Workbox neemt het resultaat van elke bron en streamt het naar de web-app, op volgorde, en vertraagt alleen als de volgende functie in de matrix nog niet is voltooid.

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

De eerste 2 bronnen zijn vooraf in het cachegeheugen opgeslagen gedeeltelijke templates die rechtstreeks worden gelezen uit de Cache Storage API, zodat ze altijd onmiddellijk beschikbaar zijn. Zo zorgen we dat onze serviceworker-implementatie betrouwbaar en snel reageert op verzoeken, net als mijn code aan de serverzijde.

Onze volgende bronfunctie haalt gegevens op uit de Stack Exchange API en verwerkt de reactie in de HTML die de web-app verwacht.

De strategie stale-while-revalidate houdt in dat als ik een eerder gecachte reactie voor deze API-aanroep heb, ik deze onmiddellijk naar de pagina kan streamen, terwijl de cache-invoer op de achtergrond wordt geüpdatet voor de volgende keer dat deze wordt aangevraagd.

Tot slot stream ik een gecachte kopie van mijn voettekst en sluit ik de laatste HTML-tags om de reactie af te ronden.

Met de deelcode blijft alles gesynchroniseerd

Je merkt dat bepaalde delen van de serviceworkercode er bekend uitzien. De gedeeltelijke HTML- en templatinglogica die door mijn servicemedewerker wordt gebruikt, is identiek aan wat mijn handler aan de serverzijde gebruikt. Door deze code te delen, krijgen gebruikers een consistente gebruikerservaring, of ze mijn web-app nu voor het eerst bezoeken of terugkeren naar een pagina die door de servicemedewerker wordt weergegeven. Dat is het mooie van isomorfische JavaScript.

Dynamische, progressieve verbeteringen

Ik heb zowel de server als de servicemedewerker voor mijn PWA doorgenomen, maar er is nog een laatste stukje logica dat ik moet behandelen: er is een kleine hoeveelheid JavaScript die op elk van mijn pagina's wordt uitgevoerd nadat ze volledig zijn gestreamd.

Deze code verbetert de gebruikerservaring geleidelijk, maar is niet cruciaal. De web-app werkt nog steeds als de code niet wordt uitgevoerd.

Paginametadata

Mijn app gebruikt JavaScript aan de clientzijde om de metadata van een pagina te updaten op basis van de API-reactie. Omdat ik voor elke pagina hetzelfde eerste stukje gecachte HTML gebruik, krijgt de web-app algemene tags in de head van mijn document. Maar door coördinatie tussen mijn templating- en aan de clientzijde code kan ik de titel van het venster updaten met paginaspecifieke metadata.

Als onderdeel van de templatingcode is mijn aanpak om een scripttag op te nemen die de correct ge-escapete tekenreeks bevat.

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

Nadat mijn pagina is geladen, lees ik die tekenreeks en update ik de documenttitel.

if (self._title) {
  document.title = unescape(self._title);
}

Als er andere paginaspecifieke metadata zijn die je wilt updaten in je eigen web-app, kun je dezelfde aanpak volgen.

Offline UX

De andere progressive enhancement die ik heb toegevoegd, is bedoeld om de aandacht te vestigen op onze offline mogelijkheden. Ik heb een betrouwbare PWA gemaakt en ik wil dat gebruikers weten dat ze eerder bezochte pagina's nog steeds kunnen laden als ze offline zijn.

Eerst gebruik ik de Cache Storage API om een lijst te krijgen van alle eerder gecachte API-verzoeken en die vertaal ik naar een lijst met URL's.

Weet je nog die speciale gegevenskenmerken waar ik het over had, die elk de URL bevatten voor het API-verzoek dat nodig is om een vraag weer te geven? Ik kan die gegevenskenmerken vergelijken met de lijst met gecachte URL's en een matrix maken van alle vraaglinks die niet overeenkomen.

Als de browser offline gaat, loop ik door de lijst met niet-gecachte links en maak ik de links die niet werken grijs. Houd er rekening mee dat dit slechts een visuele hint is voor de gebruiker over wat die van die pagina's kan verwachten. Ik zet de links niet echt uit en voorkom niet dat de gebruiker navigeert.

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

Veelvoorkomende valkuilen

Ik heb nu een rondleiding gegeven door mijn aanpak om een PWA met meerdere pagina's te maken. Er zijn veel factoren waarmee je rekening moet houden als je je eigen aanpak bedenkt. Misschien maak je andere keuzes dan ik. Die flexibiliteit is een van de geweldige dingen van ontwikkelen voor het web.

Er zijn een paar veelvoorkomende valkuilen die je kunt tegenkomen als je zelf architectonische beslissingen neemt. Ik wil je graag wat moeite besparen.

Sla geen volledige HTML op in het cachegeheugen

Ik raad je af om volledige HTML-documenten in je cache op te slaan. Het is bijvoorbeeld een verspilling van ruimte. Als je web-app dezelfde eenvoudige HTML-structuur gebruikt voor elke pagina, sla je steeds weer kopieën van dezelfde opmaak op.

Belangrijker nog: als je een wijziging implementeert in de gedeelde HTML-structuur van je site, blijven al die eerder gecachte pagina's nog steeds hangen in je oude vormgeving. Stel je de frustratie voor van een terugkerende bezoeker die een mix van oude en nieuwe pagina's ziet.

Server-/serviceworker-drift

De andere valkuil die je moet vermijden, is dat je server en serviceworker niet meer synchroon lopen. Mijn aanpak was om isomorfe JavaScript te gebruiken, zodat dezelfde code op beide plaatsen werd uitgevoerd. Afhankelijk van je bestaande serverarchitectuur is dat niet altijd mogelijk.

Welke architecturale beslissingen je ook neemt, je moet een strategie hebben om de equivalente routing- en templatingcode uit te voeren op je server en in je serviceworker.

Worstcasescenario's

Inconsistente indeling/ontwerp

Wat gebeurt er als u deze valkuilen negeert? Er zijn allerlei fouten mogelijk, maar het ergste scenario is dat een terugkerende gebruiker een pagina in het cachegeheugen bezoekt met een zeer verouderde vormgeving, misschien een met verouderde koptekst of een die CSS-klassenamen gebruikt die niet meer geldig zijn.

Worstcasescenario: Routing werkt niet

Een gebruiker kan ook een URL tegenkomen die wordt verwerkt door je server, maar niet door je serviceworker. Een site vol zombie-indelingen en doodlopende wegen is geen betrouwbare PWA.

Tips voor succes

Maar je staat er niet alleen voor. Met de volgende tips kunt u deze valkuilen vermijden:

Gebruik bibliotheken voor templates en routing die implementaties in meerdere talen hebben

Probeer templating- en routingbibliotheken te gebruiken die JavaScript-implementaties hebben. Ik weet dat niet elke ontwikkelaar de luxe heeft om te migreren van de huidige webserver en sjabloontaal.

Maar een aantal populaire frameworks voor templates en routing hebben implementaties in meerdere talen. Als je een template-engine kunt vinden die werkt met JavaScript en de taal van je huidige server, ben je een stap dichter bij het synchroniseren van je serviceworker en server.

Geef de voorkeur aan opeenvolgende in plaats van geneste templates

Daarna raad ik aan een reeks opeenvolgende templates te gebruiken die na elkaar kunnen worden gestreamd. Het is niet erg als latere delen van je pagina een ingewikkeldere templatinglogica gebruiken, zolang je het eerste deel van je HTML maar zo snel mogelijk kunt streamen.

Zowel statische als dynamische content opslaan in het cachegeheugen van je serviceworker

Voor de beste prestaties moet je alle kritieke statische bronnen van je site vooraf in het cachegeheugen opslaan. Stel ook runtime-cachinglogica in om dynamische content te verwerken, zoals API-verzoeken. Als je Workbox gebruikt, kun je voortbouwen op goed geteste, productierijpe strategieën in plaats van alles helemaal zelf te implementeren.

Blokkeer alleen op het netwerk als dat absoluut noodzakelijk is

En daarmee samenhangend: je moet alleen blokkeren op het netwerk als het niet mogelijk is om een reactie te streamen vanuit de cache. Een gecachte API-reactie meteen weergeven kan vaak leiden tot een betere gebruikerservaring dan wachten op nieuwe gegevens.

Bronnen