La guía sin conexión

Con Service Worker, renunciamos a intentar resolver el problema sin conexión y les dimos a los desarrolladores las piezas móviles para que lo resuelvan por su cuenta. Te brinda control sobre la caché y cómo se manejan las solicitudes. Esto significa que puedes crear tus propios patrones. Analicemos algunos patrones posibles de forma individual, pero, en la práctica, es probable que uses muchos de ellos en conjunto según la URL y el contexto.

Para ver una demostración funcional de algunos de estos patrones, consulta Entrenados para emocionar y este video en el que se muestra el impacto en el rendimiento.

La máquina de caché: cuándo almacenar recursos

Service Worker te permite controlar las solicitudes de forma independiente de la caché, por lo que los mostraré por separado. En primer lugar, ¿cuándo se debe usar el almacenamiento en caché?

Durante la instalación, como una dependencia

Durante la instalación, como una dependencia.
Durante la instalación, como una dependencia.

El trabajador de servicio te proporciona un evento install. Puedes usarlo para preparar elementos que deben estar listos antes de controlar otros eventos. Mientras esto sucede, cualquier versión anterior de tu trabajador de servicio sigue ejecutando y publicando páginas, por lo que lo que hagas aquí no debe interrumpirlo.

Ideal para: CSS, imágenes, fuentes, JS, plantillas… básicamente, todo lo que consideres estático para esa “versión” de tu sitio.

Estos son elementos que harían que tu sitio no funcione en absoluto si no se recuperaran, elementos que una app equivalente específica de la plataforma incluiría en la descarga inicial.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil toma una promesa para definir la duración y el éxito de la instalación. Si se rechaza la promesa, la instalación se considera un error y se abandonará este trabajador de servicio (si se ejecuta una versión anterior, se dejará intacta). caches.open() y cache.addAll() devuelven promesas. Si no se recupera alguno de los recursos, se rechaza la llamada a cache.addAll().

En trained-to-thrill, lo uso para almacenar en caché recursos estáticos.

En la instalación, no como una dependencia

En la instalación, no como una dependencia.
Durante la instalación, no como una dependencia.

Esto es similar a lo anterior, pero no retrasará la finalización de la instalación ni hará que falle si falla la caché.

Ideal para lo siguiente: Recursos más grandes que no se necesitan de inmediato, como recursos para niveles posteriores de un juego.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

En el ejemplo anterior, no se pasa la promesa cache.addAll para los niveles del 11 al 20 a event.waitUntil, por lo que, incluso si falla, el juego seguirá disponible sin conexión. Por supuesto, deberás tener en cuenta la posible ausencia de esos niveles y volver a intentar almacenarlos en caché si no están.

Es posible que se cancele el trabajador de servicio mientras se descargan los niveles del 11 al 20, ya que terminó de controlar los eventos, lo que significa que no se almacenarán en caché. En el futuro, la API de Web Periodic Background Sync controlará casos como este y descargas más grandes, como películas. Actualmente, esa API solo es compatible con bifurcaciones de Chromium.

Cuando se activa

Cuando se activa.
Cuando se activa.

Ideal para lo siguiente: Limpieza y migración.

Una vez que se instala un nuevo trabajador de servicio y no se usa una versión anterior, se activa el nuevo y recibes un evento activate. Como la versión anterior ya no está disponible, es un buen momento para controlar las migraciones de esquemas en IndexedDB y borrar las cachés que no se usan.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

Durante la activación, otros eventos, como fetch, se ponen en una cola, por lo que una activación prolongada podría bloquear las cargas de página. Mantén la activación lo más simple posible y úsala solo para las acciones que no podías realizar mientras la versión anterior estaba activa.

En trained-to-thrill, uso esto para quitar las cachés antiguas.

Cuando el usuario interactúa

Cuando el usuario interactúa.
Cuando el usuario interactúa.

Ideal para lo siguiente: Cuando no se puede acceder a todo el sitio sin conexión y decides permitir que el usuario seleccione el contenido que desea que esté disponible sin conexión. p. ej., un video en YouTube, un artículo en Wikipedia o una galería en Flickr

Proporciona al usuario un botón "Leer más tarde" o "Guardar para usar sin conexión". Cuando se hace clic en él, recupera lo que necesitas de la red y lo coloca en la caché.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

La API de cachés está disponible desde las páginas y los service workers, lo que significa que puedes agregar elementos a la caché directamente desde la página.

En la respuesta de la red

En la respuesta de la red.
En la respuesta de la red.

Ideal para lo siguiente: Actualizar recursos con frecuencia, como la bandeja de entrada de un usuario o el contenido de un artículo. También es útil para el contenido no esencial, como los avatares, pero se debe tener cuidado.

Si una solicitud no coincide con nada en la caché, obténla de la red, envíala a la página y agrégala a la caché al mismo tiempo.

Si lo haces para un rango de URLs, como los avatares, deberás tener cuidado de no aumentar demasiado el almacenamiento de tu origen. Si el usuario necesita recuperar espacio en el disco, no quieres ser el candidato principal. Asegúrate de quitar de la caché los elementos que ya no necesites.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

Para permitir un uso eficiente de la memoria, solo puedes leer el cuerpo de una respuesta o solicitud una vez. El código anterior usa .clone() para crear copias adicionales que se pueden leer por separado.

En trained-to-thrill, lo uso para almacenar en caché imágenes de Flickr.

Inactivo durante la revalidación

Inactivo durante la revalidación
Stale-while-revalidate.

Ideal para: Recursos que se actualizan con frecuencia en los que no es esencial tener la versión más reciente. Los avatares pueden pertenecer a esta categoría.

Si hay una versión almacenada en caché disponible, úsala, pero recupera una actualización para la próxima vez.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

Esto es muy similar a stale-while-revalidate de HTTP.

Mensaje de activación

En el mensaje push.
En el mensaje push.

La API de Push es otra función compilada en Service Worker. Esto permite que el trabajador de servicio se active en respuesta a un mensaje del servicio de mensajería del SO. Esto sucede incluso cuando el usuario no tiene una pestaña abierta en tu sitio. Solo se activa el trabajador de servicio. Solicitas permiso para hacerlo desde una página y se le solicita al usuario que lo haga.

Ideal para: Contenido relacionado con una notificación, como un mensaje de chat, una noticia de última hora o un correo electrónico. También cambia con poca frecuencia el contenido que se beneficia de la sincronización inmediata, como una actualización de una lista de tareas pendientes o una alteración de un calendario.

El resultado final común es una notificación que, cuando se presiona, abre o enfoca una página relevante, pero para la que es muy importante actualizar las cachés antes de que esto suceda. Obviamente, el usuario está en línea cuando recibe el mensaje push, pero es posible que no lo esté cuando finalmente interactúe con la notificación, por lo que es importante que este contenido esté disponible sin conexión.

Este código actualiza las cachés antes de mostrar una notificación:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

En background-sync

En background-sync.
En background-sync.

La sincronización en segundo plano es otra función compilada sobre Service Worker. Te permite solicitar la sincronización de datos en segundo plano de forma única o en un intervalo (extremadamente heurístico). Esto sucede incluso cuando el usuario no tiene una pestaña abierta en tu sitio. Solo se activa el trabajador de servicio. Solicitas permiso para hacerlo desde una página y se le solicita al usuario.

Ideal para lo siguiente: Actualizaciones no urgentes, en especial aquellas que ocurren con tanta frecuencia que un mensaje push por actualización sería demasiado frecuente para los usuarios, como las redes sociales o los artículos de noticias.

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

Persistencia de la caché

Tu origen tiene una cantidad determinada de espacio libre para hacer lo que quiera. Ese espacio libre se comparte entre todo el almacenamiento de origen: Almacenamiento(local), IndexedDB, Acceso al sistema de archivos y, por supuesto, Cachés.

El importe que recibes no está especificado. Este tiempo variará según el dispositivo y las condiciones de almacenamiento. Puedes averiguar cuánto tienes de las siguientes maneras:

if (navigator.storage && navigator.storage.estimate) {
  const quota = await navigator.storage.estimate();
  // quota.usage -> Number of bytes used.
  // quota.quota -> Maximum number of bytes available.
  const percentageUsed = (quota.usage / quota.quota) * 100;
  console.log(`You've used ${percentageUsed}% of the available storage.`);
  const remaining = quota.quota - quota.usage;
  console.log(`You can write up to ${remaining} more bytes.`);
}

Sin embargo, como todo el almacenamiento del navegador, el navegador puede descartar tus datos si el dispositivo se ve sometido a presión de almacenamiento. Lamentablemente, el navegador no puede distinguir entre las películas que quieres conservar a toda costa y el juego que no te importa.

Para solucionar este problema, usa la interfaz de StorageManager:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

Por supuesto, el usuario debe otorgar permiso. Para ello, usa la API de Permissions.

Es importante que el usuario forme parte de este flujo, ya que ahora podemos esperar que controle la eliminación. Si el dispositivo tiene problemas de almacenamiento y borrar los datos no esenciales no lo resuelve, el usuario puede decidir qué elementos conservar y quitar.

Para que esto funcione, los sistemas operativos deben tratar los orígenes "durables" como equivalentes a las apps específicas de la plataforma en sus desgloses del uso de almacenamiento, en lugar de informar el navegador como un solo elemento.

Publicación de sugerencias: Responde a las solicitudes

No importa cuánta caché crees, el trabajador de servicio no la usará, a menos que le digas cuándo y cómo. Estos son algunos patrones para controlar las solicitudes:

Solo caché

Solo caché.
Solo en caché.

Ideal para lo siguiente: Cualquier elemento que consideres estático para una "versión" en particular de tu sitio. Deberías almacenarlos en caché en el evento de instalación para que puedas depender de que estén allí.

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

…aunque no sueles necesitar controlar este caso de forma específica, Caché, con resguardo en la red lo cubre.

Solo de red

Solo de red
Solo en la red.

Ideal para lo siguiente: elementos que no tienen un equivalente sin conexión, como pings de Analytics y solicitudes que no son GET.

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

…aunque no sueles necesitar controlar este caso de forma específica, Caché, con resguardo en la red lo cubre.

Caché, con resguardo en la red

Caché, con resguardo en la red
Caché, con resguardo en la red.

Ideal para: compilar sin conexión. En esos casos, así es como controlará la mayoría de las solicitudes. Otros patrones serán excepciones según la solicitud entrante.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

Esto te brinda el comportamiento "solo caché" para los elementos en la caché y el comportamiento "solo red" para todo lo que no está almacenado en caché (lo que incluye todas las solicitudes que no son GET, ya que no se pueden almacenar en caché).

Competición entre caché y red

Carrera de caché y red.
Carrera de caché y red.

Ideal para: Recursos pequeños en los que buscas el rendimiento en dispositivos con acceso lento al disco.

Con algunas combinaciones de discos duros más antiguos, escáneres de virus y conexiones a Internet más rápidas, obtener recursos de la red puede ser más rápido que ir al disco. Sin embargo, ir a la red cuando el usuario tiene el contenido en su dispositivo puede ser un desperdicio de datos, así que tenlo en cuenta.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

La red recurre a la caché

La red recurre a la caché.
La red recurre a la caché.

Ideal para lo siguiente: Una solución rápida para recursos que se actualizan con frecuencia, fuera de la "versión" del sitio. P.ej., artículos, avatares, líneas de tiempo de redes sociales y tablas de clasificación de juegos.

Esto significa que les brindas a los usuarios en línea el contenido más actualizado, pero los usuarios sin conexión obtienen una versión más antigua almacenada en caché. Si la solicitud de red se realiza correctamente, es probable que desees actualizar la entrada de caché.

Sin embargo, este método tiene fallas. Si el usuario tiene una conexión intermitente o lenta, deberá esperar a que falle la red para obtener el contenido perfectamente aceptable que ya está en su dispositivo. Esto puede tardar mucho tiempo y es una experiencia del usuario frustrante. Consulta el siguiente patrón, Caché y, luego, red, para obtener una mejor solución.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

Almacenamiento en caché y, luego, red

Almacenamiento en caché y, luego, red.
Caché y, luego, red.

Ideal para: Contenido que se actualiza con frecuencia. p. ej., artículos, líneas de tiempo de redes sociales y tablas de clasificación de juegos.

Esto requiere que la página realice dos solicitudes, una a la caché y otra a la red. La idea es mostrar primero los datos almacenados en caché y, luego, actualizar la página cuando lleguen los datos de la red o si lo hacen.

A veces, puedes reemplazar los datos actuales cuando llegan datos nuevos (p.ej., la tabla de clasificación del juego), pero eso puede ser disruptivo con contenido más extenso. Básicamente, no hagas que "desaparezcan" elementos con los que el usuario pueda estar leyendo o interactuando.

Twitter agrega el contenido nuevo sobre el contenido anterior y ajusta la posición de desplazamiento para que el usuario no se vea afectado. Esto es posible porque Twitter conserva un orden lineal en su contenido. Copié este patrón para entrenar para emocionar y mostrar contenido en pantalla lo más rápido posible, a la vez que se muestra contenido actualizado en cuanto llega.

Código en la página:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Código en el trabajador de servicio:

Siempre debes ir a la red y actualizar una caché a medida que avanzas.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

En trained-to-thrill, resolví este problema con XHR en lugar de recuperar y abusando del encabezado Accept para indicarle al servicio trabajador de dónde obtener el resultado (código de página, código de servicio trabajador).

Resguardo genérico

resguardo genérico
Resguardo genérico.

Si no puedes publicar contenido desde la caché o la red, te recomendamos que proporciones un resguardo genérico.

Ideal para lo siguiente: Imágenes secundarias, como avatares, solicitudes POST fallidas y una página que indique que no está disponible sin conexión.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

Es probable que el elemento al que recurras sea una dependencia de instalación.

Si tu página publica un correo electrónico, es posible que tu trabajador de servicio recurra al almacenamiento del correo electrónico en una "carpeta de salida" de IndexedDB y responda informándole a la página que el envío falló, pero que los datos se conservaron correctamente.

Plantillas del trabajador del servicio

Plantillas del lado de ServiceWorker
Plantillas del lado de ServiceWorker.

Ideal para: Páginas que no pueden almacenar en caché la respuesta del servidor.

La renderización de páginas en el servidor hace que todo sea más rápido, pero eso puede significar que se incluyan datos de estado que no tengan sentido en una caché, p.ej., "Se accedió como…". Si un service worker controla tu página, puedes solicitar datos JSON junto con una plantilla y renderizarlos.

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

Une todo

No estás limitado a uno de estos métodos. De hecho, es probable que uses muchos de ellos según la URL de la solicitud. Por ejemplo, entrenado para emocionar usa lo siguiente:

Solo observa la solicitud y decide qué hacer:

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

… ya te haces una idea.

Créditos

…por los hermosos íconos:

Y gracias a Jeff Posnick por detectar muchos errores graves antes de que presionara “Publicar”.

Lecturas adicionales