Optimiza las tareas largas

Te dijeron "no bloquear el subproceso principal" y "dividir las tareas largas", pero ¿qué significa hacerlo?

Los consejos comunes para mantener rápidas las aplicaciones de JavaScript tienden a resumirse en los siguientes consejos:

  • "No bloquees el subproceso principal".
  • "Divide tus tareas largas".

Este es un gran consejo, pero ¿qué trabajo implica? Enviar menos JavaScript es bueno, pero ¿esto equivale automáticamente a interfaces de usuario más responsivas? Tal vez sí, pero tal vez no.

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

¿Qué es una tarea?

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

Una visualización de una tarea como se muestra en el generador de perfiles de rendimiento de Herramientas para desarrolladores de Chrome. La tarea se encuentra 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 las Herramientas para desarrolladores de Chrome.

Las tareas asociadas con JavaScript afectan el rendimiento de las siguientes maneras:

  • Cuando un navegador descarga un archivo JavaScript durante el inicio, pone en cola las tareas para analizar y compilar ese JavaScript de modo que pueda ejecutarse más tarde.
  • En otras ocasiones durante el ciclo de vida de la página, las tareas se ponen en cola cuando JavaScript funciona, como el impulso de las interacciones a través de controladores de eventos, las animaciones basadas en JavaScript y la actividad en segundo plano, como la recopilación de estadísticas.

Todos estos elementos (excepto los trabajadores web y APIs similares) suceden en el subproceso principal.

¿Cuál 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 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 para que no ocurran mientras se ejecuta una tarea de cualquier duración, pero esto no es perceptible para el usuario siempre que 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 se rompe si el subproceso principal se bloquea durante períodos muy prolongados.

Una tarea larga en el generador de perfiles de rendimiento de las Herramientas para desarrolladores de Chrome. La parte que bloquea la tarea (superior a 50 milisegundos) se representa con un patrón de rayas diagonales rojas.
Es 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, con la parte de bloqueo rellena con un patrón de rayas rojas diagonales.

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

Una sola tarea larga frente a la misma tarea dividida en una tarea más corta. La tarea larga es un rectángulo grande, mientras que la tarea fragmentada consta de cinco cajas más pequeñas que, en conjunto, tienen el mismo ancho que la tarea larga.
Visualización de una sola tarea larga en comparación con la misma tarea dividida en cinco tareas más cortas.

Esto es importante porque cuando se dividen las tareas, el navegador puede responder mucho antes al trabajo de mayor prioridad, incluidas las interacciones del usuario. Después, las tareas restantes se ejecutan hasta su finalización, lo que garantiza que el trabajo que pusiste inicialmente en cola se realice.

Representación de cómo dividir una tarea puede facilitar la interacción del usuario. En la parte superior, una tarea larga bloquea la ejecución de un controlador de eventos hasta que finaliza la tarea. En la parte inferior, la tarea fragmentada permite que el controlador de eventos se ejecute antes de lo esperado.
Visualización de lo que sucede con las interacciones cuando las tareas son demasiado largas y el navegador no puede responder con la rapidez suficiente 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 imagen anterior, un controlador de eventos en cola por una interacción del usuario tuvo que esperar una sola tarea larga antes de que comenzara, lo cual retrasa la interacción. En esta situación, el usuario podría haber notado un retraso. En la parte inferior, el controlador de eventos puede comenzar a ejecutarse antes, y es posible que la interacción se haya percibido 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.

En teoría, saveSettings() tiene una buena arquitectura. Si necesitas depurar una de estas funciones, puedes recorrer el árbol del proyecto para averiguar qué hace cada función. 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 separadas 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. Si bien la función de nivel superior llama a otras cinco funciones, todo el trabajo se lleva a cabo 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 solo una 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 la ejecución del código de forma manual

Un método que los desarrolladores han usado para dividir las tareas en otras más pequeñas implica el setTimeout(). Con esta técnica, pasas la función a setTimeout(). Esto pospone 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 yielding y funciona mejor para una serie de funciones que necesitan ejecutarse de manera secuencial.

Sin embargo, es posible que tu código no siempre se organice de esta manera. Por ejemplo, podrías tener una gran cantidad de datos que deben procesarse en un bucle, y esa tarea podría tardar 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 de los desarrolladores, y todo el array de datos podría tardar mucho tiempo en procesarse, incluso si cada iteración individual se ejecuta con rapidez. Todo suma, y setTimeout() no es la herramienta adecuada para el trabajo, al menos no cuando se usa de esta manera.

Use async/await para crear puntos de productividad

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

Como se explicó anteriormente, se puede usar setTimeout 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 aplicarle await en cualquier función async. A partir del ejemplo anterior, podrías 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 "output". El resultado es la tarea que antes era monolítica, ahora se divide en cinco tareas distintas, una para cada función.
La función saveSettings() ahora ejecuta sus funciones secundarias como tareas separadas.

Una API de programador dedicada

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

Si controlas todo el código de tu página, es posible crear tu propio programador con la capacidad de priorizar tareas, pero las secuencias de comandos de terceros no usarán tu programador. De hecho, no puedes priorizar el trabajo en esos entornos. Solo puedes fragmentarlo o ceder de forma explícita a las interacciones del usuario.

Navegadores compatibles

  • 94
  • 94
  • x

Origen

La API del programador ofrece la función postTask(), que permite programar las 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 al subproceso principal. postTask() usa promesas y acepta una de las tres opciones de configuración de priority:

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

Toma como ejemplo el siguiente código, en el que se usa la API de postTask() para ejecutar tres tareas con la prioridad más alta posible y las dos tareas 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 tal manera que las tareas priorizadas por el navegador, como las interacciones del usuario, pueden trabajar en el medio según sea necesario.

La función saveSettings como se muestra en el generador de perfiles de rendimiento de Chrome, pero al utilizar postTask. postTask divide cada función saveSettings ejecuta 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 sobre el que el usuario no conoce está programado para ejecutarse en segundo plano. Esto permite que las interacciones de los usuarios se ejecuten más rápido, ya que el trabajo se divide y se prioriza de forma adecuada.

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

Rendimiento integrado con continuidad con el uso de la próxima API de scheduler.yield()

Una adición propuesta a la API del programador es scheduler.yield(), una API diseñada específicamente para ceder al subproceso principal del navegador. Su uso se asemeja a la función yieldToMain() que se demostró 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 muy familiar, pero en lugar de usar yieldToMain(), usa await scheduler.yield().

Tres diagramas que muestran tareas sin ceder, ceder y con ceder y continuación. Sin ceder, hay tareas largas. Con el rendimiento, hay más tareas que son más cortas, pero que pueden verse interrumpidas por otras tareas no relacionadas. Con el rendimiento y la continuació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 continúa desde donde se detuvo, incluso después del punto de productividad.

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

El uso de scheduler.postTask() con priority: 'user-blocking' también tiene una alta probabilidad de continuación debido a la alta prioridad de user-blocking, por lo que este enfoque podría usarse como alternativa mientras tanto.

El uso de setTimeout() (o scheduler.postTask() con priority: 'user-visibile' o sin priority explícito) programa la tarea al final de la cola, lo que permite que otras tareas pendientes se ejecuten antes de la continuación.

No usar isInputPending()

Navegadores compatibles

  • 87
  • 87
  • x
  • x

La API de isInputPending() proporciona una forma de verificar si un usuario intentó interactuar con una página y solo mostrar el rendimiento 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 lista de tareas en cola. Esto puede generar mejoras impresionantes en el rendimiento, como se detalla en la sección Intent to Ship de los sitios que, de lo contrario, podrían no cederse al subproceso principal.

Sin embargo, desde el lanzamiento de esa API, nuestra comprensión del rendimiento ha aumentado, especialmente con la introducción de INP. Ya no recomendamos usar esta API y, en su lugar, recomendamos el rendimiento independientemente de si la entrada está pendiente o no por varios motivos:

  • Es posible que isInputPending() muestre 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 deberían cederse. Las animaciones y otras actualizaciones regulares de la interfaz de usuario pueden ser igualmente importantes para proporcionar una página web adaptable.
  • Desde entonces, se introdujeron APIs de rendimiento más integrales que abordan problemas de rendimiento, como scheduler.postTask() y scheduler.yield().

Conclusión

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

  • Cámbiate al subproceso principal para las tareas críticas y orientadas al 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, a la vez que garantizas que se realice el trabajo menos crítico. Esto creará una mejor experiencia del usuario que es más responsiva y más agradable de usar.

Agradecimientos especiales a Philip Walton por su evaluación técnica de esta guía.

Miniatura obtenida de Unsplash, cortesía de Amirali Mirhashemian.