Aplicaciones de varias páginas más rápidas con flujos

En la actualidad, los sitios web (o las aplicaciones web, si así lo prefieres) tienden a utilizar uno de estos dos esquemas de navegación:

  • Los navegadores con el esquema de navegación proporcionan de forma predeterminada; es decir, debes ingresar una URL en la barra de direcciones del navegador y una solicitud de navegación muestra un documento como respuesta. Luego, haces clic en un vínculo, que descarga el documento actual de otro, ad infinitum.
  • El patrón de aplicación de una sola página, que implica una solicitud de navegación inicial para cargar el shell de la aplicación y se basa en JavaScript para completar el shell de la aplicación con lenguaje de marcado renderizado por el cliente con contenido de una API de backend para cada "navegación".

Los defensores de cada enfoque han promovido los beneficios de cada enfoque:

  • El esquema de navegación que proporcionan los navegadores de forma predeterminada es resiliente, ya que las rutas no requieren que se pueda acceder a JavaScript. La renderización de lenguaje de marcado por medio de JavaScript por parte del cliente también puede ser un proceso potencialmente costoso, lo que significa que los dispositivos de gama baja pueden terminar en una situación en la que el contenido se retrasa porque el dispositivo está bloqueado para procesar las secuencias de comandos que proporcionan contenido.
  • Por otro lado, las aplicaciones de una sola página (SPA) pueden proporcionar navegaciones más rápidas después de la carga inicial. En lugar de depender del navegador para descargar un documento en uno completamente nuevo (y repetir esto en cada navegación), pueden ofrecer una experiencia más rápida y similar a una aplicación. experiencia, incluso si eso requiere JavaScript para funcionar.

En esta publicación, hablaremos de un tercer método que logra un equilibrio entre los dos enfoques descritos anteriormente: depender de un service worker para almacenar en caché previamente los elementos comunes de un sitio web (como el lenguaje de marcado de encabezado y pie de página) y usar transmisiones para proporcionar una respuesta HTML al cliente lo más rápido posible, todo esto mientras se utiliza el esquema de navegación predeterminado del navegador.

¿Por qué transmitir respuestas HTML en un service worker?

La transmisión es algo que tu navegador web ya hace cuando realiza solicitudes. Esto es extremadamente importante en el contexto de las solicitudes de navegación, ya que garantiza que el navegador no esté bloqueado a la espera de la respuesta completa antes de que pueda comenzar a analizar el lenguaje de marcado del documento y representar una página.

Un diagrama que muestra una comparación entre los archivos HTML que no son de transmisión y aquellos que no son de transmisión. En el primer caso, toda la carga útil del lenguaje de marcado no se procesa hasta que llega. En el último, el lenguaje de marcado se procesa de forma incremental a medida que llega en fragmentos desde la red.

Para los service workers, la transmisión es un poco diferente, ya que usa la API de Streams de JavaScript. La tarea más importante que realiza un service worker es interceptar y responder a las solicitudes, incluidas las de navegación.

Estas solicitudes pueden interactuar con la caché de varias maneras, pero un patrón de almacenamiento en caché común para el lenguaje de marcado es favorecer el uso de una respuesta de la red primero, pero recurre a la caché si hay una copia más antigua disponible y, opcionalmente, proporciona una respuesta de resguardo genérica si no hay una respuesta utilizable en la caché.

Este es un patrón probado por el tiempo para el lenguaje de marcado que funciona bien, pero, si bien ayuda con la confiabilidad en términos de acceso sin conexión, no ofrece ninguna ventaja de rendimiento inherente para las solicitudes de navegación que dependen de una estrategia centrada en la red o solo en la red. Ahí es donde entra en juego la transmisión, y exploraremos cómo usar el módulo workbox-streams potenciado por la API de Streams en tu service worker de Workbox para acelerar las solicitudes de navegación en tu sitio web de varias páginas.

Desglosar una página web típica

En términos estructurales, los sitios web tienden a tener elementos en común en todas las páginas. Una disposición típica de los elementos de página suele ser algo así:

  • Encabezado.
  • Contenido.
  • Pie de página.

Con web.dev como ejemplo, ese desglose de elementos comunes se verá de la siguiente manera:

Un desglose de los elementos comunes en el sitio web web.dev. Las áreas comunes delimitadas se marcan como "encabezado", "contenido" y "pie de página".

El objetivo detrás de la identificación de partes de una página es determinar qué se puede almacenar previamente en caché y recuperar sin ir a la red (es decir, el lenguaje de marcado del encabezado y el pie de página común en todas las páginas) y la parte de la página para la que siempre iremos a la red primero (el contenido en este caso).

Si sabemos cómo segmentar las partes de una página e identificar los elementos comunes, podemos escribir un service worker que siempre recupere el marcado de encabezado y pie de página instantáneamente de la caché, mientras solicita solo el contenido de la red.

Luego, con la API de Streams a través de workbox-streams, podemos unir todas estas partes y responder a las solicitudes de navegación de forma instantánea, mientras se solicita la cantidad mínima de lenguaje de marcado necesario de la red.

Cómo compilar un service worker de transmisión

Hay muchos aspectos dinámicos en lo que respecta a la transmisión de contenido parcial en un service worker, pero cada paso del proceso se explorará en detalle a medida que avances, comenzando por cómo estructurar tu sitio web.

la segmentación de tu sitio web en elementos parciales

Antes de comenzar a escribir un service worker de transmisión, debes realizar los siguientes tres pasos:

  1. Crea un archivo que contenga solo el lenguaje de marcado del encabezado de tu sitio web.
  2. Crea un archivo que contenga solo el lenguaje de marcado del pie de página de tu sitio web.
  3. Extrae el contenido principal de cada página en un archivo separado o configura tu backend para que publique condicionalmente solo el contenido de la página en función de un encabezado de solicitud HTTP.

Como puedes esperar, el último paso es el más difícil, especialmente si tu sitio web es estático. Si ese es tu caso, deberás generar dos versiones de cada página: una incluirá el lenguaje de marcado de la página completa y la otra solo el contenido.

Crea un service worker de transmisión

Si no instalaste el módulo workbox-streams, deberás hacerlo además de los módulos de Workbox que tengas instalados actualmente. Para este ejemplo específico, se incluyen los siguientes paquetes:

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

A partir de aquí, el siguiente paso es crear tu nuevo service worker y almacenar en caché por adelantado los elementos parciales del encabezado y el pie de página.

Almacenamiento previo en caché parcial

Lo primero que harás es crear un service worker en la raíz de tu proyecto llamado sw.js (o el nombre de archivo que prefieras). En él, comenzarás con lo siguiente:

// 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...

Este código hace lo siguiente:

  1. Habilita la precarga de navegación para los navegadores que la admiten.
  2. Almacena previamente en caché el lenguaje de marcado de encabezados y pies de página. Esto significa que el lenguaje de marcado del encabezado y el pie de página de todas las páginas se recuperará de forma instantánea, ya que la red no lo bloqueará.
  3. Almacena previamente en caché los elementos estáticos en el marcador de posición __WB_MANIFEST que usa el método injectManifest.

Respuestas de transmisión

Hacer que tu service worker transmita respuestas concatenadas es la parte más importante de todo este esfuerzo. Aun así, Workbox y su workbox-streams hacen que esto sea mucho más breve que si tuvieras que hacerlo por tu cuenta:

// 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.

Este código consta de tres partes principales que cumplen con los siguientes requisitos:

  1. Se usa una estrategia NetworkFirst para controlar las solicitudes de parciales de contenido. Con esta estrategia, se especifica un nombre de caché personalizado de content para incluir las partes del contenido, así como un complemento personalizado que controla si se debe establecer un encabezado de solicitud X-Content-Mode para los navegadores que no admiten la precarga de navegación (y, por lo tanto, no envían un encabezado Service-Worker-Navigation-Preload). Este complemento también determina si se debe enviar la última versión almacenada en caché de un parcial de contenido o si se debe enviar una página de resguardo sin conexión en caso de que no se almacene una versión almacenada en caché para la solicitud actual.
  2. El método strategy en workbox-streams (alias aquí composeStrategies) se usa para concatenar el encabezado y el pie de página almacenados previamente en caché junto con el contenido parcial solicitado a la red.
  3. Todo el esquema está configurado mediante registerRoute para las solicitudes de navegación.

Con esta lógica implementada, configuramos las respuestas de transmisión. Sin embargo, es posible que debas trabajar en un backend para asegurarte de que el contenido de la red sea una página parcial que puedas fusionar con los elementos parciales previamente almacenados en caché.

Si tu sitio web tiene un backend

Recordarás que cuando la carga previa de la navegación está habilitada, el navegador envía un encabezado Service-Worker-Navigation-Preload con un valor de true. Sin embargo, en la muestra de código anterior, enviamos un encabezado personalizado de X-Content-Mode en el caso de que un navegador no admite la precarga de navegación. En el backend, cambiarías la respuesta según la presencia de estos encabezados. En un backend de PHP, podría verse de la siguiente manera en una página determinada:

<?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();
}
?>

En el ejemplo anterior, los parciales de contenido se invocan como funciones, que toman el valor de $isPartial para cambiar la forma en que se renderizan. Por ejemplo, es posible que la función del renderizador content solo incluya cierto lenguaje de marcado en condiciones cuando se recupera como parcial, algo que se abordará en breve.

Consideraciones

Antes de implementar un service worker para transmitir y unir parciales, debes tener en cuenta algunos aspectos. Si bien es cierto que utilizar un service worker de esta manera no cambia en esencia el comportamiento de navegación predeterminado del navegador, es probable que haya algunos aspectos que deberás abordar.

Cómo actualizar elementos de página durante la navegación

La parte más complicada de este enfoque es que algunas cosas deberán actualizarse en el cliente. Por ejemplo, el lenguaje de marcado de encabezado previo en caché significa que la página tendrá el mismo contenido en el elemento <title>; incluso, la administración de estados de activación o desactivación de los elementos de navegación deberá actualizarse en cada navegación. Es posible que estos elementos, y otros, deban actualizarse en el cliente para cada solicitud de navegación.

La forma de evitar esto podría ser colocar un elemento <script> intercalado en el contenido parcial que proviene de la red para actualizar algunos aspectos importantes:

<!-- 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>

Este es solo un ejemplo de lo que podrías tener que hacer si decides usar esta configuración de service worker. En el caso de aplicaciones más complejas con información del usuario, por ejemplo, es posible que debas almacenar bits de datos relevantes en una tienda web como localStorage y actualizar la página desde allí.

Cómo lidiar con redes lentas

Una desventaja de la transmisión de respuestas con lenguaje de marcado de la caché previa puede ocurrir cuando las conexiones de red son lentas. El problema es que el lenguaje de marcado del encabezado de la precaché llegará de forma instantánea, pero el contenido parcial de la red puede tardar bastante tiempo en llegar después de la pintura inicial del lenguaje de marcado del encabezado.

Esto puede generar una experiencia confusa y, si las redes son muy lentas, incluso puede parecer que la página está dañada y ya no se renderiza. En casos como este, puedes optar por colocar un ícono o mensaje de carga en el lenguaje de marcado del contenido parcial y ocultarlo una vez que se cargue el contenido.

Una forma de hacerlo es con CSS. Supongamos que tu encabezado parcial termina con un elemento <article> de apertura que está vacío hasta que el contenido parcial llega para propagarlo. Podrías escribir una regla de CSS similar a la siguiente:

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

Esto funciona, pero mostrará un mensaje de carga en el cliente independientemente de la velocidad de la red. Si quieres evitar un destello extraño de mensajes, puedes probar este enfoque, en el que anidamos el selector del fragmento anterior dentro de una clase slow:

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

Desde aquí, puedes usar JavaScript en tu encabezado parcial para leer el tipo de conexión real (al menos en los navegadores Chromium) para agregar la clase slow al elemento <html> en determinados tipos de conexión:

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

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

Esto garantizará que los tipos de conexión efectivos más lentos que el tipo 4g reciban un mensaje de carga. Luego, en el parcial de contenido, puedes colocar un elemento <script> intercalado para quitar la clase slow del HTML y deshacerte del mensaje que se está cargando:

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

Proporciona una respuesta de resguardo

Supongamos que usas una estrategia centrada en la red para tus fragmentos de contenido. Si el usuario no tiene conexión y visita una página que ya visitó, esto no sucederá. Sin embargo, si visita una página que aún no visitó, no obtendrá nada. Para evitar esto, deberás publicar una respuesta de resguardo.

El código necesario para lograr una respuesta de resguardo se demuestra en las muestras de código anteriores. El proceso consta de dos pasos:

  1. Almacena previamente en caché una respuesta de resguardo sin conexión.
  2. Configura una devolución de llamada handlerDidError en el complemento para tu estrategia centrada en la red para verificar la caché de la última versión de una página a la que se accedió. Si nunca se accedió a la página, deberás usar el método matchPrecache del módulo workbox-precaching para recuperar la respuesta de resguardo de la caché previa.

Almacenamiento en caché y CDN

Si usas este patrón de transmisión en tu service worker, evalúa si se aplica lo siguiente a tu situación:

  • Usas una CDN o cualquier otro tipo de caché intermedia o pública.
  • Especificaste un encabezado Cache-Control con una directiva max-age o s-maxage distinta de cero en combinación con la directiva public.

Si ambos casos te suceden, la caché intermedia puede retener las respuestas a las solicitudes de navegación. Sin embargo, recuerda que cuando usas este patrón, es posible que se muestren dos respuestas diferentes para una URL determinada:

  • La respuesta completa, que incluye el lenguaje de marcado del encabezado, el contenido y el pie de página.
  • La respuesta parcial, que incluye solo el contenido.

Esto puede provocar algunos comportamientos no deseados, lo que genera un lenguaje de marcado doble del encabezado y el pie de página, ya que el service worker puede estar recuperando una respuesta completa de la caché de CDN y combinarla con el lenguaje de marcado de tu encabezado y pie de página previamente almacenado en caché.

Para solucionar este problema, deberás confiar en el encabezado Vary, que afecta el comportamiento del almacenamiento en caché mediante la clave de las respuestas que pueden almacenarse en caché en uno o más encabezados que estaban presentes en la solicitud. Debido a que variamos las respuestas de las solicitudes de navegación en función de los encabezados de solicitud Service-Worker-Navigation-Preload y X-Content-Mode personalizados, es necesario especificar este encabezado Vary en la respuesta:

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

Con este encabezado, el navegador diferenciará entre respuestas completas y parciales para las solicitudes de navegación, lo que evitará problemas con el lenguaje de marcado de encabezado y pie de página duplicado, al igual que cualquier caché intermedia.

El resultado

La mayoría de los consejos sobre el rendimiento durante el tiempo de carga se reducen a "mostrarles lo que obtuvieron"; no se detengan, no esperen hasta tener todo antes de mostrarle nada al usuario.

Jake Archibald en Fun Hacks for Faster Content .

Los navegadores sobresalen a la hora de manejar respuestas a solicitudes de navegación, incluso para cuerpos de respuesta HTML de gran tamaño. De forma predeterminada, los navegadores transmiten y procesan el lenguaje de marcado de forma progresiva en fragmentos que evitan las tareas largas, lo que es bueno para el rendimiento del inicio.

Esto es útil cuando usamos un patrón de service worker de transmisión. Cada vez que respondes a una solicitud desde la caché del service worker desde el principio, el inicio de la respuesta llega casi de inmediato. Cuando unes el lenguaje de marcado de encabezado y pie de página almacenado previamente en caché con una respuesta de la red, obtienes algunas ventajas notables de rendimiento:

  • El tiempo hasta el primer byte (TTFB) a menudo se reducirá de manera significativa, ya que el primer byte de la respuesta a una solicitud de navegación es instantáneo.
  • La primera pintura con contenido (FCP) será muy rápida, ya que el lenguaje de marcado del encabezado prealmacenado en caché contendrá una referencia a una hoja de estilo almacenada en caché, lo que significa que la página pintará muy, muy rápido.
  • En algunos casos, el Procesamiento de imagen con contenido más grande (LCP) también puede ser más rápido, en especial si el encabezado parcial prealmacenado en caché proporciona el elemento más grande en pantalla. Aun así, solo entregar algo de la caché del service worker lo antes posible en conjunto con cargas útiles de lenguaje de marcado más pequeñas puede generar un mejor LCP.

Las arquitecturas de transmisión de varias páginas pueden ser un poco complicadas de configurar e iterar, pero la complejidad involucrada a menudo no es más molesta que las SPA en teoría. La principal ventaja es que no reemplaza el esquema de navegación predeterminado del navegador, sino que lo mejoras.

Mejor aún, Workbox hace que esta arquitectura sea no solo posible, sino más fácil que si la implementaras por tu cuenta. Pruébalo en tu propio sitio web y observa cuánto más rápido puede ser un sitio web de varias páginas para los usuarios en el campo.

Recursos