Hacer que las PWAs trabajen desconectadas con servicio workers

Ahora que hemos visto cómo se ve la estructura de js13kPWA y hemos visto el intérprete básico en funcionamiento, veamos cómo se implementan las capacidades sin conexión con el Servicio Worker. En este artículo, analizamos cómo se usa en nuestro ejemplo de js13kPWA (ve el código fuente también). Examinamos cómo agregar funcionalidad fuera de línea.

El servicio workers explicado

El servicio workers son un delegado virtual entre el navegador y la red. Finalmente, solucionan problemas con los que los desarrolladores de aplicaciones para el usuario han luchado durante años, en particular, cómo almacenar correctamente en caché los activos de un sitio web y ponerlos a disposición cuando el dispositivo del usuario está desconectado.

Se ejecutan en un hilo separado del código JavaScript principal de nuestra página y no tienen acceso a la estructura DOM. Esto introduce un enfoque diferente al de la programación web tradicional — la API no bloquea y puede enviar y recibir comunicación entre diferentes contextos. Puede darle a un servicio worker algo en lo que trabajar y recibir el resultado cuando esté listo utilizando un enfoque basado en una promesa.

Pueden hacer mucho más que "solo" ofrecer capacidades sin conexión, incluido el manejo de notificaciones, la realización de cálculos pesados en un hilo separado, etc. El servicio workers es bastante poderoso, ya que pueden tomar el control de las solicitudes de red, modificarlas, entregar respuestas personalizadas recuperadas de la caché o sintetizar respuestas por completo.

Seguridad

Debido a que son tan poderosos, los Servicios Workers solo se pueden ejecutar en contextos seguros (es decir, HTTPS). Si deseas experimentar primero antes de enviar tu código a producción, siempre puedes probar en un anfitrión local o configurar las páginas de GitHub — ambas admiten HTTPS.

Desconectado primero

El patrón "desconectado primero" o "primero caché" es la estrategia más popular para entregar contenido al usuario. Si un recurso está almacenado en caché y disponible sin conexión, devuélvelo primero antes de intentar descargarlo del servidor. Si aún no está en la caché, descárgalo y almacénalo para uso futuro.

"Progresiva" en PWA

Cuando se implementa correctamente como una mejora progresiva, el servicio workers puede beneficiar a los usuarios que tienen navegadores modernos que admiten la API al brindar soporte fuera de línea, pero no romperán nada para aquellos que usan navegadores heredados.

El servicio workers en la aplicación js13kPWA

Suficiente teoría, ¡veamos algo de código real!

Registrar el servicio worker

Comenzaremos mirando el código que registra un nuevo Servicio Worker, en el archivo app.js:

Nota: Usamos la sintaxis de las funciones flecha de ES6 en la implementación del servicio worker

js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("./pwa-examples/js13kpwa/sw.js");
}

Si la API del servicio worker es compatible con el navegador, se registra en el sitio mediante el método ServiceWorkerContainer.register(). Su contenido reside en el archivo sw.js y se puede ejecutar después de que el registro sea exitoso. Es la única parte del código de servicio worker que se encuentra dentro del archivo app.js; todo lo demás que sea específico de servicio worker se escribe en el archivo sw.js.

Ciclo de vida de un servicio worker

Cuando se completa el registro, el archivo sw.js se descarga automáticamente, luego se instala y finalmente se activa.

Instalación

La API nos permite agregar escuchas de eventos para los eventos clave que nos interesan; el primero es el evento install:

js
self.addEventListener("install", (e) => {
  console.log("[Service Worker] Install");
});

En el escucha de install, podemos iniciar la caché y agregarle archivos para su uso sin conexión. Nuestra aplicación js13kPWA hace exactamente eso.

Primero, se crea una variable para almacenar el nombre de la caché y los archivos del intérprete de la aplicación se enumeran en un arreglo.

js
var cacheName = "js13kPWA-v1";
var appShellFiles = [
  "/pwa-examples/js13kpwa/",
  "/pwa-examples/js13kpwa/index.html",
  "/pwa-examples/js13kpwa/app.js",
  "/pwa-examples/js13kpwa/style.css",
  "/pwa-examples/js13kpwa/fonts/graduate.eot",
  "/pwa-examples/js13kpwa/fonts/graduate.ttf",
  "/pwa-examples/js13kpwa/fonts/graduate.woff",
  "/pwa-examples/js13kpwa/favicon.ico",
  "/pwa-examples/js13kpwa/img/js13kgames.png",
  "/pwa-examples/js13kpwa/img/bg.png",
  "/pwa-examples/js13kpwa/icons/icon-32.png",
  "/pwa-examples/js13kpwa/icons/icon-64.png",
  "/pwa-examples/js13kpwa/icons/icon-96.png",
  "/pwa-examples/js13kpwa/icons/icon-128.png",
  "/pwa-examples/js13kpwa/icons/icon-168.png",
  "/pwa-examples/js13kpwa/icons/icon-192.png",
  "/pwa-examples/js13kpwa/icons/icon-256.png",
  "/pwa-examples/js13kpwa/icons/icon-512.png",
];

A continuación, los enlaces a las imágenes que se cargarán junto con el contenido del archivo data/games.js se generan en el segundo arreglo. Después de eso, ambos arreglos se fusionan usando la función Array.prototype.concat().

js
var gamesImages = [];
for (var i = 0; i < games.length; i++) {
  gamesImages.push("data/img/" + games[i].slug + ".jpg");
}
var contentToCache = appShellFiles.concat(gamesImages);

Entonces podemos manejar el evento install en sí mismo:

js
self.addEventListener("install", (e) => {
  console.log("[Service Worker] Install");
  e.waitUntil(
    caches.open(cacheName).then((cache) => {
      console.log(
        "[Servicio Worker] Almacena todo en caché: contenido e intérprete de la aplicación",
      );
      return cache.addAll(contentToCache);
    }),
  );
});

Aquí hay dos cosas que necesitan una explicación: qué hace ExtendableEvent.waitUntil y qué es el objeto caches.

El servicio worker no se instala hasta que se ejecuta el código dentro de waitUntil. Este devuelve una promesa — este enfoque es necesario porque la instalación puede llevar algún tiempo, por lo que tenemos que esperar a que finalice.

caches es un objeto CacheStorage especial disponible en el ámbito del servicio worker dado para permitir guardar datos, guardar en el almacenamiento web no funcionará porque el almacenamiento web es síncrono. Con el servicio workers, usamos la API de Cache en su lugar.

Aquí, abrimos una caché con un nombre dado, luego agregamos a la caché todos los archivos que nuestra aplicación usa, para que estén disponibles la próxima vez que se cargue (identificado por la URL de la solicitud).

Activación

También hay un evento activate, que se usa de la misma manera que install. Este evento generalmente se usa para eliminar cualquier archivo que ya no sea necesario y limpiar después la aplicación en general. No es necesario que hagamos eso en nuestra aplicación, así que lo omitiremos.

Responder a las recuperaciones

También tenemos un evento fetch a nuestra disposición, que se activa cada vez que se activa una solicitud HTTP desde nuestra aplicación. Esto es muy útil, ya que nos permite interceptar solicitudes y reaccionar con respuestas personalizadas. Aquí hay un sencillo ejemplo de uso:

js
self.addEventListener("fetch", (e) => {
  console.log("[Servicio Worker] Recurso obtenido " + e.request.url);
});

La respuesta puede ser cualquier cosa que queramos: el archivo solicitado, su copia en caché o un fragmento de código JavaScript que hará algo específico; las posibilidades son infinitas.

En nuestra aplicación de ejemplo, servimos contenido de la caché en lugar de la red siempre que el recurso realmente esté en la caché. Hacemos esto tanto si la aplicación está en línea como si está fuera de línea. Si el archivo no está en la caché, la aplicación primero lo agrega allí antes de servirlo:

js
self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((r) => {
      console.log("[Servicio Worker] Obteniendo recurso: " + e.request.url);
      return (
        r ||
        fetch(e.request).then((response) => {
          return caches.open(cacheName).then((cache) => {
            console.log(
              "[Servicio Worker] Almacena el nuevo recurso: " + e.request.url,
            );
            cache.put(e.request, response.clone());
            return response;
          });
        })
      );
    }),
  );
});

Aquí, respondemos al evento fetch con una función que intenta encontrar el recurso en la caché y devolver la respuesta si está allí. Si no es así, usamos otra solicitud de recuperación para obtenerla de la red, luego almacenamos la respuesta en la caché para que esté disponible allí la próxima vez que se solicite.

El método FetchEvent.respondWith toma el control; esta es la parte que funciona como un servidor delegado entre la aplicación y la red. Esto nos permite responder a cada solicitud con cualquier cosa que queramos: preparada por el servicio worker, tomada de la caché o modificada si es necesario.

¡Eso es! Nuestra aplicación almacena en caché sus recursos al instalarlos y los sirve con la recuperación de la caché, por lo tanto, funciona incluso si el usuario está desconectado. También almacena en caché contenido nuevo cada vez que se agrega.

Actualizaciones

Todavía hay un punto que cubrir: ¿cómo se actualiza un servicio worker cuando hay disponible una nueva versión de la aplicación que contiene nuevos activos? El número de versión en el nombre de la caché es clave para esto:

js
var cacheName = "js13kPWA-v1";

Cuando esto se actualice a v2, podemos agregar todos nuestros archivos (incluidos nuestros archivos nuevos) a una nueva caché:

js
contentToCache.push("/pwa-examples/js13kpwa/icons/icon-32.png");

// ...

self.addEventListener("install", (e) => {
  e.waitUntil(
    caches.open("js13kPWA-v2").then((cache) => {
      return cache.addAll(contentToCache);
    }),
  );
});

Se instala un nuevo servicio worker en segundo plano y el anterior (v1) funciona correctamente hasta que no hay páginas que lo utilicen; el nuevo servicio worker se activa y se hace cargo de la administración de la página desde el anterior.

Limpiar la caché

¿Recuerdas el evento active que omitimos?, se puede usar para borrar la antigua caché que ya no necesitamos:

js
self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((keyList) => {
      return Promise.all(
        keyList.map((key) => {
          if (key !== cacheName) {
            return caches.delete(key);
          }
        }),
      );
    }),
  );
});

Esto asegura que solo tengamos los archivos que necesitamos en la caché, por lo que no dejamos basura; el espacio de caché disponible en el navegador es limitado, por lo que es una buena idea limpiarlo nosotros mismos después.

Otros casos de uso

Servir archivos desde la caché no es la única característica que ofrece el servicio worker. Si tienes que hacer cálculos pesados, los puedes descargar del hilo principal y hacerlos en el worker, y recibir el resultado tan pronto como estén disponibles. En cuanto al rendimiento, puedes obtener recursos que no se necesitan en este momento, pero que podrían serlo en un futuro cercano, por lo que la aplicación será más rápida cuando realmente los necesite.

Resumen

En este artículo, analizamos cómo puedes hacer que tu PWA funcione sin conexión con el servicio workers. Asegúrate de consultar nuestra documentación adicional si deseas obtener más información sobre los conceptos detrás de la API de Service Worker y cómo usarla con más detalle.

El servicio workers también se utilizan cuando se trata de notificaciones push; esto se explicará en un artículo posterior.