Optimiza las tareas largas

Se te dijo que "no bloquees el subproceso principal" y que "dividas las tareas largas", pero ¿qué significa hacer eso?

Los consejos comunes para mantener la velocidad de las apps de JavaScript suelen reducirse a los siguientes:

  • “No bloquees el subproceso principal”.
  • "Divide las tareas largas".

Este es un gran consejo, pero ¿qué trabajo implica? Enviar menos JavaScript es bueno, pero ¿eso se traduce automáticamente en interfaces de usuario más responsivas? Tal vez, pero quizás no.

Para comprender cómo optimizar las tareas en JavaScript, primero debes saber qué son y cómo las controla el navegador.

¿Qué es una tarea?

Una tarea es cualquier trabajo discreto que realiza el navegador. Ese trabajo incluye la renderización, el análisis de HTML y CSS, la ejecución de JavaScript y otros tipos de trabajo sobre los que es posible que no tengas control directo. De todo esto, el código JavaScript que escribes es quizás la fuente más grande de tareas.

Visualización de una tarea como se muestra en el generador de perfiles de rendimiento de las Herramientas para desarrolladores de Chrome. La tarea está en la parte superior de una pila, con un controlador de eventos de clic, una llamada a función y más elementos debajo. La tarea también incluye algunos trabajos de renderización en el lado derecho.
Una tarea iniciada por un controlador de eventos click que se muestra en el generador de perfiles de rendimiento de Chrome DevTools.

Las tareas asociadas con JavaScript afectan el rendimiento de dos maneras:

  • Cuando un navegador descarga un archivo JavaScript durante el inicio, pone en cola tareas para analizar y compilar ese código JavaScript, de modo que se pueda ejecutar más adelante.
  • En otros momentos durante el ciclo de vida de la página, las tareas se ponen en cola cuando JavaScript funciona, como cuando se generan interacciones a través de controladores de eventos, animaciones impulsadas por JavaScript y actividad en segundo plano, como la recopilación de estadísticas.

Todo esto, con la excepción de los Web Workers y las APIs similares, ocurre en el subproceso principal.

¿Qué es el subproceso principal?

El subproceso principal es donde se ejecutan la mayoría de las tareas en el navegador y donde se ejecuta casi todo el código JavaScript que escribes.

El subproceso principal solo puede procesar una tarea a la vez. Cualquier tarea que tarde más de 50 milisegundos es una tarea larga. En el caso de las tareas que superan los 50 milisegundos, el tiempo total de la tarea menos 50 milisegundos se conoce como el período de bloqueo de la tarea.

El navegador bloquea las interacciones mientras se ejecuta una tarea de cualquier duración, pero el usuario no lo percibe, siempre y cuando las tareas no se ejecuten durante demasiado tiempo. Sin embargo, cuando un usuario intenta interactuar con una página cuando hay muchas tareas largas, la interfaz de usuario no responde y, posiblemente, incluso se rompa si el subproceso principal está bloqueado durante períodos muy largos.

Una tarea larga en el generador de perfiles de rendimiento de las herramientas para desarrolladores de Chrome. La parte de bloqueo de la tarea (más de 50 milisegundos) se representa con un patrón de rayas diagonales rojas.
Una tarea larga, como se muestra en el generador de perfiles de rendimiento de Chrome. Las tareas largas se indican con un triángulo rojo en la esquina de la tarea, y la parte de bloqueo de la tarea se completa con un patrón de rayas diagonales rojas.

Para evitar que el subproceso principal esté bloqueado durante demasiado tiempo, puedes dividir una tarea larga en varias más pequeñas.

Una sola tarea larga en comparación con la misma tarea dividida en tareas más cortas. La tarea larga es un rectángulo grande, mientras que la tarea dividida en partes es de cinco cuadros más pequeños que, en conjunto, tienen el mismo ancho que la tarea larga.
Una visualización de una sola tarea larga en comparación con esa misma tarea dividida en cinco tareas más cortas.

Esto es importante porque, cuando se dividen las tareas, el navegador puede responder a tareas de mayor prioridad mucho antes, incluidas las interacciones del usuario. Luego, se ejecutan las tareas restantes hasta completarse, lo que garantiza que se realice el trabajo que pusiste en cola inicialmente.

Representación de cómo dividir una tarea puede facilitar la interacción del usuario. En la parte superior, una tarea larga impide que se ejecute un controlador de eventos hasta que se termine la tarea. En la parte inferior, la tarea dividida permite que el controlador de eventos se ejecute antes de lo que lo haría de otra manera.
Una visualización de lo que sucede con las interacciones cuando las tareas son demasiado largas y el navegador no puede responder con la suficiente rapidez a las interacciones, en comparación con cuando las tareas más largas se dividen en tareas más pequeñas.

En la parte superior de la figura anterior, un controlador de eventos puesto en cola por una interacción del usuario tuvo que esperar una sola tarea larga antes de poder comenzar. Esto retrasa la interacción. En este caso, es posible que el usuario haya notado un retraso. En la parte inferior, el controlador de eventos puede comenzar a ejecutarse antes, y la interacción podría haberse sentido instantánea.

Ahora que sabes por qué es importante dividir las tareas, puedes aprender a hacerlo en JavaScript.

Estrategias de administración de tareas

Un consejo común en la arquitectura de software es dividir tu trabajo en funciones más pequeñas:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

En este ejemplo, hay una función llamada saveSettings() que llama a cinco funciones para validar un formulario, mostrar un ícono giratorio, enviar datos al backend de la aplicación, actualizar la interfaz de usuario y enviar estadísticas.

Conceptualmente, saveSettings() tiene una buena arquitectura. Si necesitas depurar una de estas funciones, puedes recorrer el árbol del proyecto para descubrir qué hace cada una. Dividir el trabajo de esta manera facilita la navegación y el mantenimiento de los proyectos.

Sin embargo, un posible problema aquí es que JavaScript no ejecuta cada una de estas funciones como tareas independientes porque se ejecutan dentro de la función saveSettings(). Esto significa que las cinco funciones se ejecutarán como una sola tarea.

La función saveSettings como se muestra en el generador de perfiles de rendimiento de Chrome. Mientras la función de nivel superior llama a otras cinco funciones, todo el trabajo se realiza en una tarea larga que bloquea el subproceso principal.
Una sola función saveSettings() que llama a cinco funciones. El trabajo se ejecuta como parte de una tarea monolítica larga.

En el mejor de los casos, incluso una sola de esas funciones puede contribuir con 50 milisegundos o más a la duración total de la tarea. En el peor de los casos, más de esas tareas pueden ejecutarse durante mucho más tiempo, especialmente en dispositivos con recursos limitados.

Aplaza manualmente la ejecución de código

Un método que los desarrolladores han usado para dividir tareas en tareas más pequeñas incluye setTimeout(). Con esta técnica, pasas la función a setTimeout(). Esto aplaza la ejecución de la devolución de llamada en una tarea independiente, incluso si especificas un tiempo de espera de 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

Esto se conoce como renuncia y funciona mejor para una serie de funciones que deben ejecutarse de forma secuencial.

Sin embargo, es posible que tu código no siempre esté organizado de esta manera. Por ejemplo, podrías tener una gran cantidad de datos que se deben procesar en un bucle, y esa tarea podría demorar mucho tiempo si hay muchas iteraciones.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

El uso de setTimeout() aquí es problemático debido a la ergonomía del desarrollador, y todo el array de datos podría tardar mucho tiempo en procesarse, incluso si cada iteración individual se ejecuta rápidamente. Todo esto suma, y setTimeout() no es la herramienta adecuada para el trabajo, al menos no cuando se usa de esta manera.

Usa async/await para crear puntos de rendimiento

Para asegurarte de que las tareas importantes para el usuario se realicen antes que las de prioridad más baja, puedes ceder al subproceso principal interrumpiendo brevemente la lista de tareas en cola para darle al navegador la oportunidad de ejecutar tareas más importantes.

Como se explicó anteriormente, setTimeout se puede usar para ceder al subproceso principal. Sin embargo, para mayor comodidad y legibilidad, puedes llamar a setTimeout dentro de un Promise y pasar su método resolve como devolución de llamada.

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

El beneficio de la función yieldToMain() es que puedes await en cualquier función async. A partir del ejemplo anterior, puedes crear un array de funciones para ejecutar y ceder al subproceso principal después de que se ejecute cada una:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

El resultado es que la tarea que antes era monolítica ahora se divide en tareas separadas.

La misma función saveSettings que se muestra en el generador de perfiles de rendimiento de Chrome, solo con rendimiento. El resultado es que la tarea que antes era monolítica ahora se divide en cinco tareas independientes, una para cada función.
La función saveSettings() ahora ejecuta sus funciones secundarias como tareas independientes.

Una API de programador dedicada

setTimeout es una forma eficaz de dividir tareas, pero puede tener un inconveniente: cuando cedes el subproceso principal aplazando el código para que se ejecute en una tarea posterior, esa tarea se agrega al final de la cola.

Si controlas todo el código de tu página, puedes crear tu propio programador con la capacidad de priorizar tareas, pero las secuencias de comandos de terceros no lo usarán. En efecto, no puedes priorizar el trabajo en esos entornos. Solo puedes dividirlo o cederlo explícitamente a las interacciones del usuario.

Navegadores compatibles

  • Chrome: 94.
  • Edge: 94.
  • Firefox: Detrás de una marca.
  • Safari: No se admite.

Origen

La API del programador ofrece la función postTask(), que permite programar tareas con mayor precisión y es una forma de ayudar al navegador a priorizar el trabajo para que las tareas de baja prioridad cedan el control al subproceso principal. postTask() usa promesas y acepta uno de los tres parámetros de configuración de priority:

  • 'background' para las tareas de prioridad más baja.
  • 'user-visible' para tareas de prioridad media. Este es el valor predeterminado si no se establece priority.
  • 'user-blocking' para tareas críticas que deben ejecutarse con alta prioridad.

Toma el siguiente código como ejemplo, en el que se usa la API de postTask() para ejecutar tres tareas con la prioridad más alta posible y las dos restantes con la prioridad más baja posible.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

Aquí, la prioridad de las tareas se programa de manera tal que las tareas priorizadas por el navegador, como las interacciones del usuario, puedan funcionar según sea necesario.

La función saveSettings como se muestra en el generador de perfiles de rendimiento de Chrome, pero con postTask. postTask divide cada función que ejecuta saveSettings y las prioriza de modo que una interacción del usuario tenga la oportunidad de ejecutarse sin bloquearse.
Cuando se ejecuta saveSettings(), la función programa las funciones individuales con postTask(). El trabajo crítico para el usuario se programa con prioridad alta, mientras que el trabajo que el usuario no conoce se programa para ejecutarse en segundo plano. Esto permite que las interacciones del usuario se ejecuten más rápido, ya que el trabajo se divide y se prioriza de manera adecuada.

Este es un ejemplo simplificado de cómo se puede usar postTask(). Es posible crear instancias de diferentes objetos TaskController que pueden compartir prioridades entre tareas, incluida la capacidad de cambiar las prioridades de diferentes instancias de TaskController según sea necesario.

Rendimiento integrado con Continuation mediante la API de scheduler.yield()

Navegadores compatibles

  • Chrome: 129.
  • Edge: 129.
  • Firefox: No es compatible.
  • Safari: No se admite.

Origen

scheduler.yield() es una API diseñada específicamente para ceder el subproceso principal en el navegador. Su uso se asemeja a la función yieldToMain() que se mostró antes en esta guía:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

Este código es bastante familiar, pero en lugar de usar yieldToMain(), usa await scheduler.yield().

Tres diagramas que representan tareas sin ceder, con cesión y con cesión y Continuation. Sin ceder, hay tareas largas. Con la cesión, hay más tareas que son más cortas, pero que pueden ser interrumpidas por otras tareas no relacionadas. Con la cesión y la habilitación, hay más tareas que son más cortas, pero se conserva su orden de ejecución.
Cuando usas scheduler.yield(), la ejecución de la tarea se reanuda desde donde se detuvo, incluso después del punto de rendimiento.

El beneficio de scheduler.yield() es la habilitación de la reanudación, lo que significa que, si cedes en medio de un conjunto de tareas, las otras tareas programadas continuarán en el mismo orden después del punto de cesión. Esto evita que el código de las secuencias de comandos de terceros interrumpa el orden de ejecución de tu código.

No uses isInputPending()

Navegadores compatibles

  • Chrome: 87.
  • Edge: 87.
  • Firefox: No es compatible.
  • Safari: No se admite.

Origen

La API de isInputPending() proporciona una forma de verificar si un usuario intentó interactuar con una página y solo genera resultados si hay una entrada pendiente.

Esto permite que JavaScript continúe si no hay entradas pendientes, en lugar de ceder y terminar en la parte posterior de la cola de tareas. Esto puede generar mejoras de rendimiento impresionantes, como se detalla en Intent to Ship, para los sitios que, de otro modo, podrían no volver al subproceso principal.

Sin embargo, desde el lanzamiento de esa API, nuestra comprensión de la entrega aumentó, en particular con la introducción de INP. Ya no recomendamos usar esta API, sino que recomendamos generar independientemente de si la entrada está pendiente o no por varios motivos:

  • isInputPending() puede mostrar false de forma incorrecta a pesar de que un usuario haya interactuado en algunas circunstancias.
  • La entrada no es el único caso en el que las tareas deben generar resultados. Las animaciones y otras actualizaciones regulares de la interfaz de usuario pueden ser igual de importantes para proporcionar una página web responsiva.
  • Desde entonces, se introdujeron APIs de rendimiento más integrales que abordan las inquietudes sobre el rendimiento, como scheduler.postTask() y scheduler.yield().

Conclusión

Administrar tareas es un desafío, pero hacerlo garantiza que tu página responda más rápido a las interacciones de los usuarios. No hay un solo consejo para administrar y priorizar tareas, sino varias técnicas diferentes. Repito, estos son los aspectos principales que debes tener en cuenta cuando administres tareas:

  • Ceder el subproceso principal para tareas críticas para el usuario
  • Prioriza las tareas con postTask().
  • Considera experimentar con scheduler.yield().
  • Por último, haz el menor trabajo posible en tus funciones.

Con una o más de estas herramientas, deberías poder estructurar el trabajo en tu aplicación para que priorice las necesidades del usuario y, al mismo tiempo, te asegures de que se realice el trabajo menos importante. Esto creará una mejor experiencia del usuario, que será más responsiva y más agradable de usar.

Agradecemos especialmente a Philip Walton por su revisión técnica de esta guía.

Imagen en miniatura de Unsplash, cortesía de Amirali Mirhashemian.