La guía sin conexión

Con el servicio en primer plano, ya no tenemos que intentar trabajar sin conexión y les damos a los desarrolladores las piezas móviles para que se las arreglen solos. Te permite controlar el almacenamiento en caché y la forma en que se manejan las solicitudes. Esto 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-thrill y este video en el que 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é, por lo que las demostraré por separado. En primer lugar, hablaremos del almacenamiento en caché. ¿Cuándo se debe realizar?

Durante la instalación, como una dependencia

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

El Service Worker te proporciona un evento install. Puedes usarlo para preparar elementos que deben estar listos antes de controlar otros eventos. Mientras esto ocurre, todas las versiones anteriores de tu servicio en primer plano seguirán en ejecución y mostrando páginas, por lo que las tareas que realices aquí no deben interrumpir nada de eso.

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 recuperan, provocarán que tu sitio deje de funcionar por completo; 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 recibe una promesa para definir la longitud y el éxito de la instalación. Si se rechaza la promesa, la instalación se considera errónea y se abandonará este Service Worker (si hay una versión anterior en ejecución, no se tocará). caches.open() y cache.addAll() devuelven promesas. Si no se puede recuperar alguno de los recursos, se rechaza la llamada a cache.addAll().

En trained-to-thrill, lo uso 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 parecido a lo descrito arriba, pero no causará demoras en la finalización de la instalación ni provocará un fallo en la instalación si no se realiza correctamente el almacenamiento en caché.

Ideal para: Recursos más grandes que no se necesitan de inmediato, como los 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 de cache.addAll para los niveles 11 a 20 a event.waitUntil, por lo que, incluso si falla, el juego seguirá disponible sin conexión. Por supuesto, deberás prever la posible ausencia de esos niveles y volver a intentar almacenarlos en caché si están ausentes.

Es posible que se detenga el trabajador de servicio 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 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: Limpieza y migración.

Cuando se instala un nuevo Service Worker y no se utiliza una versión anterior, el nuevo se activa y obtienes un evento activate. Dado que la versión anterior ya no se utiliza, es buen momento de controlar las migraciones de esquemas en IndexedDB y también borrar los cachés sin uso.

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 larga podría bloquear la carga de páginas. Mantén tu activación lo más optimizada posible y úsala solo para tareas que no podías hacer cuando la versión anterior estaba activa.

En Trained-to-thrill, lo uso para quitar cachés antiguas.

Cuando el usuario interactúa

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

Ideal para: Cuando no se puede usar todo el sitio sin conexión y decides 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 Flickr.

Proporciónale al usuario el botón "Leer más tarde" o "Guardar para usar sin conexión". Cuando se haga clic, recupera lo que necesites de la red y guárdalo 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 páginas y service workers, lo que significa que puedes agregar elementos a la caché directamente desde la página.

Durante la respuesta de la red

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

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

Si la solicitud no coincide con ningún elemento del caché, recupérala de la red, envíala a la página y agrégala al caché al mismo tiempo.

Si lo haces para un rango de URLs, como avatares, debes tener cuidado de no exceder el almacenamiento de tu origen. Si el usuario necesita recuperar espacio en el disco, no es buena idea que tu contenido sea lo primero que descarte. Asegúrate de eliminar los elementos del 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 que el uso de memoria sea eficiente, solo puedes leer el cuerpo de una respuesta o solicitud una sola vez. En el código anterior, se usa .clone() para crear copias adicionales que se pueden leer de forma individual.

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

Inactiva durante la revalidación

Stale-while-revalidate.
Stale-while-revalidate.

Ideal para: actualizar recursos con frecuencia cuando no es esencial contar con 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 actualizada 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;
      });
    }),
  );
});

Es muy similar a stale-while-revalidate de HTTP.

Mensaje push

En un mensaje push.
Durante un mensaje push.

La API de Push es otra función compilada sobre Service Worker. Esto permite que el servicio de trabajador se active en respuesta a un mensaje del servicio de mensajería del SO. Esto ocurre incluso cuando el usuario no tiene una pestaña abierta en tu sitio. Solo se activa el service worker. Solicita 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 para contenido que no cambia con frecuencia y que se beneficia de sincronizaciones inmediatas, como modificaciones de una lista de tareas pendientes o un cambio en el calendario.

Como resultado, se obtiene una notificación que, cuando se presiona, abre o selecciona una página relevante. Pero es extremadamente importante actualizar los cachés antes de que esto ocurra. Sin lugar a dudas, el usuario estará en línea cuando reciba el mensaje push, pero es posible que no lo esté cuando interactúe con la notificación. Por eso, es importante permitir que el 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/');
  }
});

Durante la sincronización en segundo plano

En background-sync.
Durante una sincronización en segundo plano.

La sincronización en segundo plano es otra función compilada en Service Worker. Te permite solicitar sincronización de datos en segundo plano de forma única o en intervalos (extremadamente heurísticos). Esto sucede incluso cuando el usuario no tiene una pestaña abierta de tu sitio. Solo se activa el service worker. Pides permiso para hacerlo desde una página y se le solicita al usuario que responda.

Ideal para: Actualizaciones que no sean urgentes, sobre todo aquellas que ocurren tan frecuentemente que sería demasiado mostrar un mensaje push por actualización para los usuarios, como muros 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 determinada cantidad de espacio libre para hacer lo que desee. Ese espacio libre se comparte entre todo el almacenamiento del origen: almacenamiento(local), IndexedDB, Acceso al sistema de archivos y, por supuesto, cachés.

El importe que recibes no está especificado. Dependerá de las condiciones del dispositivo y de almacenamiento. Para conocer la cantidad disponible, utiliza lo siguiente:

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 cualquier almacenamiento del navegador, este puede descartarlos libremente si el dispositivo se queda sin espacio. Desafortunadamente, el navegador no puede diferenciar entre las películas que quieres guardar sí o sí, y el juego que no te interesa perder.

Para solucionar este problema, usa la interfaz 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 dar permiso. Para ello, usa la API de Permissions.

Es importante que el usuario sea parte de este flujo, ya que ahora controlará la eliminación. Si su dispositivo se queda con poco espacio y el problema no se soluciona eliminando datos no esenciales, el usuario determinará los elementos que se conservarán y los que se quitarán.

Para que esto funcione, los sistemas operativos deben tratar a los orígenes “duraderos” de la misma forma que 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 para la publicación: responder a solicitudes

No importa cuántas veces realices almacenamiento en caché: el service worker no usará la caché hasta que le informes cuándo y cómo hacerlo. A continuación, se describen algunos patrones para controlar las solicitudes:

Solo caché

Solo caché.
Solo caché.

Ideal para: Cualquier elemento que consideres estático en una "versión" en particular de tu sitio. Se supone que almacenaste estos elementos en caché durante el evento de instalación, así que puedes estar tranquilo 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));
});

Sin embargo, por lo general, no hace falta que gestiones este caso específicamente: se analiza en Caché y recurrir a la red.

Solo de red

Solo de red
Solo de red.

Ideal para: Elementos que no tengan un equivalente sin conexión, como pings de Analytics y solicitudes que no sean 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, por lo general, no hace falta que gestiones este caso específicamente: se analiza 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 con una perspectiva de "primero 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 proporciona el comportamiento de “solo caché” para los elementos de la caché y el comportamiento de “solo red” para los elementos que no están almacenados en caché (incluidas todas las solicitudes que no sean GET, ya que no se pueden almacenar en caché).

La carrera de la caché y la red

Carrera de caché y red.
Carrera de caché y 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 ir al disco. Sin embargo, ten en cuenta que acceder 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é

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

Ideal para: Una solución rápida para los recursos que se actualizan con frecuencia en otra “versión” del sitio. Por ejemplo, artículos, avatares, muros de redes sociales y tablas de clasificación de juegos.

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

Sin embargo, este método tiene deficiencias. Si la conexión del usuario es intermitente o lenta, deberá esperar a que falle la red antes de mostrar el contenido que ya está en el dispositivo y que es completamente aceptable. Esto puede demorar mucho y la experiencia del usuario será 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);
    }),
  );
});

Caché y, luego, red

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

Ideal para contenido que se actualiza con frecuencia. Por ejemplo, artículos, muros de redes sociales y marcadores 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 si se obtienen los datos de la red.

A veces, simplemente basta con reemplazar los datos actuales cuando llegan los datos nuevos (p.ej., en el marcador de un juego), pero este método puede generar interrupciones en contenido de mayor tamaño. Básicamente, no debes "desaparecer" 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 no interrumpir al usuario. Esto es posible porque el orden del contenido de Twitter es, en gran medida, lineal. Copié este patrón para trained-to-thrill para que el contenido aparezca 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 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-thrill, lo solucioné usando XHR en lugar de fetch, y abusando 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).

Contenido de reserva genérico

Resguardo genérico.
Resguardo genérico.

Si no puedes proporcionar un elemento de la caché ni de la red, conviene proporcionar un resguardo genérico.

Ideal para imágenes secundarias, como avatares, solicitudes POST fallidas 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 de reserva sea una dependencia de instalación.

Si tu página publica un correo electrónico, tu service worker puede recurrir a almacenar el correo electrónico en una "bandeja de salida" de IndexedDB y responderle a la página que no se pudo enviar el correo, pero que los datos se conservaron correctamente.

Plantillas del service worker

Plantillas en el service worker
Plantillas en el service worker.

Ideal para: Páginas cuya respuesta del servidor no se puede almacenar en caché.

La renderización de páginas en el servidor permite que todo sea más rápido, pero es posible que se incluyan datos de estado que no tengan mucho sentido en una caché, p.ej., "Se accedió como…". Si un service worker controla tu página, puedes optar por solicitar datos JSON junto con una plantilla y representar eso 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 muchas de ellas según la URL de la solicitud. Por ejemplo, trained-to-thrill 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);
    }),
  );
});

…ya te haces una idea.

Créditos

… de los hermosos íconos:

Gracias a Jeff Posnick por detectar muchos errores importantes antes de que presionara “Publicar”.

Lecturas adicionales