Applicazioni multipagina più veloci con i flussi

Al giorno d'oggi i siti web, o le app web se preferisci, tendono a utilizzare uno di questi due schemi di navigazione:

  • Gli schemi di navigazione forniti dai browser sono predefiniti, ossia inserisci un URL nella barra degli indirizzi del browser e una richiesta di navigazione restituisce un documento come risposta. Quindi fai clic su un link, che scarica il documento corrente per un altro, ad infinitum.
  • Il pattern dell'applicazione a pagina singola, che prevede una richiesta di navigazione iniziale per caricare la shell dell'applicazione e si basa su JavaScript per completare la shell dell'applicazione con un markup eseguito dal client con contenuti provenienti da un'API back-end per ogni "navigazione".

I vantaggi di ciascun approccio sono stati promossi dai sostenitori:

  • Lo schema di navigazione fornito dai browser per impostazione predefinita è resiliente, poiché le route non richiedono che JavaScript sia accessibile. Anche il rendering client del markup tramite JavaScript può essere un processo potenzialmente costoso, il che significa che i dispositivi di fascia bassa potrebbero finire in una situazione in cui i contenuti vengono ritardati perché il dispositivo è bloccato a elaborare script che forniscono contenuti.
  • Le applicazioni a pagina singola, invece, possono offrire una navigazione più rapida dopo il caricamento iniziale. Piuttosto che fare affidamento sul browser per scaricare un documento e crearne uno completamente nuovo (e ripeterlo per ogni navigazione), è in grado di offrire una soluzione che sembra più veloce e "simile a un'app" anche se questo richiede il funzionamento di JavaScript.

In questo post parleremo di un terzo metodo che trova un equilibrio tra i due approcci descritti sopra: fare affidamento su un service worker per prememorizzare nella cache gli elementi comuni di un sito web, come il markup dell'intestazione e del piè di pagina, e utilizzare gli stream per fornire una risposta HTML al client il più rapidamente possibile, utilizzando comunque lo schema di navigazione predefinito del browser.

Perché trasmettere risposte HTML in streaming in un service worker?

Lo streaming è qualcosa che il tuo browser web già fa quando invia le richieste. Questo aspetto è estremamente importante nel contesto delle richieste di navigazione, in quanto garantisce che il browser non venga bloccato in attesa dell'intera risposta prima di poter iniziare ad analizzare il markup del documento e a visualizzare una pagina.

Diagramma che mostra il confronto tra HTML non in streaming e HTML in streaming. Nel primo caso, l'intero payload di markup non viene elaborato fino a quando non arriva. Nel secondo caso, il markup viene elaborato in modo incrementale quando arriva in blocchi dalla rete.

Per i service worker, il flusso di dati è un po' diverso in quanto utilizza l'API Streams JavaScript. L'attività più importante eseguita da un service worker è l'intercettazione e la risposta alle richieste, incluse le richieste di navigazione.

Queste richieste possono interagire con la cache in vari modi, ma un pattern di memorizzazione nella cache comune per il markup è quello di favorire l'utilizzo di una risposta dalla rete prima, ma di ricorrere alla cache se è disponibile una copia meno recente e, facoltativamente, di fornire una risposta di riserva generica se una risposta utilizzabile non è presente nella cache.

Si tratta di un pattern per il markup che funziona bene ma che, sebbene sia utile per l'affidabilità in termini di accesso offline, non offre vantaggi intrinseci in termini di prestazioni per le richieste di navigazione che si basano su una strategia prima di tutto o solo di rete. È qui che entra in gioco lo streaming. Vedremo come utilizzare il modulo workbox-streams basato sull'API Streams nel service worker di Workbox per velocizzare le richieste di navigazione sul sito web multipagina.

Analisi di una tipica pagina web

Dal punto di vista strutturale, i siti web tendono ad avere elementi comuni presenti in ogni pagina. Una tipica disposizione degli elementi di una pagina è spesso simile a:

  • Intestazione.
  • Contenuti.
  • Piè di pagina

Utilizzando web.dev come esempio, la suddivisione degli elementi comuni ha il seguente aspetto:

Un'analisi degli elementi comuni sul sito web web.dev. Le aree comuni definite sono quelle "intestazione", "contenuti" e "piè di pagina".

L'obiettivo dietro l'identificazione delle parti di una pagina è determinare cosa può essere prememorizzato e recuperato senza accedere alla rete, ovvero il markup dell'intestazione e del piè di pagina comuni a tutte le pagine, e la parte della pagina che andremo sempre sulla rete per prima, in questo caso i contenuti.

Una volta che sappiamo come segmentare le parti di una pagina e identificare gli elementi comuni, possiamo scrivere un service worker che recupera sempre all'istante il markup dell'intestazione e del piè di pagina dalla cache, richiedendo solo i contenuti alla rete.

Quindi, utilizzando l'API Streams tramite workbox-streams, possiamo unire tutte queste parti e rispondere immediatamente alle richieste di navigazione, richiedendo la quantità minima di markup necessaria alla rete.

Creazione di un worker di servizio di streaming

Quando si tratta di trasmettere contenuti parziali in streaming in un service worker, ci sono molte parti in movimento, ma ogni fase del processo verrà esplorata in dettaglio man mano che si procede, a partire da come strutturare il sito web.

Segmentazione del sito web in parziali

Prima di poter iniziare a scrivere un worker di servizi di streaming, devi fare tre cose:

  1. Crea un file contenente solo il markup dell'intestazione del tuo sito web.
  2. Crea un file contenente solo il markup del piè di pagina del tuo sito web.
  3. Estrai i contenuti principali di ogni pagina in un file separato o configura il tuo backend in modo che pubblichi in modo condizionale solo i contenuti della pagina in base all'intestazione di una richiesta HTTP.
di Gemini Advanced.

Come ci si potrebbe aspettare, l'ultimo passaggio è quello più difficile, soprattutto se il sito web è statico. Se questo è il tuo caso, dovrai generare due versioni di ogni pagina: una conterrà il markup della pagina intera, mentre l'altra conterrà solo i contenuti.

Composizione di un worker di servizi di streaming

Se non hai installato il modulo workbox-streams, dovrai farlo in aggiunta ai moduli Workbox attualmente installati. In questo esempio specifico, ciò riguarda i seguenti pacchetti:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

Da qui, il passaggio successivo è creare un nuovo service worker e prememorizzare nella cache le parti parziali di intestazione e piè di pagina.

Prememorizzazione nella cache dei parziali

Per prima cosa dovrai creare un service worker nella radice del progetto denominato sw.js (o con il nome file che preferisci). All'interno del codice, inizierai con quanto segue:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Questo codice esegue due operazioni:

  1. Attiva il precaricamento della navigazione per i browser che la supportano.
  2. Prememorizza nella cache il markup di intestazione e piè di pagina. Ciò significa che il markup di intestazione e piè di pagina per ogni pagina verrà recuperato istantaneamente, dato che non verrà bloccato dalla rete.
  3. Prememorizza nella cache gli asset statici nel segnaposto __WB_MANIFEST che utilizza il metodo injectManifest.
di Gemini Advanced.

Risposte dinamiche

Fare in modo che il tuo service worker esegua lo streaming di risposte concatenate è la parte principale di questo impegno. Anche in questo caso, Workbox e i suoi workbox-streams rendono questo aspetto molto più conciso di quanto accadrebbe se dovessi fare tutto da solo:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Questo codice è costituito da tre parti principali che soddisfano i seguenti requisiti:

  1. Per gestire le richieste di parziali di contenuti, viene utilizzata una strategia NetworkFirst. Utilizzando questa strategia, viene specificato un nome cache personalizzato content per contenere le parziali dei contenuti, nonché un plug-in personalizzato che gestisce se impostare un'intestazione della richiesta X-Content-Mode per i browser che non supportano il precaricamento della navigazione (e che pertanto non inviano un'intestazione Service-Worker-Navigation-Preload). Questo plug-in determina inoltre se inviare l'ultima versione memorizzata nella cache di una parte dei contenuti o inviare una pagina di riserva offline nel caso in cui non venga memorizzata alcuna versione memorizzata nella cache per la richiesta corrente.
  2. Il metodo strategy in workbox-streams (con l'alias composeStrategies qui) viene utilizzato per concatenare le parziali di intestazione e piè di pagina pre-memorizzate nella cache insieme alla parte di contenuti richiesta dalla rete.
  3. L'intero schema viene modificato tramite registerRoute per le richieste di navigazione.

Con questa logica, abbiamo configurato le risposte dinamiche. Tuttavia, potrebbe essere necessario eseguire alcune operazioni in back-end per garantire che i contenuti della rete siano una pagina parziale che puoi unire alle parti pre-memorizzate nella cache.

Se il sito web ha un backend

Ricorderai che quando il precaricamento della navigazione è attivato, il browser invia un'intestazione Service-Worker-Navigation-Preload con il valore true. Tuttavia, nell'esempio di codice riportato sopra, abbiamo inviato un'intestazione personalizzata X-Content-Mode nel caso in cui il precaricamento della navigazione eventi non sia supportato in un browser. Nel backend, modificheresti la risposta in base alla presenza di queste intestazioni. In un backend PHP, per una determinata pagina potrebbe avere un aspetto simile al seguente:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

Nell'esempio precedente, le parziali di contenuti vengono richiamate come funzioni, che assumono il valore $isPartial per modificare il modo in cui vengono visualizzate le parziali. Ad esempio, la funzione del renderer content potrebbe includere solo un determinato markup nelle condizioni quando viene recuperata come parziale, con un aspetto che verrà trattato a breve.

Considerazioni

Prima di eseguire il deployment di un service worker per eseguire il flusso di dati e unire le parziali, devi considerare alcuni aspetti. Sebbene sia vero che utilizzare un service worker in questo modo non modifica sostanzialmente il comportamento di navigazione predefinito del browser, ci sono alcuni aspetti che probabilmente dovrai risolvere.

Aggiornamento degli elementi della pagina durante la navigazione

La parte più difficile di questo approccio è che alcune cose dovranno essere aggiornate sul client. Ad esempio, se il markup dell'intestazione viene eseguito preventivamente nella cache, la pagina avrà gli stessi contenuti nell'elemento <title> o anche la gestione degli stati di attivazione e disattivazione degli elementi di navigazione dovrà essere aggiornata a ogni navigazione. Questi e altri elementi potrebbero dover essere aggiornati sul client per ogni richiesta di navigazione.

Per aggirare questo problema, puoi inserire un elemento <script> incorporato nella parte dei contenuti proveniente dalla rete per aggiornare alcuni elementi importanti:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

Questo è solo un esempio di ciò che potresti dover fare se decidi di utilizzare questa configurazione del service worker. Per applicazioni più complesse con informazioni relative agli utenti, ad esempio, potresti dover archiviare bit di dati pertinenti in un web store come localStorage e aggiornare la pagina da lì.

Gestire le reti lente

Quando le connessioni di rete sono lente, è possibile che si verifichi uno svantaggio delle risposte in modalità flusso utilizzando il markup della preregistrazione. Il problema è che il markup dell'intestazione dalla pre-cache arriva istantaneamente, ma il markup della parte dei contenuti dalla rete può impiegare un po' di tempo per arrivare dopo la visualizzazione iniziale del markup dell'intestazione.

Ciò può creare un'esperienza confusa e, se le reti sono molto lente, potrebbe persino sembrare che la pagina sia rotta e il rendering non venga completato. In casi come questo, puoi scegliere di inserire un'icona o un messaggio di caricamento nel markup della parte parziale dei contenuti che puoi nascondere una volta caricati i contenuti.

Un modo per farlo è tramite CSS. Supponiamo che la porzione di intestazione termini con un elemento <article> di apertura vuoto fino a quando non arriva la parte dei contenuti per completarla. Potresti scrivere una regola CSS simile alla seguente:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Questa operazione funziona, ma verrà visualizzato un messaggio di caricamento sul client a prescindere dalla velocità della rete. Se vuoi evitare messaggi strani, puoi provare questo approccio, in cui nidifichiamo il selettore nello snippet sopra all'interno di una classe slow:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Da qui puoi usare JavaScript nella parte dell'intestazione per leggere il tipo di connessione effettivo (almeno nei browser Chromium) per aggiungere la classe slow all'elemento <html> su determinati tipi di connessione:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

In questo modo, i tipi di connessione effettivi più lenti rispetto al tipo 4g riceveranno un messaggio di caricamento. Quindi, nella parte dei contenuti, puoi inserire un elemento <script> incorporato per rimuovere la classe slow dal codice HTML ed eliminare il messaggio di caricamento:

<script>
  document.documentElement.classList.remove('slow');
</script>

Fornire una risposta di riserva

Supponiamo che tu stia utilizzando una strategia incentrata sulla rete per le parziali di contenuti. Se l'utente è offline e visita una pagina che ha già visitato, viene coperto. Tuttavia, se visitano una pagina che non sono ancora state visitate, non riceveranno nulla. Per evitare che ciò accada, dovrai pubblicare una risposta di riserva.

Il codice necessario per ottenere una risposta di riserva è dimostrato negli esempi di codice precedenti. La procedura prevede due passaggi:

  1. Prememorizzare nella cache una risposta di fallback offline.
  2. Imposta un callback handlerDidError nel plug-in per la tua strategia Network-first per controllare la cache per verificare la versione dell'ultimo accesso di una pagina. Se non è mai stato eseguito l'accesso alla pagina, devi utilizzare il metodo matchPrecache del modulo workbox-precaching per recuperare la risposta di riserva dalla preregistrazione.

Memorizzazione nella cache e CDN

Se utilizzi questo pattern di flussi di dati nel tuo service worker, verifica se quanto segue si applica alla tua situazione:

  • Utilizzi una CDN o qualsiasi altro tipo di cache intermedia/pubblica.
  • Hai specificato un'intestazione Cache-Control con istruzioni max-age e/o s-maxage diverse da zero in combinazione con l'istruzione public.

Se entrambe le cose sono adatti al tuo caso, la cache intermedia potrebbe contenere le risposte per le richieste di navigazione. Tuttavia, ricorda che quando utilizzi questo pattern, potrebbero essere visualizzate due risposte diverse per ogni URL specifico:

  • La risposta completa, contenente il markup dell'intestazione, dei contenuti e del piè di pagina.
  • La risposta parziale, che include solo i contenuti.

Ciò può causare alcuni comportamenti indesiderati, con conseguente markup dell'intestazione e del piè di pagina raddoppiati, poiché il service worker potrebbe recuperare una risposta completa dalla cache CDN e combinarla con il markup dell'intestazione e del piè di pagina prememorizzati nella cache.

Per aggirare questo problema, dovrai fare affidamento sull'intestazione Vary, che influisce sul comportamento di memorizzazione nella cache mediante la codifica delle risposte memorizzabili nella cache in una o più intestazioni presenti nella richiesta. Poiché le risposte alle richieste di navigazione variano in base alle intestazioni delle richieste Service-Worker-Navigation-Preload e X-Content-Mode personalizzate, dobbiamo specificare questa intestazione Vary nella risposta:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

Con questa intestazione, il browser distingue tra risposte complete e parziali per le richieste di navigazione, evitando problemi con il markup dell'intestazione e del piè di pagina raddoppiati, così come eventuali cache intermedie.

Il risultato

La maggior parte dei consigli sulle prestazioni in fase di caricamento si riduce a "mostrare agli utenti cosa ottieni". Non aspettare: non aspettare di avere tutto prima di mostrare qualcosa all'utente.

Jake Archibald in Fun Hacks for Faster Content

I browser sono eccellenti quando si tratta di gestire le risposte alle richieste di navigazione, anche per corpi di risposta HTML di grandi dimensioni. Per impostazione predefinita, i browser trasmettono ed elaborano progressivamente il markup in blocchi che evitano attività lunghe, il che è positivo per le prestazioni all'avvio.

Ciò è a nostro vantaggio quando utilizziamo un pattern di worker di servizio di streaming. Ogni volta che rispondi a una richiesta dalla cache del service worker fin dall'inizio, l'inizio della risposta arriva quasi istantaneamente. Quando unisci il markup di intestazione e piè di pagina prememorizzati nella cache con una risposta dalla rete, ottieni alcuni notevoli vantaggi in termini di prestazioni:

  • Time to First Byte (TTFB) sarà spesso notevolmente ridotto, poiché il primo byte della risposta a una richiesta di navigazione è istantaneo.
  • La funzionalità First Contentful Paint (FCP) sarà molto veloce, in quanto il markup dell'intestazione pre-memorizzato nella cache conterrà un riferimento a un foglio di stile memorizzato nella cache, il che significa che la pagina verrà visualizzata molto rapidamente.
  • In alcuni casi, anche Largest Contentful Paint (LCP) può essere più veloce, in particolare se l'elemento più grande sullo schermo viene fornito dalla parte dell'intestazione pre-memorizzata nella cache. Anche in questo caso, pubblicare il prima possibile qualcosa dalla cache del service worker insieme a payload di markup più piccoli può portare a un LCP migliore.

La configurazione e l'iterazione di architetture di flussi di dati multipagina possono essere un po' complesse, ma la complessità in questione spesso non è più gravosa delle APS in teoria. Il vantaggio principale è che non stai sostituendo lo schema di navigazione predefinito del browser, ma lo stai migliorando.

Meglio ancora, Workbox rende questa architettura non solo possibile, ma più facile di quanto si farebbe se si implementasse da soli. Provalo sul tuo sito web e scopri quanto può essere più veloce il tuo sito web con più pagine per gli utenti sul campo.

Risorse