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

Ahora es más fácil trasladar el trabajo pesado 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 el procesamiento de datos, el análisis, el procesamiento o el análisis. A medida que se entregan cada vez más aplicaciones complejas en la Web, aumenta la necesidad de realizar 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, además de los subprocesos del sistema operativo, que exponen un mensaje que pasa a la API para la comunicación entre subprocesos. Esto puede ser muy útil cuando se realizan procesamientos costosos o cuando se opera en grandes conjuntos de datos, lo que permite que el subproceso principal se ejecute sin problemas mientras realiza las operaciones costosas en uno o más subprocesos en segundo plano.

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

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 tienen una excelente compatibilidad con el navegador y están bien optimizados, también significa que son anteriores a los módulos de JavaScript durante mucho tiempo. Debido a que no había un sistema de módulos cuando se diseñaban los trabajadores, la API para cargar código en un trabajador y redactar secuencias de comandos se mantuvo similar a los enfoques de carga de secuencias de comandos síncronas comunes en 2009.

Historial: trabajadores clásicos

El constructor de trabajadores toma una URL de secuencia de comandos clásica, que es relativa a la URL del documento. De inmediato, muestra una referencia a la instancia de trabajador nueva, que expone una interfaz de mensajería, así como 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 alcance global como una etiqueta <script> clásica, lo que significa que las variables en 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 esta razón, históricamente, los trabajadores web imponen un efecto mayor en la arquitectura de una aplicación. Los desarrolladores tuvieron que crear herramientas inteligentes y soluciones alternativas para que sea posible usar trabajadores web sin renunciar a las prácticas de desarrollo modernas. Por ejemplo, los agrupadores como webpack incorporan una implementación pequeña de cargador de módulos en el código generado que usa importScripts() para cargar código, pero une módulos en funciones a fin de evitar colisiones de variables y simular importaciones y exportaciones de dependencias.

Ingresa los trabajadores del módulo

En Chrome 80, se lanzará un nuevo modo para trabajadores web con los beneficios de ergonomía y rendimiento de los módulos de JavaScript: los 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'
});

Dado que los trabajadores del módulo son módulos estándar de JavaScript, pueden usar sentencias 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. 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 este se ejecute, lo que permite cargar á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.

La migración a módulos de JavaScript también habilita el uso de dynamic import para la carga diferida de código 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 su alcance global. Afortunadamente, siempre ha habido un self global que proporciona una referencia al alcance global. Está disponible en todos los tipos de trabajadores, incluidos los service worker, y en el DOM.

Precarga los trabajadores con modulepreload

Una mejora significativa del rendimiento que incluyen los trabajadores de módulos es la capacidad de precargar trabajadores y sus dependencias. Con los trabajadores de módulos, las secuencias de comandos se cargan y se ejecutan como módulos estándar de JavaScript, lo que significa que se pueden precargar y 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 se pueden usar tanto en el subproceso principal como en los trabajadores del módulo. 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 la precarga de secuencias de comandos de trabajadores web eran limitadas y no eran necesariamente confiables. Los trabajadores clásicos tenían su propio tipo de recurso "worker" para la precarga, pero ningún navegador implementaba <link rel="preload" as="worker">. Como resultado, la técnica principal disponible para la precarga de trabajadores web 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 creación de instancias de trabajador tenga que esperar para descargar la secuencia de comandos de 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 para admitir módulos de JavaScript. Al igual que los trabajadores dedicados, la construcción de 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 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, la creación de trabajadores compartidos de módulos 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 sustituye el argumento name anterior.

¿Qué ocurre con un service worker?

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 ha implementado en los navegadores. Una vez que esto suceda, se podrá crear una instancia de un service worker mediante 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 comienzan a implementar el nuevo comportamiento. Esto lleva tiempo porque existen algunas complicaciones adicionales asociadas al uso de módulos de JavaScript en el service worker. El registro de service worker debe comparar las secuencias de comandos importadas con sus versiones anteriores en caché cuando se determina si se debe activar una actualización. Esto debe implementarse para los módulos de JavaScript cuando se usan para service workers. Además, en ciertos casos, cuando comprueban si hay actualizaciones, los service workers deben poder omitir la caché para las secuencias de comandos.

Recursos adicionales y lecturas adicionales