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

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

  • Los navegadores de esquemas de navegación proporcionan de forma predeterminada, es decir, que ingresas una URL en la barra de direcciones de tu navegador y una solicitud de navegación muestra un documento como respuesta. Luego, se hace 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 la shell de aplicación y se basa en JavaScript para propagar la 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 son los siguientes:

  • El esquema de navegación que proporcionan los navegadores de forma predeterminada es resiliente, ya que las rutas no requieren JavaScript para ser accesible. La representación del lenguaje de marcado por parte de los clientes por medio de JavaScript 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 debido a que el dispositivo está bloqueado el procesamiento de 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 y obtener uno totalmente nuevo (y repetir esto para cada navegación), pueden ofrecer lo que parece ser una experiencia más rápida y similar a una aplicación, 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: basarse en un service worker para almacenar previamente en caché los elementos comunes de un sitio web (como el lenguaje de marcado de encabezado y pie de página) y usar flujos para proporcionar una respuesta HTML al cliente lo más rápido posible, todo ello con 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 procesar una página.

Diagrama en el que se muestran las diferencias entre HTML sin transmisión y HTML de transmisión. En el primer caso, no se procesa toda la carga útil del lenguaje de marcado hasta que llega. En el segundo, el lenguaje de marcado se procesa de forma incremental a medida que llega en partes de 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 solicitudes 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 recurrir a la caché si hay una copia más antigua disponible y, de manera opcional, proporcionar una respuesta de resguardo genérica si no hay una respuesta utilizable en la caché.

Este es un patrón probado en el tiempo para 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 de red. Aquí es donde entra en juego la transmisión. 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

Desde el punto de vista estructural, los sitios web tienden a tener elementos en común en cada página. Una disposición típica de los elementos de página suele usarse de la siguiente manera:

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

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

Un desglose de los elementos comunes del sitio web web.dev. Las áreas comunes delimitadas están marcadas como "encabezado", "contenido" y "pie de página".

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

Cuando sabemos cómo segmentar las partes de una página e identificar los elementos comunes, podemos escribir un service worker que siempre recupere el lenguaje de marcado del encabezado y el pie de página de forma instantánea desde la caché, al tiempo que solicita solo el contenido de la red.

Luego, con la API de Streams mediante workbox-streams, podemos unir todas estas partes y responder a las solicitudes de navegación al instante, a la vez que solicitamos 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 cuando se trata de la transmisión de contenido parcial en un service worker, pero cada paso del proceso se explorará en detalle a medida que avanzas, comenzando por la forma de estructurar tu sitio web.

Segmentar el sitio web en parciales

Antes de comenzar a escribir un service worker de transmisión, deberás realizar las siguientes tres tareas:

  1. Cree un archivo que contenga solo el lenguaje de marcado del encabezado de su 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 es de 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 contendrá el lenguaje de marcado completo de la página y la otra, solo el contenido.

Compón 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. 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 previamente en caché los parciales de encabezado y pie de página.

Almacenamiento previo en caché parciales

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 realiza algunas acciones:

  1. Habilita la precarga de navegación para los navegadores que la admiten.
  2. Almacena previamente en caché el lenguaje de marcado del encabezado y el pie de página. Esto significa que el lenguaje de marcado del encabezado y el pie de página de cada página se recuperará al instante, 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 en tiempo real

La mayor parte de este esfuerzo es lograr que tu service worker transmita respuestas concatenadas. Aun así, Workbox y su workbox-streams hacen que este sea un asunto mucho más breve que si tuvieras que hacer todo 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 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 que contenga los parciales de contenido, así como un complemento personalizado que controla si se debe establecer un encabezado de solicitud X-Content-Mode para 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 debe enviar la última versión almacenada en caché de un contenido parcial o 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 (que aquí se denomina composeStrategies) se usa para concatenar los parciales de encabezado y pie de página que se almacenaron previamente en caché junto con el contenido parcial solicitado desde la red.
  3. Todo el esquema se arma mediante registerRoute para las solicitudes de navegación.

Con esta lógica en su lugar, configuramos las respuestas de transmisión. Sin embargo, es posible que debas realizar algunas tareas en un backend para asegurarte de que el contenido de la red sea una página parcial que puedas combinar con los parciales que se almacenaron previamente en caché.

Si tu sitio web tiene un backend

Recuerda que, cuando se habilita la precarga de navegación, 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 la precarga de navegación de eventos no sea compatible con un navegador. 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, y toman el valor de $isPartial para cambiar la forma en que se renderizan los parciales. Por ejemplo, es posible que la función del procesador 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 considerar algunos aspectos. Si bien es cierto que usar un service worker de esta manera no cambia fundamentalmente el comportamiento de navegación predeterminado del navegador, es probable que debas solucionar algunos problemas.

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

La parte más complicada de este enfoque es que se deberán actualizar algunos aspectos en el cliente. Por ejemplo, el almacenamiento previo en caché del lenguaje de marcado del encabezado significa que la página tendrá el mismo contenido en el elemento <title> o, incluso, se deberá actualizar en cada navegación los estados de activación o desactivación de los elementos de navegación. Es posible que estos y otros elementos 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 continuar con la configuración de este service worker. En el caso de aplicaciones más complejas con información del usuario, por ejemplo, es posible que debas almacenar fragmentos de datos relevantes en una tienda web como localStorage y actualizar la página desde allí.

Cómo abordar redes lentas

Una desventaja de transmitir respuestas con lenguaje de marcado de la memoria caché previa puede ocurrir cuando las conexiones de red son lentas. El problema es que el lenguaje de marcado del encabezado del precaché llegará instantáneamente, 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 que no se renderiza más. En casos como este, puedes optar por colocar un ícono o mensaje de carga en el lenguaje de marcado del contenido parcial, que puedes ocultar una vez que se cargue el contenido.

Una forma de hacerlo es a través de CSS. Supongamos que tu encabezado parcial finaliza con un elemento <article> de apertura que está vacío hasta que llegue parte del contenido 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 deseas evitar una aparición extraña de mensajes, puedes probar este enfoque, en el que anidamos el selector en el fragmento anterior dentro de una clase slow:

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

Desde aquí, puedes usar JavaScript en el 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 tipos de conexión seleccionados:

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

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

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

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

Proporciona una respuesta de resguardo

Supongamos que usas una estrategia centrada en la red para tus segmentos de contenido parciales. Si el usuario no tiene conexión y visita una página en la que ya estuvo, eso no se aplica. Sin embargo, si visitan una página en la que aún no han estado, no obtendrán 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. Almacenamiento previo en caché de 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 comprobar 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 del precaché.

Almacenamiento en caché y CDN

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

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

Si ambos son tu caso, la caché intermedia puede retener respuestas para solicitudes de navegación. Sin embargo, recuerda que, si utilizas este patrón, es posible que se muestren dos respuestas diferentes para cualquier URL determinada:

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

Esto puede provocar comportamientos no deseados, lo que puede generar la duplicación del lenguaje de marcado del encabezado y el pie de página, ya que el service worker podría recuperar una respuesta completa de la caché de CDN y combinarla con el lenguaje de marcado de encabezado y pie de página almacenado en caché.

Para solucionar este problema, utiliza el encabezado Vary, que afecta el comportamiento del almacenamiento en caché mediante la clave de las respuestas que se pueden almacenar en caché a uno o más encabezados que estaban presentes en la solicitud. Debido a que variaremos las respuestas a las solicitudes de navegación en función de los encabezados de solicitud Service-Worker-Navigation-Preload y X-Content-Mode personalizados, debemos 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 lenguaje de marcado de encabezado y pie de página duplicado, al igual que cualquier caché intermedia.

Resultado

La mayoría de los consejos sobre el rendimiento del tiempo de carga se resumen en “muéstrales lo que tienes”. No te detengas, no esperes hasta tener todo antes de mostrarle algo al usuario.

Jake Archibald en Fun Hacks for Faster Content

Los navegadores se destacan cuando se trata 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 tareas largas, lo que es bueno para el rendimiento del inicio.

Esto nos beneficia 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 lenguaje de marcado de encabezado y pie de página prealmacenado 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á considerablemente, ya que el primer byte de la respuesta a una solicitud de navegación es instantáneo.
  • First Contentful Paint (FCP) será muy rápido, 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 se pintará muy rápidamente.
  • En algunos casos, el Largest Contentful Paint (LCP) también puede ser más rápido, en especial si el encabezado prealmacenado en caché proporciona el elemento más grande en pantalla. Aun así, entregar algo fuera de la caché del service worker lo antes posible en conjunto con cargas útiles de lenguaje de marcado más pequeñas podría generar un mejor LCP.

Las arquitecturas de transmisión de varias páginas pueden ser un poco difíciles de iterar y configurar, pero la complejidad involucrada suele no ser más engorrosa que las SPA en teoría. El principal beneficio es que no reemplazas el esquema de navegación predeterminado del navegador, sino que lo mejoras.

Mejor aún, Workbox hace que esta arquitectura no solo sea 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 tu sitio web de varias páginas para los usuarios en el campo.

Recursos