Se te dijo que "no bloquees el subproceso principal" y que "dividas las tareas largas", pero ¿qué significa hacer eso?
Fecha de publicación: 30 de septiembre de 2022; última actualización: 19 de diciembre de 2024
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.
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, por ejemplo, cuando responde a 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 trabajadores web 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.
Para evitar que el subproceso principal esté bloqueado durante demasiado tiempo, puedes dividir una tarea larga en varias más pequeñas.
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.
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.
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.
En este caso, saveSettings()
se activa con un clic del usuario y, como el navegador no puede mostrar una respuesta hasta que se termina de ejecutar toda la función, el resultado de esta tarea larga es una IU lenta y que no responde, y se medirá como una interacción deficiente a la siguiente pintura (INP).
Aplaza la ejecución de código de forma manual
Para asegurarte de que las tareas importantes para el usuario y las respuestas de la IU se realicen antes que las tareas de menor prioridad, puedes ceder el subproceso principal interrumpiendo brevemente tu trabajo para darle al navegador la oportunidad de ejecutar tareas más importantes.
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, después de cinco rondas de setTimeout()
anidados, el navegador comenzará a imponer una demora mínima de 5 milisegundos para cada setTimeout()
adicional.
setTimeout
también tiene otra desventaja en lo que respecta a la cesión: cuando cedes al subproceso principal aplazando el código para que se ejecute en una tarea posterior con setTimeout
, esa tarea se agrega al final de la cola. Si hay otras tareas en espera, se ejecutarán antes que tu código diferido.
Una API de rendimiento dedicada: scheduler.yield()
scheduler.yield()
es una API diseñada específicamente para ceder el subproceso principal en el navegador.
No es una sintaxis a nivel del lenguaje ni una construcción especial. scheduler.yield()
es solo una función que muestra un Promise
que se resolverá en una tarea futura. Cualquier código encadenado para ejecutarse después de que se resuelva Promise
(ya sea en una cadena .then()
explícita o después de await
en una función asíncrona) se ejecutará en esa tarea futura.
En la práctica, inserta un await scheduler.yield()
y la función detendrá la ejecución en ese punto y cederá al subproceso principal. La ejecución del resto de la función, llamada continuación, se programará para ejecutarse en una nueva tarea de bucle de eventos. Cuando se inicie esa tarea, se resolverá la promesa esperada y la función continuará ejecutándose desde donde quedó.
async function saveSettings () {
// Do critical work that is user-visible:
validateForm();
showSpinner();
updateUI();
// Yield to the main thread:
await scheduler.yield()
// Work that isn't user-visible, continued in a separate task:
saveToDatabase();
sendAnalytics();
}
Sin embargo, el verdadero beneficio de scheduler.yield()
sobre otros enfoques de cesión es que su continuación se prioriza, lo que significa que, si cedes en medio de una tarea, la continuación de la tarea actual se ejecutará antes de que se inicien otras tareas similares.
Esto evita que el código de otras fuentes de tareas interrumpa el orden de ejecución de tu código, como las tareas de secuencias de comandos de terceros.
Compatibilidad con varios navegadores
scheduler.yield()
aún no es compatible con todos los navegadores, por lo que se necesita un resguardo.
Una solución es colocar scheduler-polyfill
en tu compilación y, luego, puedes usar scheduler.yield()
directamente. El polyfill controlará el resguardo a otras funciones de programación de tareas para que funcione de manera similar en todos los navegadores.
Como alternativa, se puede escribir una versión menos sofisticada en unas pocas líneas, con solo setTimeout
unido en una promesa como resguardo si scheduler.yield()
no está disponible.
function yieldToMain () {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
// Fall back to yielding with setTimeout.
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
Si bien los navegadores sin compatibilidad con scheduler.yield()
no obtendrán la continuación priorizada, seguirán generando resultados para que el navegador siga siendo responsivo.
Por último, puede haber casos en los que tu código no pueda ceder al subproceso principal si no se prioriza su continuación (por ejemplo, una página conocida como ocupada en la que ceder puede generar el riesgo de no completar el trabajo durante algún tiempo). En ese caso, scheduler.yield()
se podría tratar como un tipo de mejora progresiva: genera en los navegadores en los que scheduler.yield()
está disponible, de lo contrario, continúa.
Esto se puede hacer mediante la detección de funciones y el uso de una sola microtarea en una sola línea:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Cómo dividir el trabajo de larga duración con scheduler.yield()
El beneficio de usar cualquiera de estos métodos para usar scheduler.yield()
es que puedes await
en cualquier función async
.
Por ejemplo, si tienes un array de trabajos para ejecutar que, a menudo, terminan sumando una tarea larga, puedes insertar rendimientos para dividirla.
async function runJobs(jobQueue) {
for (const job of jobQueue) {
// Run the job:
job();
// Yield to the main thread:
await yieldToMain();
}
}
Se priorizará la continuación de runJobs()
, pero aún se permitirá que se ejecute un trabajo de mayor prioridad, como responder visualmente a la entrada del usuario, sin tener que esperar a que finalice la lista potencialmente larga de trabajos.
Sin embargo, este no es un uso eficiente de la entrega. scheduler.yield()
es rápido y eficiente, pero tiene cierta sobrecarga. Si algunos de los trabajos en jobQueue
son muy cortos, la sobrecarga podría aumentar rápidamente y dedicar más tiempo a la entrega y la reanudación que a la ejecución del trabajo real.
Un enfoque es agrupar las tareas y solo generarlas entre ellas si transcurrió suficiente tiempo desde la última generación. Una fecha límite común es de 50 milisegundos para evitar que las tareas se conviertan en tareas largas, pero se puede ajustar como una compensación entre la capacidad de respuesta y el tiempo para completar la cola de trabajos.
async function runJobs(jobQueue, deadline=50) {
let lastYield = performance.now();
for (const job of jobQueue) {
// Run the job:
job();
// If it's been longer than the deadline, yield to the main thread:
if (performance.now() - lastYield > deadline) {
await yieldToMain();
lastYield = performance.now();
}
}
}
El resultado es que las tareas se dividen para que nunca tarden demasiado en ejecutarse, pero el ejecutor solo cede al subproceso principal cada 50 milisegundos.
No uses isInputPending()
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 incorrectamentefalse
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()
yscheduler.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 una serie de 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
- Usa
scheduler.yield()
(con un resguardo multinavegador) para obtener de manera ergonómica continuaciones con prioridad - Por último, haz el menor trabajo posible en tus funciones.
Para obtener más información sobre scheduler.yield()
, su elemento scheduler.postTask()
relativo explícito de programación de tareas y la priorización de tareas, consulta la documentación de la API de Prioritized Task Scheduling.
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.