Inicio / Artículos / Cuál es el costo real de lo que renderiza React en el hilo principal

Cuál es el costo real de lo que renderiza React en el hilo principal

Render versus commit en React: por qué el trabajo de render invisible sigue compitiendo con las entradas en el hilo principal, y qué realmente ahorra la memorización.

1665 palabras

Un “render” de React, por sí solo, no cambia lo que se muestra en el navegador. Un componente que se ejecuta una vez y el mismo componente que se ejecuta cien veces pueden parecer idénticos para la persona que usa la aplicación. Parecen idénticos porque el navegador solo se actualiza cuando la fase de commit escribe en el DOM, y un render por sí solo nunca garantiza esa escritura. Entonces, ¿por qué tanta de la guía sobre rendimiento en React se centra en evitar los renders: useCallback (mantener la identidad de una función entre renders), useMemo (mantener un valor calculado entre renders), React.memo (saltarse el render de un hijo cuando los props no cambian) y reducir las actualizaciones de estado?

Esa tensión es real: puede parecer que se está optimizando algo de lo cual el usuario nunca se daría cuenta.

Si un render nunca toca el navegador, ¿en qué está gastando tiempo realmente?

Dos fases ocultas dentro de una renderización

La gente suele referirse a todo el ciclo de actualización como “render”. React divide ese ciclo en dos fases con tareas diferentes. Una fase crea una descripción de la siguiente interfaz de usuario. La otra fase decide si esa descripción debe convertirse en cambios reales en el DOM.

Los mismos cuatro desencadenantes inician ambas fases: una actualización de estado, un cambio en propiedades, una nueva renderización del padre o un cambio en el valor de contexto (un valor de la API Context leído sin pasar por las propiedades). Lo que hace cada fase a continuación —y cuál es su costo— es lo que las diferencia.

¿Qué ocurre dentro de la primera fase, esa que siempre se ejecuta cuando React programa una actualización?

Análisis de la fase de renderización

Cuando React decide que es necesario realizar una actualización, vuelve a llamar a la función del componente desde el principio. Se ejecuta cada instrucción que contiene: cálculos, bucles y creación de objetos escritos directamente en el código.

Luego React evalúa el JSX después de return. El JSX (<div>...</div>) no es HTML y nunca llega por sí solo al navegador. En el momento de la compilación, Babel o el compilador de TypeScript lo convierten en llamadas a funciones; históricamente React.createElement, pero más frecuentemente el helper moderno jsx(). Son estas llamadas las que construyen la descripción del componente.

El resultado se denomina generalmente DOM virtual; el nombre interno de React para ello es el árbol de elementos. Se trata de un objeto ordinario de JavaScript que describe la interfaz de usuario deseada, no de HTML real ni de un nodo DOM activo.

Considere un componente muy pequeño:

function Greeting({ name }) {
  const message = `Hello, ${name}`;
  return (
    <div>
      <h3>{message}</h3>
    </div>
  );
}

Cualquier cambio en name hace que React invoque nuevamente a Greeting. La cadena de plantilla para message se ejecuta de nuevo. Las llamadas a JSX compilado generan entonces un árbol de objetos similar al siguiente:

{
  "type": "div",
  "props": {
    "children": {
      "type": "h3",
      "props": { "children": "Hello, Akshat" }
    }
  }
}

Ese objeto representa el DOM virtual actualizado de Greeting. La estructura permanece en memoria; no hay mutación del DOM, ni actualización visual ni reorganización. ¿Qué consume ese árbol a continuación?

Análisis de la fase de confirmación

React compara el nuevo árbol de elementos con el anterior — proceso de reconciliación— para identificar las diferencias reales. Solo se escriben en el DOM real aquellas diferencias detectadas por esa comparación. Si no hay cambios, no se escribe nada.

Vuelva a Greeting e imagine que name pasa de una cadena a otra. El árbol anterior contenía el texto antiguo de saludo en un h3; el nuevo árbol contiene el texto actualizado. La estructura se mantiene igual: un div que envuelve a un h3 — por lo que la reconciliación registra una sola diferencia: ese nodo de texto. La fase de commit actualiza únicamente ese texto; el resto del árbol se deja intacto.

Esta es la única fase que involucra al navegador real, y también es por eso la única fase que puede forzar el recálculo del diseño (volver a calcular la geometría) o una nueva pintura de la pantalla. Si se modifican suficientes contenidos, el navegador debe volver a realizar ese trabajo. Si no se modifica nada, no lo hace.

Aquí está el caso clave para el resto del argumento. Si name no cambia al volver a activarse, el nuevo árbol coincide con el anterior, la reconciliación no encuentra diferencias y el commit es inoperante. Ninguna escritura en el DOM, ningún cambio de layout, ninguna actualización visual: nada que los ojos del usuario puedan detectar. Sin embargo, la fase de renderizado —la llamada a función, el message recalculado, el árbol de objetos recién asignado— se ejecutó por completo un momento antes.

Si el renderizado puede finalizar sin dejar rastro visible, ¿cuál fue el costo de esa operación?

La fase de renderizado puede ejecutarse sin cambiar nada

Aquí es donde el debate adquiere coherencia. Un renderizado que produce un resultado idéntico sigue consumiendo recursos reales: una llamada a función real, asignaciones reales para cada nodo del árbol, y un proceso de verificación real que recorre el árbol antes de concluir que no ha habido cambios. Nada de eso se muestra en la pantalla. Pero todo ello ocurrió igualmente.

Esa brecha —el trabajo real que permanece invisible— es la razón por la cual tanto “los renderizados no importan” como “los renderizados son muy importantes” pueden ser ciertos en diferentes niveles. Los renderizados realmente no deciden lo que ve el usuario; eso lo determina el commit. Los renderizados sí importan para algo más que no tiene nada que ver con los píxeles.

¿Y qué es ese algo más?

El hilo principal no se preocupa por si el trabajo es visible

Ese “algo” es el hilo principal de JavaScript: una cola compartida que ejecuta las tareas una tras otra. El mismo hilo procesa tu código JavaScript, calcula el diseño y distribuye los eventos de entrada: clics, desplazamientos, pulsaciones de teclado.

Los navegadores crean un nuevo frame cada 16,7 milisegundos aproximadamente para mantener 60 frames por segundo. Todo lo relacionado con un frame determinado —la lógica de la fase de renderizado, la reconciliación, la confirmación de escrituras en el DOM, el diseño y la pintura— debe caber dentro de ese límite de tiempo; además, la fase de renderizado no recibe un trato preferencial solo porque su resultado podría ser descartado. Comparte exactamente la misma cola que el usuario percibe a través de las interacciones.

Sé preciso: no cada paso del navegador relacionado con un frame se ejecuta en ese hilo. La rasterización (convertir comandos de dibujo en píxeles) y la composición (unir capas para formar el frame final) suelen realizarse en otro lugar, y por eso una capa ya rasterizada puede seguir permitiendo el desplazamiento mientras el hilo principal está ocupado. La fase de renderizado en sí, así como el evento que la activó, siguen ejecutándose en el hilo principal. Los hilos de composición separados explican parte de la fluidez bajo carga, pero no eliminan los costos de la fase de renderizado.

Un solo renderizado innecesario puede costar una fracción de milisegundo. ¿Cuándo eso se vuelve algo que el usuario percibe?

Por qué la fase de renderizado sigue teniendo impacto

Porque rara vez se ejecuta una sola vez. Cuando un padre vuelve a renderizar, React ejecuta la fase de renderizado para cada hijo por defecto, incluso cuando las propiedades de ese hijo no han cambiado, a menos que el hijo esté envuelto en React.memo.

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const products = useProducts(); // 200 items
  return (
      <div>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
        />
        <ProductList products={products} />
      </div>
    );
  }

function ProductList({ products }) {
  return (
    <div>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </div>
  );
}

Introduce un carácter en el cuadro de búsqueda y searchTerm se actualiza. Se espera que el panel de control vuelva a ejecutarse, ya que su salida ha cambiado. ProductList y, debajo de él, los 200 componentes ProductRow también se ejecutan nuevamente, no porque sus propiedades hayan cambiado (la tecla pulsada no está relacionada con los datos de los productos), sino porque los hijos se vuelven a renderizar cuando lo hacen sus padres, a menos que algo impida esa secuencia.

Cada una de esas 200 llamadas sigue siendo una ejecución real de función, un árbol de elementos real por fila y un proceso de reconciliación real que concluye que ninguna de las filas necesita una escritura en el DOM. Una tecla pulsada no cuesta nada. Un campo de búsqueda en tiempo real, a una velocidad de escritura normal, dispara ese proceso varias veces por segundo en el mismo hilo que debe manejar la siguiente tecla pulsada.

Este es el escenario al que apuntan useMemo, useCallback y React.memo: ¿entonces, qué es lo que realmente protegen?

Qué protegen realmente useMemo, useCallback y React.memo

React.memo envuelve un componente y omite su fase de renderizado cuando los nuevos props son igualmente simples que los anteriores (=== por prop). Al envolver ProductRow, una tecla pulsada en el panel de control ya no fuerza 200 llamadas a renderizar; React compara los props una vez por fila y deja de hacerlo cuando la referencia a product no cambia.

useMemo almacena en caché un cálculo entre renders, de modo que el trabajo intensivo dentro del cuerpo del componente se vuelve a ejecutar solo cuando cambian las dependencias especificadas. useCallback hace lo mismo en relación con la identidad de las funciones. Por lo general, su propósito no es tanto ahorrar en la asignación de una función como proteger a un componente hijo memorizado: tener una nueva función en cada render implica una nueva referencia, y una nueva referencia interrumpe la verificación superficial de React.memo sobre lo que recibe esa propiedad.

Ninguno de los tres altera la fase de confirmación (commit). La confirmación ya estaba condicionada a que la reconciliación detectara una diferencia real. Si de todos modos nada habría llegado a la pantalla, estas herramientas no modifican si se produce o no una escritura en el DOM. Lo que ahorran es el cálculo durante la fase de renderizado: el tiempo consumido en el hilo principal, incluso cuando el resultado es idéntico.

Tampoco son gratuitos. La comparación de React.memo y la búsqueda en caché de useMemo implican un costo adicional en cada iteración, por lo que envolver un componente económico y poco utilizado puede resultar en una pérdida neta: se paga un sobrecosto para proteger operaciones que nunca fueron costosas.

¿Cuándo le importa todo esto a alguien que utiliza el producto?

El costo de renderizado afecta al hilo que realmente percibe el usuario

Nada dentro de la fase de renderizado cambia un píxel por sí solo; esa parte de la tensión inicial siempre fue cierta. Un componente que se ejecuta una vez o cien veces puede verse igual, porque lo que llega a la pantalla está determinado por el commit, no por el renderizado.

El renderizado aún no es gratuito porque es invisible. Se trata de un trabajo real que se realiza en la misma cola de hilos simples que el diseño, las escrituras en el DOM y cada clic, desplazamiento o tecla pulsada. Evitar que tareas innecesarias de la fase de renderizado ocupen esa cola — especialmente cuando una actualización en un elemento padre se propaga a través de una lista profunda de hijos, o cuando la escritura y el desplazamiento generan actualizaciones rápidamente — es la forma de recuperar los milisegundos que necesita una interacción.

Rara vez se menciona la parte importante. Menos renderizaciones nunca ha sido el objetivo final. El objetivo es dejar suficiente del presupuesto compartido de aproximadamente 16,7 ms para las tareas de confirmación y el manejo de entradas que el usuario puede percibir.

Lecturas relacionadas