Se te dijo que "no bloquees el subproceso principal" y que "dividas tus 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. 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 código JavaScript que escribes es quizás la mayor fuente 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, 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 rompe si el subproceso principal se bloquea durante períodos muy prolongados.
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 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.
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.
Aplaza la ejecución de código de forma manual
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 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 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 se organice 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.
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, 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.
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 usarán tu programador. En efecto, no puedes priorizar el trabajo en esos entornos. Solo puedes dividirlo o cederlo explícitamente a las interacciones del usuario.
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 establecepriority
.'user-blocking'
para tareas críticas que deben ejecutarse con prioridad alta.
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 tal manera que las tareas priorizadas por el navegador, como las interacciones del usuario, pueden trabajar en el medio según sea necesario.
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 continuación mediante el uso de la API de scheduler.yield()
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()
.
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()
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 y, en su lugar, recomendamos el rendimiento independientemente de si la entrada está pendiente o no por varios motivos:
- Es posible que
isInputPending()
muestrefalse
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 habituales 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()
yscheduler.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 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 de modo 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 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.