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

Ahora es más fácil trasladar las tareas pesadas a subprocesos en segundo plano con los módulos de JavaScript en los trabajadores web.

JavaScript es de un solo subproceso, lo que significa que solo puede realizar una operación a la vez. Esto es intuitivo y funciona bien en muchos casos en la Web, pero puede volverse problemático cuando necesitamos realizar tareas pesadas, como procesamiento, análisis, cálculo o interpretación de datos. A medida que se entregan aplicaciones cada vez más complejas en la Web, aumenta la necesidad de procesamiento de subprocesos múltiples.

En la plataforma web, la primitiva principal para subprocesos y paralelismo es la API de Web Workers. Los trabajadores son una abstracción ligera sobre los subprocesos del sistema operativo que exponen una API de transmisión de mensajes para la comunicación entre subprocesos. Esto puede ser muy útil cuando se realizan cálculos costosos o se opera con grandes conjuntos de datos, ya 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.

A continuación, se muestra un ejemplo típico del uso de un trabajador, en el que un script 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 workers tienen una excelente compatibilidad con los navegadores y están bien optimizados, también significa que son anteriores a los módulos de JavaScript. 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 ha mantenido similar a los enfoques de carga de secuencias de comandos síncronos comunes en 2009.

Historial: Trabajadores clásicos

El constructor de Worker toma una URL de script clásico, que es relativa a la URL del documento. Devuelve de inmediato una referencia a la nueva instancia de trabajador, que expone una interfaz de mensajería y un método terminate() que detiene y destruye al trabajador de inmediato.

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

Hay una función importScripts() disponible en 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 alcance 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';
}

Por este motivo, históricamente, los Web Workers han impuesto un efecto desproporcionado en la arquitectura de una aplicación. Los desarrolladores tuvieron que crear herramientas y soluciones ingeniosas para poder usar Web Workers sin renunciar a las prácticas de desarrollo modernas. Por ejemplo, los bundlers como webpack incorporan una pequeña implementación del cargador de módulos en el código generado que usa importScripts() para la carga de código, pero encapsulan los módulos en funciones para evitar colisiones de variables y simular importaciones y exportaciones de dependencias.

Ingresa trabajadores del módulo

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

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

Dado que los trabajadores de módulos son módulos de JavaScript estándar, 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 (hilo principal, trabajador, etcétera), y todas las importaciones futuras hacen referencia a la instancia del módulo ya ejecutada. Los navegadores también optimizan la carga y la ejecución de los módulos de JavaScript. Las dependencias de un módulo se pueden cargar antes de que se ejecute el módulo, 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 deben analizarse una vez.

Pasar a los módulos de JavaScript también permite el uso de dynamic import para cargar código de forma 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 devuelven 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. Cambiar los trabajadores para que usen módulos de JavaScript significa que todo el código se carga 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. Afortunadamente, siempre hubo 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.

Precarga trabajadores con modulepreload

Una mejora sustancial en el rendimiento que ofrecen los trabajadores de módulo es la capacidad de precargar trabajadores 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 precargar y hasta 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>

Los módulos precargados también pueden usarse en el subproceso principal y en los subprocesos de los módulos. Esto es ú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 la precarga de secuencias de comandos de Web Workers eran limitadas y no necesariamente confiables. Los trabajadores clásicos tenían su propio tipo de recurso "trabajador" para la carga previa, pero ningún navegador implementó <link rel="preload" as="worker">. Como resultado, la técnica principal disponible para la carga previa de Web Workers era usar <link rel="prefetch">, que dependía por completo de la caché HTTP. Cuando se usa en combinación con los encabezados de almacenamiento en caché correctos, esto permite evitar que la instanciación del trabajador tenga que esperar a descargar la secuencia de comandos del trabajador. Sin embargo, a diferencia de modulepreload, esta técnica no admitía la carga previa de dependencias ni el análisis previo.

¿Qué sucede con los trabajadores compartidos?

A partir de Chrome 83, los trabajadores compartidos se actualizaron para admitir módulos de JavaScript. Al igual que con los trabajadores dedicados, construir un trabajador compartido con la opción {type:"module"} ahora 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 que se admitieran 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, crear trabajadores compartidos de módulos requiere usar el nuevo argumento options. Las opciones disponibles son las mismas que para un trabajador dedicado, incluida la opción name que reemplaza el argumento name anterior.

¿Qué sucede con el service worker?

La especificación de 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ódulos. Sin embargo, este cambio aún no se implementó en los navegadores. Una vez que eso suceda, será posible crear una instancia de un trabajador de servicio con un módulo de JavaScript usando 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 hay algunas complicaciones adicionales asociadas con la incorporación de módulos de JavaScript al trabajador de servicio. El registro del trabajador de servicio debe comparar los scripts importados con sus versiones almacenadas en caché anteriores cuando determina si debe activar una actualización, y esto debe implementarse para los módulos de JavaScript cuando se usan para trabajadores de servicio. Además, los service workers deben poder omitir la caché para las secuencias de comandos en ciertos casos cuando se buscan actualizaciones.

Recursos adicionales y lecturas complementarias