Se te indicó que no bloquees el subproceso principal. y "dividir las tareas largas", pero ¿qué significa hacer esas cosas?
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.
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.
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 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.
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.
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 garantizar que las tareas importantes para el usuario se realicen antes que las de menor prioridad, Puedes llevar al subproceso principal si interrumpes brevemente la lista de tareas en cola para que la oportunidades del navegador para 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.
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.
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 unpriority
.'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.
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()
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()
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 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()
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 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.