Entendiendo el Ciclo de Eventos con nextTick

En Vue.js, frecuentemente utilizamos nextTick para obtener los elementos del DOM o instancias de componentes actualizados. El motivo detrás de esto radica en que Vue emplea un mecanismo asíncrono de renderizado del DOM. Sin importar cuántas veces cambie el estado del componente de forma síncrona, sus efectos secundarios siempre se almacenan en una cola de tareas asíncronas que se ejecutará en el siguiente "tick", realizando únicamente una actualización. Este artículo explora las razones detrás de este enfoque y el famoso mecanismo del ciclo de eventos (Event Loop) que lo sustenta.

Hilos en el Navegador

Al introducir el concepto de ciclo de eventos, muchos comienzan directamente con la distinción entre código síncrono y asíncrono. Sin embargo, ¿por qué existen estos dos paradigmas? ¿Qué cambios ocurren internamente en la lógica JavaScript y en los elementos de la página desde la interacción del usuario hasta la respuesta de la interfaz? Para comprenderlo más profundamente, regresemos al origen: los hilos del navegador.

  • Hilo de Renderizado GUI

El hilo de renderizado GUI del navegador es responsable de renderizar la página, analizar HTML y CSS, construir los árboles DOM y CSSOM, el árbol de renderizado y dibujar la página. Cuando ocurren redibujados de la página o reflows debido a ciertas operaciones, este hilo se activa.

  • Hilo del Motor JavaScript (Web Worker)

El hilo del motor JavaScript del navegador es responsable de procesar los scripts de JavaScript. Analiza y ejecuta código JavaScript y maneja tareas asíncronas. El motor JavaScript es de único hilo, lo que significa que en una pestaña del navegador solo hay un hilo de JavaScript ejecutando programas en cualquier momento. Esto evita la complejidad y peligrosidad asociados con múltiples hilos. Es importante destacar que el hilo del motor JavaScript y el hilo de renderizado GUI son mutuamente excluyentes; cuando uno trabaja, el otro se pausa, lo que puede causar interrupciones en el renderizado y bloqueos.

  • Hilo de Eventos del Navegador

El hilo de eventos del navegador controla el ciclo de eventos. Añade operaciones del usuario como clics o movimientos, así como eventos de otros hilos como temporizadores o solicitudes asíncronas, al final de la cola de tareas, esperando su procesamiento por el hilo principal de JavaScript. Dado que el motor JavaScript es de único hilo, los eventos en la cola deben esperar su turno. Este es el mecanismo del ciclo de eventos en el navegador.

  • Hilo de Disparo de Temporizadores

El hilo de disparo de temporizadores maneja eventos asíncronos como setTimeout y setInterval. Mide intervalos de tiempo y, al completarse, añade la función de callback al final de la cola de tareas. Como el hilo principal podría estar bloqueado afectando la precisión del temporizado, los temporizadores son manejados por un hilo separado, no por el motor JavaScript.

  • Hilo HTTP Asíncrono

El hilo HTTP asíncrono maneja solicitudes HTTP asíncronas como XMLHttpRequest. Abre un nuevo hilo para enviar la solicitud y, al detectar cambios de estado, añade la función de callback a la cola de tareas. Las solicitudes HTTP pueden ser largas, por lo que están diseñadas como API asíncronas que permiten al programa continuar ejecutándose sin bloquear el hilo actual.

Flujo de Ejecución de Código Síncrono y Asíncrono

Hemos identificado los cinco hilos principales y comprendido que solo un hilo de JavaScript ejecuta nuestro código. Para simplificar, lo denominaremos hilo principal. El código síncrono se ejecuta directamente o se almacena en la pila de ejecución, mientras que el código asíncrono es gestionado por los hilos de eventos, disparo de temporizadores y HTTP asíncrono.

Este código se guarda en la tabla de eventos (Event Table) como tareas con sus respectivas funciones de callback, y cuando ocurren los eventos correspondientes, sus callbacks se añaden a la cola de eventos (Event Queue) o cola de tareas (Task Queue), esperando ser procesados por el hilo principal y añadidos a la pila de ejecución (algunas fuentes indican que se ejecutan directamente, lo cual es incorrecto; existen reglas específicas para la extracción de callbacks de la cola, que se explicarán más adelante).

En resumen, mientras el hilo principal y el hilo de renderizado GUI funcionan alternadamente, los otros tres hilos registran callbacks de eventos en la cola de tareas según sus responsabilidades, esperando ser extraídos y colocados en la pila de ejecución.

Tareas Macro y Micro

Conceptos Básicos

A partir de la sección anterior, hemos profundizado en la relación entre hilos y código síncrono/ asíncrono, y aclarado su proceso de ejecución. Para el código asíncrono, almacenado en la cola de tareas, se puede clasificar en tareas macro y tareas micro según su naturaleza.

Orden de Ejecución

Cuando el hilo principal lee la cola de tareas, primero procesa todas las tareas micro de la cola de microtareas, colocándolas en la pila de ejecución. Una vez completadas, lee la primera tarea de la cola de macrotareas, ejecuta todas las microtareas generadas durante su ejecución, y luego procede con la siguiente macrotarea. Es crucial destacar que antes de ejecutar cada macrotarea, se produce un renderizado de la página. Sin embargo, este "renderizado" no significa redibujar toda la página completa, sino identificar diferencias en los datos y actualizar el DOM real (proceso de diff del DOM virtual en React y Vue), aunque el DOM real no siempre cambia (actualizaciones innecesarias).

Este enfoque garantiza que operaciones importantes puedan realizarse con prioridad (como "cortando fila"). Si solo existieran macrotareas, los cambios de datos asíncrronos en una macrotarea solo se ejecutarían después de completarse todas las macrotareas. Si una segunda macrotarea necesita estos datos actualizados para el renderizado, obtendría el valor antiguo. Con las microtareas, los datos actualizados asíncronamente aseguran que estén actualizados antes del renderizado. Al actualizar variables mediante macrotareas (macroData) y microtareas (microData), vemos que la actualización por macrotarea ocurre después del renderizado, mientras que la microtarea se ejecuta después de la primera macrotarea pero antes del renderizado, actualizando el valor antes de que se necesite. Esto nos da la capacidad de insertar tareas antes de la ejecución para modificar el comportamiento de la página.

El proceso de Promise no es completamente asíncrono

Es fundamental强调 que para Promise, la parte que realmente genera microtareas son las sentencias resolve y reject, que podemos considerar como los disparadores. Las tareas (funciones de callback) que se registran en la cola de tareas son proporcionadas por las sentencias .then() y .catch(), mientras que la instanciación de Promise sigue siendo síncrona. Correspondientemente, en la función Executor pasada al crear el Promise, todo excepto las sentencias resolve y reject se trata como código síncrono.

new Promise((resolve,reject)=>{
  console.log("1") // Soy síncrono
  resolve()
}).then(()=>{
  console.log("2") // Soy asíncrono, soy una microtarea
})
console.log("3")
// 1 3 2

En el código anterior, la primera sentencia de salida es síncrona, mientras que la sentencia de salida dentro del callback de then es asíncrona. Comprender esto es sencillo: una promesa se establece inmediatamente, pero su cumplimiento es un proceso (pending), aunque sea muy rápido. El resultado final solo tiene dos posibilidades: cumplimiento (fulfilled) o incumplimiento (rejected), y el resultado no puede cambiar una vez establecido, lo cual es parte de la especificación Promise A+.

Recursos Adicionales

Si deseas conocer más sobre las API mencionadas en la figura anterior, he incluido enlaces de referencia. Puedes hacer clic para ver más detalles o continuar leyendo el artículo.

  • setTimeout
  • setInterval
  • setImmediate
  • requestAnimationFrame
  • process.nextTick
  • promise
  • async/await
  • MutationObserver

Problema Clásico de Entrevista

Una vez aclaradas las diferencias entre macrotareas y microtareas, podemos analizar un problema clásico de entrevista:

async function procesoAsincrono1() {
    console.log("A")
    await procesoAsincrono2()
    console.log("B")
} 

function procesoAsincrono2() {
    console.log('C');
}

console.log('D')

setTimeout(function () { 
    console.log('E')
}, 0)

procesoAsincrono1();

new Promise(function (resolver) {
    console.log('F')
    resolver()
}).then(function () {
    console.log('G')
})

console.log('H')

Análisis Simple

Este problema incluye async/await, Promise y setTimeout, lo que nos permite explorar conceptos de sincronía/ asíncronía y tareas macro/micro.

Convirtiendo async/await

Sabemos que async/await es azúcar sintáctico para Promise, por lo que podemos convertirlo a puro código Promise:

function procesoAsincrono1() {
    console.log("A")
    new Promise((resolver) => {
        console.log("C")
        resolver(undefined)
    }).then((undefined) => {
        console.log("B")
    })
}

console.log('D')

setTimeout(function () { 
    console.log('E')
}, 0)

procesoAsincrono1();

new Promise(function (resolver) {
    console.log('F')
    resolver(undefined)
}).then(function () {
    console.log('G')
})

console.log("H")

Una función async resuelve su valor de retorno, lo que equivale al siguiente código:

function procesoAsincrono2(){
  console.log("C")
 return new Promise(resolver=>{
    resolver(undefined)
  })
}

await espera primero el cambio de estado del Promise subsiguiente, obtiene el value de fulfilled o el reason de rejected, y coloca el código posterior en una microtarea, cediendo el control del hilo principal. Por lo tanto, el código después de await puede colocarse en el callback de then() de un new Promise().then().

Ejecución de Código Síncrono

Eliminando async y await, ejecutamos el código síncrono. Además de las sentencias de salida y la función procesoAsincrono1, también tenemos la instanciación de Promise, lo que produce la salida: DACFH. Hay dos microtareas (then()) y una macrotarea (setTimeout).

Ejecución de Macrotareas y Microtareas

En este punto, las dos microtareas se ejecutan primero, seguidas por la macrotarea. Sabemos que el resultado final incluirá: E. Dado que las llamadas a resolver ocurren inmediatamente después de la instanciación, las dos microtareas se ejecutarán en orden, produciendo: BG.

Por lo tanto, el resultado de salida es: DACFHBGE.

Principios de Implementación de nextTick en Vue.js

Actualización Asíncrona Interna de Vue

Vue utiliza un mecanismo de actualización asíncrona del DOM interno, lo que significa que espera a que todos los cambios de datos síncronos se completen antes de actualizar el DOM.

Sin este mecanismo de actualización asíncrona, múltiples componentes dependientes de una misma propiedad reactiva en Vue podrían renderizarse varias veces, lo que supondría una gran pérdida de rendimiento y podría causar que la página se congele. Por lo tanto, la introducción de un mecanismo de actualización asíncrona es esencial.

Implementación del Renderizado Asíncrono y nextTick en Vue

¿Cómo implementa internamente Vue este mecanismo de renderizado asíncrono?

Primero, como este proceso es asíncrono, debemos determinar si es una macrotarea o microtarea. Queremos que se ejecute inmediatamente después del código síncrono, por lo que debe ser una microtarea. En segundo lugar, estas tareas de renderizado deben almacenarse primero en una cola de tareas, eliminando actualizaciones redundantes innecesarias durante el almacenamiento. Una vez completados los cambios de datos síncronos, ejecutamos todas las tareas de la cola.

Dado que el DOM se actualiza de forma asíncrona, no podemos obtener el DOM actualizado o las instancias de componentes inmediatamente después de los cambios de datos síncronos. Debemos esperar a que se completen las tareas de renderizado, es decir, insertar nuevas microtareas después de ellas. Así que ya casi tenemos la lógica de implementación de nextTick:

const promesaResuelta=Promise.resolve()
function siguienteTick(fn){
  return fn? promesaResuelta.then(fn):promesaResuelta
}

Promise.resolve() devuelve un Promise en estado fulfilled. Su llamada a then() añade directamente la tarea al final de la cola de microtareas actual, asegurando que la tarea declarada por nextTick se ejecute oportunamente.

Por lo tanto, el propósito detallado de nextTick es declarar una microtarea para su ejecución futura.

Implementación Manual de una Cola de Renderizado Asíncrono y nextTick

Para una mejor comprensión, implementemos un ejemplo de renderizado asíncrono similar:

  1. Crear una cola de tareas vacía colaDeTareas
  2. En el proxy, colocar la lógica de renderizado original en la cola de tareas
  3. Implementar nextTick usando Promise
  4. Llamar a nextTick para añadir la lógica de ejecución de todas las tareas de la cola (tarea principal) a la cola de microtareas.

Una vez completada la ejecución del código síncrono, se ejecutará la tarea de renderizado asíncrono en la cola de microtareas.

Conclusión

El motor JavaScript del navegador es de único hilo. Para lograr la programación asíncrona, se introduce el mecanismo del ciclo de eventos.

El hilo principal siempre vacía la pila de ejecución actual antes de extraer una tarea de la cola de tareas y colocarla en la pila de ejecución para su ejecución.

La cola de tareas se divide en macrotareas y microtareas. Primero se ejecutan las microtareas y luego las macrotareas, y es necesario ejecutar todas las microtareas actuales antes de pasar a la siguiente macrotarea.

El mecanismo de renderizado del DOM en Vue es asíncrono, almacenando las actualizaciones en formato de cola y ejecutándolas mediante microtareas.

La función de nextTick es declarar una microtarea que, al ser llamada directamente después de una actualización de datos, permite obtener el DOM actualizado y las instancias de componentes.

Etiquetas: event-loop nextTick JavaScript vuejs async-programming

Publicado el 6-3 21:52