Te dijeron que "no bloquees el subproceso principal" y que "dividas las tareas largas", pero ¿qué significa hacer esas cosas?
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 resumirse en lo siguiente:
- "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 equivale automáticamente a interfaces de usuario más responsivas? Tal vez sí, tal vez no.
Para comprender cómo optimizar las tareas en JavaScript, primero debes saber qué son las tareas y cómo las maneja 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 quizás no tengas control directo. De todo esto, el código JavaScript que escribes es, tal vez, la mayor fuente de tareas.
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 varias 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 se pueda ejecutar más tarde.
- En otros momentos durante la vida de la página, las tareas se ponen en cola cuando JavaScript realiza un trabajo, como responder a las interacciones a través de controladores de eventos, animaciones controladas 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 de 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 esto no es perceptible para el usuario, siempre y cuando las tareas no se ejecuten durante demasiado tiempo. Sin embargo, cuando un usuario intenta interactuar con una página en la que hay muchas tareas largas, la interfaz de usuario se sentirá como si no respondiera y, posiblemente, incluso como si estuviera rota si el subproceso principal se bloquea durante períodos muy prolongados.
Para evitar que el subproceso principal se bloquee 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 mucho antes al trabajo de mayor prioridad, incluidas las interacciones de los usuarios. Luego, las tareas restantes se ejecutan 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 que se puso en cola por una interacción del usuario tuvo que esperar una sola tarea larga antes de poder comenzar, lo que 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 el 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 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.
saveSettings() que llama a cinco funciones. El trabajo se ejecuta como parte de una tarea monolítica larga, lo que bloquea cualquier respuesta visual hasta que se completen las cinco funciones.
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 tardar mucho más, en especial 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 termine de ejecutar toda la función, el resultado de esta tarea larga es una IU lenta que no responde, y se medirá como una Interaction to Next Paint (INP) deficiente.
Cómo aplazar manualmente la ejecución de código
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 control al subproceso principal interrumpiendo brevemente tu trabajo para darle al navegador la oportunidad de ejecutar tareas más importantes.
Un método que usan los desarrolladores para dividir las tareas en otras más pequeñas implica 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 cesión 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 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.
}
}
Usar 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 un retraso mínimo de 5 milisegundos para cada setTimeout() adicional.
setTimeout también tiene otra desventaja cuando se trata de ceder el control: cuando cedes el control 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 control al 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 devuelve un Promise que se resolverá en una tarea futura. Cualquier código encadenado para ejecutarse después de que se resuelva ese 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 pausará la ejecución en ese punto y cederá el control al subproceso principal. La ejecución del resto de la función, llamada continuación de la funció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 se detuvo.
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();
}
saveSettings() ahora se divide en dos tareas. Como resultado, el diseño y la pintura se pueden ejecutar entre las tareas, lo que le brinda al usuario una respuesta visual más rápida, según lo medido por la interacción del puntero, que ahora es mucho más corta.
Sin embargo, el verdadero beneficio de scheduler.yield() sobre otros enfoques de cesión es que se prioriza su continuación, 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.
scheduler.yield(), la continuación se reanuda donde se interrumpió antes de pasar a otras tareas.
Compatibilidad entre navegadores
scheduler.yield() aún no es compatible con todos los navegadores, por lo que se necesita un resguardo.
Una solución es colocar el scheduler-polyfill en tu compilación y, luego, se puede usar scheduler.yield() directamente; el polyfill se encargará de recurrir 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, usando 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 cediendo el control para que el navegador siga respondiendo.
Por último, puede haber casos en los que tu código no pueda ceder el control al subproceso principal si no se prioriza su continuación (por ejemplo, una página que se sabe que está ocupada y en la que ceder el control corre el riesgo de no completar el trabajo durante un tiempo). En ese caso, scheduler.yield() podría tratarse como un tipo de mejora progresiva: ceder el control en los navegadores en los que scheduler.yield() esté disponible; de lo contrario, continuar.
Esto se puede hacer detectando la función y recurriendo a esperar una sola microtarea en una práctica línea de código:
// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();
Divide 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 yields para dividir la tarea.
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 se permitirá que se ejecute el trabajo de mayor prioridad, como responder visualmente a la entrada del usuario, sin tener que esperar a que finalice la lista de trabajos potencialmente larga.
Sin embargo, este no es un uso eficiente de la cesión. 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 acumularse rápidamente y generar más tiempo de suspensión y reanudación que de ejecución del trabajo real.
Un enfoque es agrupar los trabajos por lotes y solo ceder entre ellos si pasó suficiente tiempo desde la última cesión. Un plazo común es de 50 milisegundos para intentar evitar que las tareas se conviertan en tareas largas, pero se puede ajustar como un equilibrio 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();
}
}
}
Como resultado, los trabajos se dividen para que nunca tarden demasiado en ejecutarse, pero el ejecutor solo cede el paso al subproceso principal cada 50 milisegundos aproximadamente.
No uses isInputPending()
La API de isInputPending() proporciona una forma de verificar si un usuario intentó interactuar con una página y solo ceder si hay una entrada pendiente.
Esto permite que JavaScript continúe si no hay entradas pendientes, en lugar de ceder y terminar al final de la lista de tareas en cola. Esto puede generar mejoras de rendimiento impresionantes, como se detalla en la Intención de envío, para los sitios que, de lo contrario, no volverían al subproceso principal.
Sin embargo, desde el lanzamiento de esa API, nuestra comprensión de la cesión aumentó, en especial con la introducción de INP. Ya no recomendamos usar esta API y, en su lugar, recomendamos ceder el control independientemente de si hay entradas pendientes o no por varios motivos:
- En algunas circunstancias, es posible que
isInputPending()devuelvafalsede forma incorrecta, aunque un usuario haya interactuado. - La entrada no es el único caso en el que las tareas deben ceder el control. Las animaciones y otras actualizaciones periódicas de la interfaz de usuario pueden ser igual de importantes para proporcionar una página web responsiva.
- Desde entonces, se introdujeron APIs de cesión más completas que abordan las inquietudes relacionadas con la cesión, como
scheduler.postTask()yscheduler.yield().
Conclusión
Administrar las tareas es un desafío, pero hacerlo garantiza que tu página responda con mayor rapidez a las interacciones de los usuarios. No hay un solo consejo para administrar y priorizar tareas, sino varias técnicas diferentes. Para reiterar, estos son los aspectos principales que debes tener en cuenta cuando administres tareas:
- Cede el control al subproceso principal para las tareas críticas para el usuario.
- Usa
scheduler.yield()(con un resguardo para varios navegadores) para ceder el control de forma ergonómica y obtener continuaciones priorizadas. - Por último, realiza la menor cantidad de trabajo posible en tus funciones.
Para obtener más información sobre scheduler.yield(), su pariente de programación de tareas explícita scheduler.postTask() 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, garantizar que se realice el trabajo menos crítico. 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.