Cómo ejecutar subprocesos en la Web con trabajadores del módulo

Con los módulos de JavaScript en los trabajadores web, ahora es más fácil mover el trabajo pesado a subprocesos en segundo plano.

JavaScript tiene un solo subproceso, lo que significa que solo puede realizar una operación a la vez. Este método es intuitivo y funciona bien para muchos casos en la Web, pero puede ser problemático cuando necesitamos realizar tareas pesadas, como el procesamiento de datos, el análisis, el procesamiento o el análisis. A medida que se entregan aplicaciones cada vez más complejas en la Web, hay una mayor necesidad de procesamiento multiproceso.

En la plataforma web, la primitiva principal para los subprocesos y el paralelismo es la API de Web Workers. Los trabajadores son una abstracción ligera de los subprocesos del sistema operativo que exponen una API que transmite mensajes para la comunicación entre subprocesos. Esto puede ser muy útil cuando se realizan cálculos costosos o cuando se opera en conjuntos de datos grandes, lo que permite que el subproceso principal se ejecute sin problemas mientras se realizan las operaciones costosas en uno o más subprocesos en segundo plano.

Este es un ejemplo típico de uso de trabajador, en el que una secuencia de comandos de trabajador escucha los mensajes del subproceso principal y responde enviando sus propios mensajes:

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

La API de Web Worker está disponible en la mayoría de los navegadores desde hace más de diez años. Si bien eso significa que los trabajadores cuentan con una excelente compatibilidad con el navegador y están bien optimizados, también significa que son anteriores a los módulos de JavaScript por mucho tiempo. Dado que no había un sistema de módulos cuando se diseñaron los trabajadores, la API para cargar código en un trabajador y componer secuencias de comandos se mantuvo similar a los enfoques síncronos de carga de secuencias de comandos comunes en 2009.

Historial: trabajadores clásicos

El constructor de Worker toma una URL de secuencia de comandos clásica, que está relacionada con la URL del documento. En este caso, se muestra inmediatamente una referencia a la instancia de trabajador nueva, que expone una interfaz de mensajería y un método terminate() que detiene y destruye el trabajador de inmediato.

const worker = new Worker('worker.js');

Hay una función importScripts() disponible dentro de los trabajadores web para cargar código adicional, pero pausa la ejecución del trabajador para recuperar y evaluar cada secuencia de comandos. También ejecuta secuencias de comandos en el permiso global como una etiqueta <script> clásica, lo que significa que las variables de una secuencia de comandos pueden reemplazarse por las variables de otra.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

Históricamente, los trabajadores web impusieron un efecto extragrande en la arquitectura de una aplicación. Los desarrolladores tuvieron que crear herramientas y soluciones alternativas inteligentes para poder usar trabajadores web sin renunciar a las prácticas de desarrollo modernas. Por ejemplo, los agrupadores como webpack incorporan una implementación de cargador de módulos pequeño en el código generado que usa importScripts() para cargar código, pero une módulos en funciones para evitar colisiones de variables y simulan importaciones y exportaciones de dependencias.

Ingresar trabajadores del módulo

Un nuevo modo para los trabajadores web con los beneficios de ergonomía y rendimiento de los módulos de JavaScript está disponible en Chrome 80, llamado trabajadores de módulos. El constructor Worker ahora acepta una nueva opción {type:"module"}, que cambia la carga y ejecución de la secuencia de comandos para que coincidan con <script type="module">.

const worker = new Worker('worker.js', {
  type: 'module'
});

Como los trabajadores del módulo son módulos estándar de JavaScript, pueden usar instrucciones de importación y exportación. Al igual que con todos los módulos de JavaScript, las dependencias solo se ejecutan una vez en un contexto determinado (subproceso principal, trabajador, etc.) y todas las importaciones futuras hacen referencia a la instancia del módulo ya ejecutada. La carga y ejecución de módulos de JavaScript también se optimiza con los navegadores. Las dependencias de un módulo se pueden cargar antes de que se ejecute, lo que permite que se carguen árboles de módulos completos en paralelo. La carga de módulos también almacena en caché el código analizado, lo que significa que los módulos que se usan en el subproceso principal y en un trabajador solo se deben analizar una vez.

El cambio a los módulos de JavaScript también habilita el uso de la importación dinámica para el código de carga diferida sin bloquear la ejecución del trabajador. La importación dinámica es mucho más explícita que usar importScripts() para cargar dependencias, ya que se muestran las exportaciones del módulo importado en lugar de depender de variables globales.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Para garantizar un rendimiento excelente, el método importScripts() anterior no está disponible en los trabajadores del módulo. Si cambias los trabajadores para que usen módulos de JavaScript, todo el código se cargará en modo estricto. Otro cambio notable es que el valor de this en el alcance de nivel superior de un módulo de JavaScript es undefined, mientras que en los trabajadores clásicos, el valor es el alcance global del trabajador. Por suerte, siempre ha habido un self global que proporciona una referencia al alcance global. Está disponible en todos los tipos de trabajadores, incluidos los service workers, así como en el DOM.

Precargar los trabajadores con modulepreload

Una mejora sustancial del rendimiento que se incorpora a los trabajadores de módulos es la capacidad de precargarlos y sus dependencias. Con los trabajadores de módulos, las secuencias de comandos se cargan y ejecutan como módulos de JavaScript estándar, lo que significa que se pueden cargar previamente e incluso analizar previamente con modulepreload:

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Tanto el subproceso principal como los trabajadores del módulo pueden usar los módulos precargados. Esto resulta útil para los módulos que se importan en ambos contextos o en los casos en los que no es posible saber de antemano si un módulo se usará en el subproceso principal o en un trabajador.

Anteriormente, las opciones disponibles para precargar secuencias de comandos de trabajadores web eran limitadas y no necesariamente confiables. Los trabajadores clásicos tenían su propio tipo de recurso de "trabajador" para la precarga, pero ningún navegador implementó <link rel="preload" as="worker">. Como resultado, la técnica principal disponible para precargar trabajadores web era usar <link rel="prefetch">, que se basaba por completo en la caché HTTP. Cuando se usa en combinación con los encabezados de almacenamiento en caché correctos, esto hace posible evitar que la creación de instancias del trabajador tenga que esperar para descargar la secuencia de comandos del trabajador. Sin embargo, a diferencia de modulepreload, esta técnica no admitía la precarga de dependencias ni el análisis previo.

¿Qué sucede con los trabajadores compartidos?

A partir de Chrome 83, los trabajadores compartidos se actualizaron y ahora admiten módulos de JavaScript. Al igual que los trabajadores dedicados, cuando construyes un trabajador compartido con la opción {type:"module"} ahora se carga la secuencia de comandos del trabajador como un módulo en lugar de una secuencia de comandos clásica:

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Antes de admitir los módulos de JavaScript, el constructor SharedWorker() solo esperaba una URL y un argumento name opcional. Esto seguirá funcionando para el uso clásico de trabajadores compartidos. Sin embargo, para crear trabajadores compartidos de módulos, se requiere el uso del nuevo argumento options. Las opciones disponibles son las mismas que las de un trabajador dedicado, incluida la opción name que reemplaza el argumento name anterior.

¿Qué ocurre con los service workers?

La especificación del service worker ya se actualizó para admitir la aceptación de un módulo de JavaScript como punto de entrada con la misma opción {type:"module"} que los trabajadores de módulo. Sin embargo, este cambio aún no se implementó en los navegadores. Una vez que eso suceda, podrás crear una instancia de un service worker usando un módulo de JavaScript con el siguiente código:

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Ahora que se actualizó la especificación, los navegadores están comenzando a implementar el nuevo comportamiento. Esto lleva tiempo porque existen algunas complicaciones adicionales asociadas con la incorporación de módulos de JavaScript al service worker. El registro de service worker debe comparar las secuencias de comandos importadas con sus versiones anteriores almacenadas en caché para determinar si se debe activar una actualización. Esto se debe implementar en los módulos de JavaScript cuando se usan con service workers. Además, los service workers deben poder omitir la caché para secuencias de comandos en ciertos casos cuando comprueban si hay actualizaciones.

Recursos adicionales y lecturas adicionales