El ciclo de vida del service worker

El ciclo de vida del service worker es la parte más complicada de este. Si desconoces lo que intenta hacer y los beneficios que ofrece, puede parecer que molesta. Sin embargo, una vez que conoces cómo funciona, puedes ofrecer actualizaciones discretas y fluidas a los usuarios, mezclando lo mejor de los patrones web y nativos.

Este artículo es un análisis exhaustivo, pero las viñetas que figuran en el comienzo de cada sección analizan los principales conceptos que debes conocer.

El intent

El intent del ciclo de vida es el siguiente:

  • hacer posible la perspectiva de “primero sin conexión”;
  • permitir que un nuevo service worker se prepare sin interrumpir el flujo del actual ;
  • garantizar que una página dentro del ámbito esté controlada por el mismo service worker (o por ningún service worker) en todo momento;
  • garantizar que solo se ejecute una versión de tu sitio a la vez.

El último punto es muy importante. Sin service worker, los usuarios pueden cargar una pestaña en tu sitio y, más tarde, abrir otra. De esta manera, pueden ejecutarse dos versiones de tu sitio al mismo tiempo. A veces, este proceso es correcto. Sin embargo, si estás lidiando con el concepto de almacenamiento, puedes fácilmente tener dos pestañas con opiniones muy diferentes acerca de cómo se debería administrar el almacenamiento compartido. Esto puede ocasionar errores o, peor aún, pérdida de datos.

El primer service worker

Resumen:

  • El evento install es el primer evento que obtiene un service worker y solo sucede una vez.
  • Una promesa que se pasa a installEvent.waitUntil() señala la duración y el éxito o fracaso de tu instalación.
  • Un service worker no recibirá eventos como fetch y push hasta que se termine de instalar correctamente y su estado sea "activo".
  • De manera predeterminada, los fetch de una página no atravesarán un service worker a menos que la solicitud de la página en sí lo haya hecho. Por lo tanto, tendrás que actualizar la página para ver los efectos del service worker.
  • clients.claim() puede anular esta configuración predeterminada y tomar el control de las páginas no controladas.

Analiza este HTML:

<!DOCTYPE html>
An image will appear here in 3 seconds:
<script>
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered!', reg))
    .catch(err => console.log('Boo!', err));

  setTimeout(() => {
    const img = new Image();
    img.src = '/dog.svg';
    document.body.appendChild(img);
  }, 3000);
</script>

Se registra un service worker y se agrega una imagen de un perro después de 3 segundos.

Aquí se muestra su service worker, sw.js:

self.addEventListener('install', event => {
  console.log('V1 installing…');

  // cache a cat SVG
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/cat.svg'))
  );
});

self.addEventListener('activate', event => {
  console.log('V1 now ready to handle fetches!');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the cat SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/cat.svg'));
  }
});

Almacena en caché la imagen de un gato y la provee donde haya una solicitud de /dog.svg. Sin embargo, si ejecutas el ejemplo anterior, verás un perro la primera vez que cargues la página. Si actualizas la página, verás el gato.

Ámbito y control

El ámbito predeterminado del registro de un service worker es ./ en relación con la URL de la secuencia de comandos. Esto significa que, si registras un service worker en //example.com/foo/bar.js, el ámbito predeterminado será//example.com/foo/.

Denominamos clients a las páginas, los procesos de trabajo y los procesos de trabajo compartidos. Tu service worker solo puede controlar clientes que estén dentro del ámbito. Una vez que un cliente está “controlado”, sus fetch atraviesan el service worker dentro del ámbito. Puedes detectar si un cliente es controlado mediante navigator.serviceWorker.controller porque su valor será null o una instancia de service worker.

Descarga, análisis y ejecución

Cuando llamas a .register(), se descarga el primer service worker. Si tu secuencia de comandos no se descarga, no se analiza o arroja un error en su ejecución inicial, se rechaza la promesa de registro y se descarta el service worker.

DevTools de Chrome muestra el error en la consola y en la sección de service worker de la pestaña Application:

Error que aparece en la pestaña Service Workers de DevTools

Instalación

El primer evento que recibe un service worker es install. Se activa apenas se ejecuta el proceso de trabajo, y solo se lo llama una vez por service worker. Si modificas la secuencia de comandos del service worker, el navegador lo considera un proceso de trabajo de servicio diferente, y este recibirá su propio evento install. Analizaré las actualizaciones con más detalle más adelante.

El evento install es tu oportunidad de almacenar en caché todo lo que necesitas para poder controlar los clientes. La promesa que pasas a event.waitUntil() permite que el navegador sepa que tu instalación se completó correctamente.

Si se rechaza la promesa, significa que la instalación no se completó y el navegador elimina el service worker. Nunca controlará los clientes. Esto significa que podemos confiar en la presencia de "cat.svg" en la caché en nuestros eventos fetch. Se trata de una dependencia.

Activación

Una vez que tu service worker esté listo para controlar clientes y administrar eventos funcionales como push y sync, recibirás un evento activate. Sin embargo, eso no significa que se controlará la página desde la que se llamó a .register().

La primera vez que cargas la versión demo, si bien dog.svg se solicita mucho después de que se activa el service worker, no se controla la solicitud, y seguirás viendo la imagen del perro. La configuración predeterminada es consistencia: si tu página no se carga con un service worker, tampoco lo harán los subrecursos. Si cargas la versión demo otra vez (en otras palabras, actualizas la página), se controlará la solicitud. Tanto la página como la imagen atravesarán eventos fetch, y verás un gato en lugar de un perro.

clients.claim

Puedes controlar clientes no controlados llamando a clients.claim() dentro del service worker una vez que este está activo.

En este caso, se trata de una variación de la versión demo anterior que llama a clients.claim() en su evento activate. Deberías ver un gato la primera vez. Digo “deberías”, porque es una cuestión de sincronización. Solo verás un gato si se activa el service worker y clients.claim() entra en vigencia antes de que la imagen intente cargarse.

Si usas tu service worker para cargar páginas de manera diferente con respecto a la forma en la que se hubieran cargado mediante la red, clients.claim() puede ser problemático, ya que el service worker termina controlando algunos clientes que se cargaron sin él.

Actualización del service worker

Resumen:

  • Se activa una actualización:
    • durante la navegación hasta una página dentro del ámbito;
    • durante eventos funcionales como push y sync, a menos que haya habido una revisión de actualización dentro de las 24 horas anteriores;
    • al llamar a .register() solo si ha cambiado la URL del service worker.
  • Los encabezados de caché de la secuencia de comandos del service worker se respetan (hasta 24 horas) cuando se actualiza el proceso de obtención. Haremos que este sea un comportamiento opcional, ya que atrae a las personas. Es probable que desees un max-age de 0 en la secuencia de comandos de tu service worker.
  • Tu service worker se considera actualizado si tiene una cantidad de bytes diferente con respecto al proceso que ya tiene el navegador. (Extendemos este concepto para incluir también los módulos/las secuencias de comandos importados.)
  • El service worker actualizado se inicia junto con el existente y recibe su propio evento install.
  • Si tu nuevo proceso de trabajo tiene un código de estado incorrecto (por ejemplo, 404), no se analiza, arroja un error durante la ejecución o se rechaza durante la instalación, el nuevo proceso de trabajo se elimina, pero el actual permanece activo.
  • Una vez que se instale correctamente, el proceso de trabajo actualizado esperará con un evento wait hasta que el proceso de trabajo actual no controle clientes. (Ten en cuenta que los clientes se superponen durante una actualización.)
  • self.skipWaiting() evita la espera, es decir, el service worker se activa apenas finaliza su instalación.

Supongamos que modificamos la secuencia de comandos de nuestro service worker para responder con una imagen de un caballo en lugar de la de un gato:

const expectedCaches = ['static-v2'];

self.addEventListener('install', event => {
  console.log('V2 installing…');

  // cache a horse SVG into a new cache, static-v2
  event.waitUntil(
    caches.open('static-v2').then(cache => cache.add('/horse.svg'))
  );
});

self.addEventListener('activate', event => {
  // delete any caches that aren't in expectedCaches
  // which will get rid of static-v1
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log('V2 now ready to handle fetches!');
    })
  );
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the horse SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/horse.svg'));
  }
});

Prueba una versión demo de lo anterior. Deberías continuar viendo una imagen de un gato. Este es el motivo...

Instalación

Observa que he cambiado el nombre del caché de static-v1 a static-v2. Esto significa que puedo configurar el nuevo caché sin sobrescribir elementos en el actual ,que continúa utilizando el service worker antiguo.

Mediante este patrón, se crean cachés específicos de la versión, similar a los recursos que una app nativa incluiría en el paquete con su ejecutable. También es posible tener cachés que no sean específicos de la versión, como por ejemplo, avatars.

Espera

Luego de que el service worker actualizado se instala correctamente, este no se activa hasta que el proceso de trabajo actual ya no controle clientes. Este estado se denomina “espera” y representa la forma en la que el navegador garantiza que solo se ejecute una versión de tu service worker a la vez.

Si ejecutas la versión demo actualizada, deberías seguir viendo una imagen de un gato, porque el proceso de trabajo V2 todavía no se activó. Puedes ver el nuevo service worker en estado de espera en la pestaña "Application" de DevTools:

DevTools con un nuevo service worker en estado de espera

Incluso si tienes solo una pestaña abierta en la versión demo, actualizar la página no es suficiente para permitir que la nueva versión tome el control. Esto se debe a cómo funcionan las búsquedas en el navegador. Cuando navegas, la página actual no desaparece hasta que se hayan recibido los encabezados de respuesta, e incluso después la página actual puede permanecer visible si la respuesta tiene un encabezado Content-Disposition. Debido a esta superposición, el service worker actual siempre controla un cliente durante una actualización.

Para obtener la actualización, cierra o abandona todas las pestañas que utilizan el proceso de trabajo de servicio actual. Luego, cuando navegues hasta la versión demo nuevamente, deberías ver el caballo.

Este patrón es similar a cómo se actualiza Chrome. Las actualizaciones de Chrome se descargan en segundo plano, pero no se aplican hasta que Chrome se reinicia. Mientras tanto, puedes continuar utilizando la versión actual sin interrupciones. Sin embargo, esto es un punto débil durante el desarrollo, pero DevTools tiene formas de simplificarlo, las cuales analizaremos más adelante en este artículo.

Activación

Se activa cuando el service worker antiguo haya desaparecido y tu nuevo proceso de trabajo de servicio pueda controlar clientes. Es el momento ideal para llevar a cabo tareas que no pudiste hacer mientras el proceso de trabajo antiguo todavía estaba en uso, como por ejemplo, migrar bases de datos y vaciar cachés.

En la versión demo anterior, mantengo una lista de cachés que deseo estén allí y, en el evento activate, elimino el resto, lo cual permite quitar la caché static-v1 antiguo.

Si pasas una promesa a event.waitUntil(), se almacenarán en búfer los eventos funcionales (fetch, push, sync, etc.) hasta que se resuelva la promesa. Por lo tanto, cuando se activa el evento fetch , significa que la activación finalizó completamente.

Omisión de la fase de espera

La fase de espera significa que solo puedes ejecutar una versión de tu sitio a la vez; sin embargo, si no necesitas esa función, puedes hacer que tu nuevo service worker se active antes llamando a self.skipWaiting().

De esta manera, el service worker expulsa el proceso de trabajo activo actual y se activa automáticamente apenas ingresa en la fase de espera (o inmediatamente si ya se encuentra en dicha fase). No hace que se omita la instalación de tu proceso de trabajo; simplemente es una fase de espera.

Puedes llamar a skipWaiting() en cualquier momento, siempre y cuando sea durante la espera o antes de esta. Es bastante común realizar la llamada en el evento install:

self.addEventListener('install', event => {
  self.skipWaiting();

  event.waitUntil(
    // caching etc
  );
});

Sin embargo, es posible que desees realizar la llamada como consecuencia de un postMessage() al proceso de trabajo de servicio. En este caso, debes llamar a skipWaiting() luego de la interacción del usuario.

Aquí se presenta una versión demo que utiliza skipWaiting(). Deberías ver una imagen de una vaca sin tener que dejar de navegar. Como sucede con clients.claim(), se trata de una carrera, por lo que solo verás la vaca si el nuevo service worker realiza la obtención, se instala y se activa antes de que la página intente cargar la imagen.

Actualizaciones manuales

Como mencioné antes, el navegador revisa si hay actualizaciones disponibles automáticamente luego de las navegaciones y los eventos funcionales, pero también puedes activar dichas actualizaciones manualmente:

navigator.serviceWorker.register('/sw.js').then(reg => {
  // sometime later…
  reg.update();
});

Si prevés que el usuario utilizará tu sitio por un período prolongado sin volver a cargar la página, puedes establecer un intervalo de llamada a update() (por ejemplo, una hora).

Evitar modificar la URL de la secuencia de comandos de tu service worker

Si leíste mi publicación sobre mejores prácticas de uso del caché, podrías considerar otorgarle una URL exclusiva a cada versión de tu service worker. ¡No lo hagas! Por lo general, se trata de una práctica poco eficaz para los service worker. Simplemente, actualiza la secuencia de comandos en su ubicación actual.

Se podría generar uno de los siguientes problemas:

  1. index.html registra sw-v1.js como service worker.
  2. sw-v1.js almacena en caché y proporciona index.html por lo que funciona en el modo primero sin conexión.
  3. Actualizas index.html por lo que se registra el nuevo service worker sw-v2.js.

Si sigues los pasos anteriores, el usuario nunca recibe sw-v2.js, porque sw-v1.js proporciona la versión anterior de index.html desde su caché. Te encuentras en una posición en la que debes actualizar tu proceso de trabajo de servicio. Ew.

Sin embargo, en el caso de la versión demo anterior, he modificado la URL del service worker. Hice esto, a los efectos de la versión demo, para que puedas alternar entre las versiones. No es algo que haga en el entorno de producción.

Facilidad de desarrollo

El ciclo de vida del service worker se crea teniendo en cuenta al usuario; sin embargo, durante el desarrollo, este concepto es un punto débil. Por suerte, existen algunas herramientas de ayuda:

Update on reload

Es mi preferida.

Se muestra la herramienta 'update on reload' en DevTools

Mediante esta herramienta, se logra que el ciclo de vida sea accesible para el programador. En cada navegación, sucede lo siguiente:

  1. Se recupera el service worker.
  2. Se lo instala como una nueva versión incluso si tiene la misma cantidad de bytes, lo que significa que tu evento install se ejecuta y los cachés se actualizan.
  3. Se omite la fase de espera de manera que se active el nuevo service worker.
  4. Se navega por la página.

Esto significa que obtendrás actualizaciones en cada navegación (incluida la función de actualizar) sin necesidad de volver a cargar la página dos veces o cerrar la pestaña.

Skip waiting

Se muestra la herramienta 'skip waiting' en DevTools

Si cuentas con un proceso de trabajo en espera, puedes seleccionar "skip waiting" en DevTools para activarlo inmediatamente.

Shift-reload

Si fuerzas la recarga de la página (shift-reload), se evita el service worker por completo. No se lo podrá controlar. Esta función se encuentra en la especificación, por lo que funciona en otros navegadores que son compatibles con el service worker.

Administración de actualizaciones

El service worker se diseñó como parte de la Web extensible. La idea es que nosotros, como programadores de navegadores, reconozcamos que no somos mejores que los programadores web en lo que respecta al desarrollo web. Y, como tales, no deberíamos proporcionar API de alto nivel estrechas que resuelvan un problema en particular mediante patrones que a nosotros nos gusten. En cambio, deberíamos ofrecerte acceso a la parte central del navegador y permitirte usar tu propia metodología, de forma que funcione mejor para tus usuarios.

Por lo tanto, para habilitar la mayor cantidad posible de patrones, debemos observar el ciclo de actualización completo:

navigator.serviceWorker.register('/sw.js').then(reg => {
  reg.installing; // the installing worker, or undefined
  reg.waiting; // the waiting worker, or undefined
  reg.active; // the active worker, or undefined

  reg.addEventListener('updatefound', () => {
    // A wild service worker has appeared in reg.installing!
    const newWorker = reg.installing;

    newWorker.state;
    // "installing" - the install event has fired, but not yet complete
    // "installed"  - install complete
    // "activating" - the activate event has fired, but not yet complete
    // "activated"  - fully active
    // "redundant"  - discarded. Either failed install, or it's been
    //                replaced by a newer version

    newWorker.addEventListener('statechange', () => {
      // newWorker.state has changed
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // This fires when the service worker controlling this page
  // changes, eg a new worker has as skipped waiting and become
  // the new active worker. 
});

¡Sobreviviste!

¡Uf! Se analizaron muchos conceptos técnicos teóricos. No te pierdas las novedades de las próximas semanas , ya que analizaremos algunas apps prácticas de los temas anteriores.