La guía sin conexión

Con Service Worker, dejamos de intentar resolver tareas sin conexión y les dimos a los desarrolladores las partes móviles para resolverlas por su cuenta. Te permite controlar el almacenamiento en caché y la forma en que se manejan las solicitudes. Eso significa que puedes crear tus propios patrones. Veamos algunos patrones posibles de forma aislada, 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 Trained-to-Threat y este video donde se muestra el impacto en el rendimiento.

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

Service Worker te permite controlar las solicitudes independientemente del almacenamiento en caché, así que las mostraré por separado. En primer lugar, hablaremos del almacenamiento en caché. ¿Cuándo se debe hacer?

Durante la instalación, como una dependencia

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

Service Worker te proporciona un evento install. Puedes usarlo para preparar elementos, que deben estar listos antes de manejar otros eventos. Si bien esto sucede, cualquier versión anterior de tu Service Worker aún se está ejecutando y entrega páginas, por lo que las acciones que realices aquí no deben interrumpirlo.

Ideal para CSS, imágenes, fuentes, JS, plantillas... Básicamente, para cualquier elemento que consideres estático en esa “versión” de tu sitio.

Son elementos que, si no se recuperaran, harían que tu sitio no funcione por completo, elementos que una app equivalente específica de la plataforma formaría parte de 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 longitud y el éxito de la instalación. Si se rechaza la promesa, se considera que la instalación falló y se abandonará este service worker (si se ejecuta una versión anterior, no se modificará). caches.open() y cache.addAll() muestran promesas. Si no se puede recuperar alguno de los recursos, se rechaza la llamada a cache.addAll().

En Trained-to- thriller, se usa para almacenar recursos estáticos en caché.

Durante la instalación (no como una dependencia)

Durante 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 y no provocará que la instalación falle si falla el almacenamiento en caché.

Ideal para recursos más grandes que no se necesitan de inmediato, como elementos 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
        ();
    }),
  );
});

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

Se puede cerrar Service Worker mientras se descargan los niveles 11 a 20, ya que terminó de controlar los eventos, lo que significa que no se almacenarán en caché. En el futuro, la API de sincronización en segundo plano web periódica se encargará de casos como este y de descargas más grandes, como películas. Por el momento, esa API solo es compatible con las bifurcaciones de Chromium.

Activar

Activación.
Durante la activación.

Ideal para limpieza y migración.

Una vez que se instala un nuevo Service Worker y no se usa una versión anterior, se activa el nuevo y obtienes 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 colocan en una cola, por lo que una activación larga podría bloquear la carga de la página. Mantén la activación lo más eficiente posible y úsala solo para tareas que no podías hacer mientras la versión anterior estaba activa.

En Trained-to-rocket, uso esta opción para quitar cachés antiguas.

Cuando el usuario interactúa

Durante la interacción del usuario.
Durante la interacción del usuario.

Ideal para cuando todo el sitio no se puede reproducir sin conexión y eliges permitir que el usuario seleccione el contenido que desea que esté disponible sin conexión. Por ejemplo, un video en una plataforma como YouTube, un artículo de Wikipedia o una determinada galería de Facebook.

Establece el botón "Leer más tarde" o "Guardar para sin conexión" en el usuario. Cuando haces clic en él, recupera lo que necesites de la red y guárdalo en 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é está disponible en páginas y service workers, lo que significa que puedes agregarla a la caché directamente desde la página.

En respuesta de la red

Durante la respuesta de la red.
Durante la respuesta de la red.

Ideal para actualizar recursos con frecuencia, como la carpeta Recibidos del 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 ningún elemento de la caché, obtenla de la red, envíala a la página y agrégala al mismo tiempo.

Si lo haces para un rango de URLs, como avatares, deberás tener cuidado de no sobrecargar el almacenamiento de tu origen. Si el usuario necesita recuperar espacio en el disco, no querrás ser el candidato ideal. Asegúrate de eliminar los elementos de la caché 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- thriller, se usa para almacenar en caché imágenes de Slack.

Revalidación inactiva

Está inactiva durante la revalidación.
Inactiva durante la revalidación.

Ideal para actualizar recursos con frecuencia en los que no es esencial tener la versión más reciente. Los avatares pueden entrar en esta categoría.

Si hay una versión almacenada en caché disponible, úsala, pero busca 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.

En mensaje push

En mensaje push.
Durante un mensaje push.

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

Ideal para contenido relacionado con una notificación, como un mensaje de chat, una noticia de último momento o un correo electrónico. También es contenido que cambia con poca frecuencia y que se beneficia de la sincronización inmediata, como una actualización de una lista de tareas pendientes o un cambio en el calendario.

El resultado final común es una notificación que, cuando se presiona, abre o enfoca una página relevante, pero para la cual es extremely importante actualizar las cachés antes de que esto suceda. Es obvio que el usuario está en línea cuando recibe el mensaje push, pero es posible que no lo esté cuando 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/');
  }
});

Con sincronización en segundo plano

Cuando se activa la sincronización en segundo plano.
Durante la sincronización en segundo plano.

La sincronización en segundo plano es otra función que se compila sobre el service worker. Te permite solicitar sincronización de datos en segundo plano como una sola vez o en un intervalo (extremadamente heurístico). Esto sucede incluso cuando el usuario no tiene una pestaña abierta de tu sitio. Solo se despierta al service worker. Pides permiso para hacerlo desde una página y se le solicitará permiso al usuario.

Ideal para actualizaciones que no sean urgentes, en especial aquellas que ocurren tan seguido que un mensaje push por actualización sería demasiado frecuente para los usuarios, como cronogramas de redes sociales o 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 recibe una cierta cantidad de espacio libre para hacer lo que desee. Ese espacio libre se comparte entre todo el almacenamiento de origen: (local) Storage, IndexedDB, Acceso al sistema de archivos y, por supuesto, Cachés.

El importe que recibes no está especificado. Diferirá según las condiciones del dispositivo y de almacenamiento. Para saber cuánto tienes, usa lo siguiente:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

Sin embargo, como todo el almacenamiento del navegador, este puede descartar tus datos si el dispositivo se encuentra en condiciones de almacenamiento. Lamentablemente, el navegador no puede diferenciar entre las películas que quieres conservar a toda costa y el juego que no te interesa.

Para solucionar esto, 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.
});

Obviamente, 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 controlará la eliminación. Si su dispositivo está bajo presión de almacenamiento y borrar los datos no esenciales no resuelve el problema, el usuario podrá juzgar qué elementos conservar y quitar.

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

Sugerencias de publicación: cómo responder a solicitudes

No importa cuánto almacenamiento en caché hagas, el service worker no la usará a menos que le indiques cuándo y cómo. Estos son algunos patrones para controlar solicitudes:

Solo caché

Solo caché.
Solo caché.

Ideal para cualquier elemento que consideres estático en una "versión" específica de tu sitio. Deberías haber almacenado en caché estos elementos en el evento de instalación, por lo que puedes estar seguro de que estará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));
});

Sin embargo, no necesitas preocuparte por este caso de forma específica. Se aborda en Caché y recurrir a la red.

Solo de red

Solo de red.
Solo de red.

Ideal para 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
});

Sin embargo, no necesitas preocuparte por este caso de forma específica. Se aborda en Caché y recurrir a la red.

Caché y recurrir a la red

Caché y recurrir a la red.
Caché y recurrir a la red

Ideal para compilar priorizando el uso sin conexión. En esos casos, así es como manejarás 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 de “solo caché” para elementos en la caché y el comportamiento de “solo red” para todo lo que no se haya almacenado en caché (lo que incluye todas las solicitudes que no son GET, ya que no pueden almacenarse en caché).

La carrera de la caché y la red

La carrera de la caché y la red
La carrera de la caché y la red.

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

Con algunas combinaciones de discos duros antiguos, análisis de virus y conexiones a Internet más rápidas, obtener recursos de la red puede ser más rápido que pasar al disco. Sin embargo, ten en cuenta que ingresar a la red cuando el usuario tiene el contenido en su dispositivo puede ser un desperdicio de datos.

// 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)]));
});

Red y recurrir a la caché

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

Ideal para una solución rápida de los recursos que se actualizan con frecuencia en otra “versión” del sitio. P.ej., artículos, avatares, líneas de tiempo en redes sociales y marcadores de juegos.

Esto significa que ofreces a los usuarios en línea el contenido más actualizado, pero que los usuarios sin conexión obtienen una versión anterior almacenada en caché. Si la solicitud de red se realiza correctamente, te recomendamos que actualices la entrada de la caché.

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

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

Caché y, luego, red

Caché y, luego, red.
Caché y, luego, red.

Ideal para contenido que se actualiza con frecuencia. P. ej., artículos, cronogramas de redes sociales, juegos, tablas de clasificación.

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 si llegan los datos de la red.

A veces, simplemente puedes reemplazar los datos actuales cuando llegan datos nuevos (p.ej., en la tabla de clasificación de un juego), pero esto puede generar interrupciones en contenido más grande. Básicamente, no "desaparezcan" algo que el usuario esté leyendo o con el que esté interactuando.

Twitter agrega el contenido nuevo sobre el contenido anterior y ajusta la posición de desplazamiento para que el usuario no tenga interrupciones. Esto es posible porque Twitter mantiene principalmente un orden mayormente lineal del contenido. Copí este patrón para entrenado a emocionante para mostrar el contenido en pantalla lo más rápido posible y mostrar el contenido actualizado en cuanto lo reciba.

Código de 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 del Service Worker:

Siempre debes ir a la red y actualizar la 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-Threat, resolví esto con XHR en lugar de la recuperación y abusé del encabezado Accept para indicarle al Service Worker de dónde obtener el resultado (código de la página, código de Service Worker).

Resguardo genérico

Resguardo genérico.
Resguardo genérico.

Si no entregas algo de la caché o la red, te recomendamos que proporciones un resguardo genérico.

Ideal para imágenes secundarias, como avatares, solicitudes POST no exitosas y una página "No 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 alternativo sea una dependencia de instalación.

Si tu página publica un correo electrónico, tu service worker puede volver a almacenar el correo electrónico en una bandeja de salida de IndexedDB y responder informando a la página que el envío falló, pero los datos se retuvieron correctamente.

Plantillas del lado del service worker

Plantillas en el lado del ServiceWorker.
Plantillas del ServiceWorker.

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

Renderizar páginas en el servidor acelera el proceso, pero eso puede implicar incluir datos de estado que no tengan sentido en una caché, p. ej., “Accediste como...”. Si un service worker controla tu página, puedes optar por solicitar datos JSON junto con una plantilla y procesarlos en su lugar.

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, entrenamiento para emociones usa lo siguiente:

Solo mira 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);
    }),
  );
});

... te tomas una foto.

Créditos

... de los hermosos íconos:

Gracias a Jeff Posnick por descubrir muchos errores importantes antes de que presione "Publicar".

Lecturas adicionales