Técnicas comunes para compilar aplicaciones sin conexión

Con los Service Workers, les brindamos a los desarrolladores una forma de resolver la conexión de red. Obtienes control sobre el almacenamiento en caché y la forma en que se administran las solicitudes. Esto significa que puedes crear tus propios patrones. Echa un vistazo a algunos patrones posibles de forma aislada, pero, en la práctica, es probable que los uses en conjunto, según la URL y el contexto.

Para ver una demostración práctica de algunos de estos patrones, consulta Trained-to-thrill.

Cuándo almacenar recursos

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Los service workers te permiten controlar solicitudes de forma independiente del almacenamiento en caché, por lo que los demostraré por separado. Primero, determinemos cuándo debes usar la caché.

Durante la instalación, como dependencia

En la instalación, como dependencia.

La API de Service Worker te proporciona un evento install. Puedes usarlo para preparar elementos que deben estar listos antes de que manejes otros eventos. Durante install, las versiones anteriores de tu Service Worker siguen ejecutándose y publicando páginas. Lo que hagas en este momento no debería interrumpir el service worker existente.

Ideal para: CSS, imágenes, fuentes, JS, plantillas o cualquier otro elemento que consideres estático para esa versión de tu sitio.

Recupera los elementos que harían que tu sitio no funcionara en absoluto si no se pudieran recuperar, elementos que una app equivalente específica para 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 la promesa se rechaza, la instalación se considera un error y se abandona este Service Worker (si se está ejecutando una versión anterior, se dejará intacta). 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, uso esto para almacenar en caché los recursos estáticos.

En la instalación, no como dependencia

Se instala, pero no como dependencia.

Es similar a instalar como dependencia, pero no retrasará la finalización de la instalación ni provocará que falle si falla 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 este ejemplo, 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 faltan.

Es posible que se cierre 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é. La API de Web Periodic Background Synchronization puede controlar casos como este y descargas más grandes, como películas.

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Al activar

Al activarse.

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 recibes un evento activate. Como la versión anterior ya no está, es un buen momento para controlar las migraciones de esquemas en IndexedDB y también borrar las memorias caché no utilizadas.

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, los eventos como fetch se colocan en una cola, por lo que una activación prolongada podría bloquear la carga de la página. Mantén la activación lo más simple posible y solo úsala para las cosas que no podías hacer mientras la versión anterior estaba activa.

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

Cuando el usuario interactúa

Cuando el usuario interactúa.

Ideal para: Cuando no se puede desconectar todo el sitio y elegiste permitir que el usuario seleccione el contenido que quiere que esté disponible sin conexión. P.ej., un video en YouTube, un artículo en Wikipedia o una galería específica en Flickr.

Proporciona al usuario un botón de "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 Cache está disponible desde las páginas y los service workers, lo que significa que puedes agregar contenido a la caché directamente desde la página.

Browser Support

  • Chrome: 40.
  • Edge: 16.
  • Firefox: 41.
  • Safari: 11.1.

Source

Respuesta en la red

Respuesta en la red.

Ideal para: Actualizar con frecuencia recursos como la bandeja de entrada de un usuario o el contenido de artículos. 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 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 principal. Asegúrate de deshacerte de 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. La muestra de código 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.

Stale-while-revalidate

Stale-while-revalidate.

Ideal para: recursos que se actualizan con frecuencia y 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.

En el mensaje push

En el mensaje push.

La API de Push es otra función compilada sobre el trabajador de servicio. Esto permite que el service worker 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 service worker. Solicitas permiso para hacerlo desde una página y se le muestra un mensaje 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 se incluye el contenido que no cambia con frecuencia, pero que se beneficia de la sincronización inmediata, como la actualización de una lista de tareas pendientes o la modificación de un calendario.

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

Este código actualiza las memorias caché 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/');
  }
});

On background-sync

En background-sync.

La sincronización en segundo plano es otra función integrada en el 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 service worker. Solicitas permiso para hacerlo desde una página y se le solicita al usuario.

Ideal para: 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 cronologías de 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 recibe una cierta cantidad 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.

No se especifica el importe que recibes. Varía según el dispositivo y las condiciones de almacenamiento. Puedes averiguar cuánto tienes con las siguientes opciones:

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 almacenamiento del navegador, este puede descartar tus datos si el dispositivo se encuentra bajo 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 interesa.

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 otorgar el 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 los resuelve, el usuario puede decidir qué elementos conservar y quitar.

Para que esto funcione, los sistemas operativos deben tratar los orígenes "duraderos" 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

No importa cuánto almacenamiento en caché realices, el service worker solo usa la caché cuando le indicas cuándo y cómo. Estos son algunos patrones para controlar las solicitudes:

Solo caché

Solo caché.

Ideal para: Todo lo que consideres estático para una "versión" en particular de tu sitio. Deberías haberlos almacenado en caché en el evento de instalación, por lo que puedes confiar en 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));
});

…aunque no es necesario que manejes este caso de forma específica, Caché, con respaldo de la red lo abarca.

Solo de red

Solo en la red.

Ideal para: Elementos que no tienen un equivalente sin conexión, como los pings de Analytics y las solicitudes que no son GET.

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

…aunque no es necesario que manejes este caso de forma específica, Caché, con respaldo de la red lo abarca.

Caché, con opción de volver a la red

Caché, con opción de volver a la red.

Ideal para: Compilar apps que priorizan el uso sin conexión. En estos casos, así es como manejarás la mayoría de las solicitudes. Otros patrones son excepciones basadas en 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 los elementos en la caché y el comportamiento de "solo red" para cualquier elemento que no esté en la caché (lo que incluye todas las solicitudes que no son GET, ya que no se pueden almacenar en caché).

Carrera de caché y red

Carrera de caché y red.

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

Con algunas combinaciones de discos duros más 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, acceder 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 rejects when a promise rejects before fulfilling.
// To make a 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 memoria caché.

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

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

Sin embargo, este método tiene fallas. Si el usuario tiene una conexión lenta o intermitente, deberá esperar a que falle la red para obtener el contenido perfectamente aceptable que ya tiene en su dispositivo. Esto puede llevar mucho tiempo y generar 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);
    }),
  );
});

Caché y luego red

Caché y luego red.

Ideal para: Contenido que se actualiza con frecuencia. P. ej., artículos, cronogramas 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 cuando lleguen los datos de la red.

A veces, puedes reemplazar los datos actuales cuando llegan datos nuevos (como una tabla de clasificación de un juego), pero eso puede ser perjudicial con contenido más grande. Básicamente, no "desaparezcas" algo que el usuario pueda estar leyendo o con lo que pueda estar interactuando.

Twitter agrega el contenido nuevo sobre el contenido anterior y ajusta la posición de desplazamiento para que el usuario no se vea interrumpido. Esto es posible porque Twitter conserva un orden del contenido casi lineal. Copie este patrón para trained-to-thrill y mostrar contenido en la 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, solucioné este problema 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 del service worker).

Respuesta genérica de respaldo

Resguardo genérico.

Si no puedes publicar contenido desde la caché o la red, proporciona una alternativa genérica.

Ideal para: Imágenes secundarias, como avatares, solicitudes POST fallidas y una página de "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 al que recurras sea una dependencia de instalación.

Si tu página publica un correo electrónico, es posible que tu service worker recurra a almacenar el correo electrónico en una bandeja de salida de IndexedDB y responda indicándole a la página que el envío falló, pero que los datos se conservaron correctamente.

Plantillas del Service Worker

Plantillas del Service Worker.

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

Es más rápido renderizar páginas en el servidor, pero eso puede significar incluir datos de estado que tal vez no tengan sentido en una caché, como el estado de acceso. Si un service worker controla tu página, puedes solicitar datos JSON junto con una plantilla y renderizar 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',
        },
      });
    }),
  );
});

Cómo armar el dispositivo

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

Lecturas adicionales

Créditos

En cuanto a los íconos:

Y gracias a Jeff Posnick por detectar muchos errores aullantes antes de que hiciera clic en "Publicar".