Una arquitectura fuera del subproceso principal puede mejorar significativamente la confiabilidad y la experiencia del usuario de tu app.
En los últimos 20 años, la Web evolucionó de forma significativa de documentos estáticos con algunos estilos e imágenes a aplicaciones complejas y dinámicas. Sin embargo, hay algo que no ha cambiado en gran medida: tenemos un solo subproceso por pestaña del navegador (con algunas excepciones) para renderizar nuestros sitios y ejecutar nuestro código JavaScript.
Como resultado, el subproceso principal se sobrecarga. A medida que las apps web aumentan de complejidad, el subproceso principal se convierte en un cuello de botella significativo para el rendimiento. Para empeorar las cosas, la cantidad de tiempo que se tarda en ejecutar código en el subproceso principal para un usuario determinado es casi completamente impredecible porque las capacidades del dispositivo tienen un efecto masivo en el rendimiento. Esa imprevisibilidad solo aumentará a medida que los usuarios accedan a la Web desde un conjunto cada vez más diverso de dispositivos, desde teléfonos con funciones hiperlimitadas hasta máquinas insignia de alta potencia y alta frecuencia de actualización.
Si queremos que las apps web sofisticadas cumplan de forma confiable con los lineamientos de rendimiento, como las Métricas web esenciales (que se basan en datos empíricos sobre la percepción y la psicología humanas), necesitamos formas de ejecutar nuestro código fuera del subproceso principal (OMT).
¿Por qué usar trabajadores web?
De forma predeterminada, JavaScript es un lenguaje de subproceso único que ejecuta tareas en el subproceso principal. Sin embargo, los Web Workers proporcionan una especie de salida de emergencia del subproceso principal, ya que permiten que los desarrolladores creen subprocesos independientes para controlar el trabajo fuera del subproceso principal. Si bien el alcance de los trabajadores web es limitado y no ofrece acceso directo al DOM, pueden ser muy beneficiosos si hay un trabajo considerable que se debe realizar y que, de otro modo, abrumaría al subproceso principal.
En el caso de las Métricas web esenciales, puede ser beneficioso ejecutar el trabajo fuera del subproceso principal. En particular, descargar trabajo del subproceso principal a los trabajadores web puede reducir la contención del subproceso principal, lo que puede mejorar la métrica de capacidad de respuesta de Interaction to Next Paint (INP) de una página. Cuando el subproceso principal tiene menos trabajo para procesar, puede responder más rápido a las interacciones del usuario.
Menos trabajo del subproceso principal, especialmente durante el inicio, también puede beneficiar al procesamiento de imagen con contenido más grande (LCP), ya que reduce las tareas largas. La renderización de un elemento de LCP requiere tiempo del subproceso principal, ya sea para renderizar texto o imágenes, que son elementos de LCP frecuentes y comunes. Si reduces el trabajo del subproceso principal en general, puedes asegurarte de que sea menos probable que el elemento de LCP de tu página esté bloqueado por un trabajo costoso que un trabajador web podría controlar.
Cómo crear subprocesos con trabajadores web
Por lo general, otras plataformas admiten el trabajo en paralelo, ya que te permiten asignar una función a un subproceso, que se ejecuta en paralelo con el resto del programa. Puedes acceder a las mismas variables desde ambos subprocesos, y el acceso a estos recursos compartidos se puede sincronizar con mutexes y semáforos para evitar condiciones de carrera.
En JavaScript, podemos obtener una funcionalidad similar a la de los trabajadores web, que existen desde 2007 y son compatibles con todos los navegadores principales desde 2012. Los Web Workers se ejecutan en paralelo con el subproceso principal, pero, a diferencia de los subprocesos del SO, no pueden compartir variables.
Para crear un trabajador web, pasa un archivo al constructor de trabajadores, que comenzará a ejecutarlo en un subproceso independiente:
const worker = new Worker("./worker.js");
Para comunicarte con el trabajador web, envía mensajes con la API de postMessage
. Pasa el valor del mensaje como parámetro en la llamada a postMessage
y, luego, agrega un objeto de escucha de eventos de mensajes al trabajador:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
// ...
});
Para volver a enviar un mensaje al subproceso principal, usa la misma API de postMessage
en el trabajador web y configura un objeto de escucha de eventos en el subproceso principal:
main.js
const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
console.log(event.data);
});
worker.js
addEventListener('message', event => {
const [a, b] = event.data;
// Do stuff with the message
postMessage(a + b);
});
Es cierto que este enfoque es algo limitado. Históricamente, los Web Workers se han usado principalmente para quitar una sola tarea pesada del subproceso principal. Intentar controlar varias operaciones con un solo trabajador web se vuelve engorroso rápidamente: debes codificar no solo los parámetros, sino también la operación en el mensaje, y debes realizar el registro para que coincidan las respuestas con las solicitudes. Es probable que esa complejidad sea la razón por la que los trabajadores web no se hayan adoptado de forma más amplia.
Sin embargo, si pudiéramos quitar parte de la dificultad de la comunicación entre el subproceso principal y los trabajadores web, este modelo podría ser una excelente opción para muchos casos de uso. Y, por suerte, hay una biblioteca que hace exactamente eso.
Comlink: Reduce el trabajo de los trabajadores web
Comlink es una biblioteca cuyo objetivo es permitirte usar trabajadores web sin tener que pensar en los detalles de postMessage
. Comlink te permite compartir variables entre los trabajadores web y el subproceso principal casi como otros lenguajes de programación que admiten subprocesos.
Para configurar Comlink, instálalo en un trabajador web y define un conjunto de funciones para exponer al subproceso principal. Luego, importas Comlink en el subproceso principal, unes el trabajador y obtienes acceso a las funciones expuestas:
worker.js
import {expose} from 'comlink';
const api = {
someMethod() {
// ...
}
}
expose(api);
main.js
import {wrap} from 'comlink';
const worker = new Worker('./worker.js');
const api = wrap(worker);
La variable api
en el subproceso principal se comporta de la misma manera que la del trabajador web, excepto que cada función muestra una promesa de un valor en lugar del valor en sí.
¿Qué código deberías transferir a un trabajador web?
Los trabajadores web no tienen acceso al DOM ni a muchas APIs, como WebUSB, WebRTC o Web Audio, por lo que no puedes colocar partes de tu app que dependan de ese acceso en un trabajador. Sin embargo, cada pequeño fragmento de código que se mueve a un trabajador compra más margen en el subproceso principal para los elementos que tienen que estar allí, como actualizar la interfaz de usuario.
Un problema para los desarrolladores web es que la mayoría de las apps web dependen de un framework de IU como Vue o React para organizar todo en la app. Todo es un componente del framework y, por lo tanto, está inherentemente vinculado al DOM. Eso parece dificultar la migración a una arquitectura de OMT.
Sin embargo, si cambiamos a un modelo en el que las preocupaciones de la IU están separadas de otras, como la administración de estados, los trabajadores web pueden ser muy útiles incluso con apps basadas en frameworks. Ese es exactamente el enfoque que se adoptó con PROXX.
PROXX: un caso de éxito de OMT
El equipo de Google Chrome desarrolló PROXX como un clon de Minesweeper que cumple con los requisitos de las aplicaciones web progresivas, como funcionar sin conexión y tener una experiencia del usuario atractiva. Lamentablemente, las primeras versiones del juego tenían un rendimiento bajo en dispositivos con limitaciones, como los teléfonos con funciones, lo que llevó al equipo a darse cuenta de que el subproceso principal era un cuello de botella.
El equipo decidió usar trabajadores web para separar el estado visual del juego de su lógica:
- El subproceso principal controla la renderización de animaciones y transiciones.
- Un trabajador web controla la lógica del juego, que es puramente computacional.
La OMT tuvo efectos interesantes en el rendimiento de los teléfonos celulares de PROXX. En la versión sin OMT, la IU se inmoviliza durante seis segundos después de que el usuario interactúa con ella. No hay comentarios, y el usuario debe esperar los seis segundos completos antes de poder hacer otra cosa.
Sin embargo, en la versión de OMT, el juego tarda doce segundos en completar una actualización de la IU. Si bien eso parece una pérdida de rendimiento, en realidad genera más comentarios para el usuario. La ralentización se produce porque la app envía más tramas que la versión sin OMT, que no envía ninguna. Por lo tanto, el usuario sabe que está sucediendo algo y puede seguir jugando mientras se actualiza la IU, lo que hace que el juego se sienta mucho mejor.
Esta es una compensación consciente: les brindamos a los usuarios de dispositivos con limitaciones una experiencia que se siente mejor sin penalizar a los usuarios de dispositivos de alta gama.
Implicaciones de una arquitectura de OMT
Como se muestra en el ejemplo de PROXX, la OMT permite que tu app se ejecute de forma confiable en una variedad más amplia de dispositivos, pero no la hace más rápida:
- Solo estás moviendo el trabajo del subproceso principal, no lo estás reduciendo.
- La sobrecarga de comunicación adicional entre el trabajador web y el subproceso principal a veces puede ralentizar las cosas un poco.
Considera las compensaciones
Dado que el subproceso principal puede procesar interacciones del usuario, como el desplazamiento, mientras se ejecuta JavaScript, hay menos fotogramas perdidos, aunque el tiempo de espera total puede ser un poco más largo. Es preferible que el usuario espere un poco a que se pierda un fotograma, ya que el margen de error es menor para los fotogramas perdidos: la pérdida de un fotograma ocurre en milisegundos, mientras que tienes cientos de milisegundos antes de que un usuario perciba el tiempo de espera.
Debido a la imprevisibilidad del rendimiento en los dispositivos, el objetivo de la arquitectura de OMT es reducir el riesgo (hacer que tu app sea más sólida frente a condiciones de tiempo de ejecución muy variables), no los beneficios de rendimiento de la paralelización. El aumento de la resiliencia y las mejoras en la UX valen más que cualquier pequeña compensación en la velocidad.
Nota sobre las herramientas
Los trabajadores web aún no son populares, por lo que la mayoría de las herramientas de módulos, como webpack y Rollup, no los admiten de forma predeterminada. (pero Parcel sí lo hace). Por suerte, existen complementos para que los trabajadores web funcionen con Webpack y Rollup:
- worker-plugin para webpack
- rollup-plugin-off-main-thread para Rollup
En resumen
Para garantizar que nuestras apps sean lo más confiables y accesibles posible, especialmente en un mercado cada vez más globalizado, debemos admitir dispositivos con limitaciones, ya que es la forma en que la mayoría de los usuarios acceden a la Web en todo el mundo. La OMT ofrece una forma prometedora de aumentar el rendimiento en esos dispositivos sin afectar negativamente a los usuarios de dispositivos de alta gama.
Además, la OMT tiene beneficios secundarios:
- Traslada los costos de ejecución de JavaScript a un subproceso independiente.
- Mueve los costos de análisis, lo que significa que la IU podría iniciarse más rápido. Esto podría reducir el primer procesamiento de imagen con contenido o incluso el tiempo de interacción, lo que, a su vez, puede aumentar tu puntuación de Lighthouse.
Los Web Workers no tienen por qué ser abrumadores. Herramientas como Comlink les quitan trabajo a los trabajadores y los convierten en una opción viable para una amplia variedad de aplicaciones web.