Inicio / Artículos

Entendiendo las carriles de React: cómo los máscaras de bits codifican la prioridad de las actualizaciones

Aprenda cómo React codifica múltiples prioridades de actualización en un único número entero mediante máscaras de bits, y por qué las operaciones bit a bit reemplazan a las simples banderas booleanas para la programación.

9 min de lectura2125 palabras

No son booleanos, después de todo

En la parte anterior, viste cómo lanes y childLanes funcionan como si fueran simples indicadores. Un campo pasaba de estar vacío a ocupado en la fibra correspondiente a Counter; el mismo cambio ocurría en cada fibra padre superior, y el recorrido por el árbol dependía de esos cambios para decidir qué ramas valían la pena explorar. Para esa explicación no importaba nada más: un campo estaba o vacío o no.

Pero esa descripción omite qué es lo que realmente contienen estos campos.

{
  type: Counter,
  memoizedState: { count: 1 },
  lanes: 16,
}

lanes almacena un número, no true o false; se trata de un entero real almacenado en la fibra. Si React solo necesitara saber “¿hay algo pendiente, sí o no?”, un simple booleano habría sido suficiente. En lugar de eso, React eligió otra cosa.

Entonces, ¿por qué sería un número la opción natural para indicar “esta fibra tiene trabajo pendiente”?

Qué es realmente una máscara de bits

React incluye un grupo de constantes creadas específicamente para este propósito, y al analizarlas se obtiene la respuesta de inmediato.

SyncLane            = 1   = 00000001
InputContinuousLane = 4   = 00000100
DefaultLane         = 16  = 00010000
TransitionLane      = 64  = 01000000

Escritas en binario, cada constante contiene exactamente un 1, y ese 1 se encuentra en una posición distinta para cada constante. SyncLane ocupa el primer slot; DefaultLane ocupa el quinto. Ninguna de ellas se encuentra nunca en la misma posición. Este diseño es lo que se conoce como una máscara de bits: un número cuyo valor no es importante desde el punto de vista numérico, pero que funciona como una fila de interruptores independientes de encendido/apagado, uno por bit, todos almacenados juntos en un único campo.

Cuando la fibra de Counter muestra lanes: 16, eso no significa “esta actualización vale dieciséis unidades de algo”. Significa que el bit correspondiente aDefaultLaneestá activado, y todos los demás bits permanecen desactivados. El número funciona como un contenedor de almacenamiento para indicadores, no como una cantidad que se deba medir.

¿Qué sucede cuando una sola fibra necesita hacer seguimiento de más de un indicador al mismo tiempo?

Combinar dos canales con una sola operación

Imagínese una fibra que recibe dos tipos diferentes de tareas pendientes al mismo momento: una actualización normal junto con otra que se ha asignado intencionalmente una prioridad más baja. React combina las dos máscaras de bits mediante un operador OR bit a bit: una operación que alinea los números bit por bit y establece un bit en 1 en el resultado siempre que alguno de los números de origen lo tenga establecido.

00010000   (16, DefaultLane)
| 01000000   (64, TransitionLane)
-----------
  01010000   (80)

Dado que DefaultLane y TransitionLane poseen cada uno una posición de bit a la que ninguna otra vía accede, al combinarlas mediante operación OR simplemente se incluyen ambos 1 en el número resultante, sin que uno borre al otro. El resultado, 80, es el nuevo valor de lanes; si lo conviertes nuevamente a binario, encontrarás que ambos bits originales se conservan dentro de él, tal como estaban.

Al observar el número 80 por sí solo, ¿qué revela realmente?

Por qué ver solo el 80 no te dice nada

Por sí solo, 80 no revela nada. No se lee este tipo de valor como se leería un número normal; en su lugar, se analiza bit a bit, utilizando una operación de AND bit a bit, la cual deja un 1 en una posición solo cuando ambos números comparados ya contienen un 1 en esa posición.

01010000   (80)
& 00010000   (16, DefaultLane)
-----------
  00010000   (non-zero → yes, DefaultLane's bit is set)
01010000   (80)
& 00000001   (1, SyncLane)
-----------
  00000000   (zero → no, SyncLane's bit is not set)

Nunca se examina el valor de una vía por separado. En su lugar, se plantea una pregunta específica sobre una vía concreta, y la respuesta es cero o algún valor distinto de cero. Esto es exactamente lo que sucedió, en secreto, en la lógica de recorrido del árbol mencionada anteriormente: cada vez que se analizaban los childLanes de una fibra para decidir si valía la pena profundizar en un ramo, se realizaba esta misma comprobación con AND contra la vía que se estaba buscando en ese momento.

Así que, de vuelta a la pregunta fundamental: ¿por qué representar esto como un número en lugar de simplemente como una bandera booleana?

¿Por qué no usar solo un booleano?

Un booleano puede responder a un único tipo de pregunta: ¿hay algo pendiente, sí o no? No tiene espacio para expresar “hay una actualización normal esperando y, de forma independiente, otra de menor prioridad también está esperando”, ya que un booleano solo lleva consigo un único bit de información. React necesita hacer seguimiento de múltiples categorías distintas de tareas pendientes en una misma fibra al mismo tiempo, y debe poder verificar cualquiera de ellas sin afectar el estado de las demás.

Una máscara de bits cumple con ambos requisitos al mismo tiempo. Un único campo numérico puede contener varias banderas en paralelo, ya que cada bandera ocupa su propio slot de bit. Además, probar una bandera o combinar varias banderas es una operación simple y económica para la CPU, ya sea un operador AND o OR, en lugar de algo que requiera recorrer una estructura de datos o mantener una variable booleana separada para cada nivel de prioridad posible. Esa es la razón principal por la cual lanes se almacena como un entero en lugar de como true o false: no registra si existe trabajo, sino exactamente qué tipos de trabajo existen, todo simultáneamente y casi sin costo alguno.

Lo que aún no se ha abordado es por qué una actualización tendría que considerarse de menor prioridad en primer lugar.

Por qué las actualizaciones necesitan tener prioridad

Considere una entrada de búsqueda construida como un componente controlado, donde cada tecla pulsada vuelve a filtrar una lista de diez mil elementos, y el propio proceso de filtrado tiene lugar directamente dentro del cuerpo de la función del componente.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  function handleChange(e) {
    setQuery(e.target.value);
  }
  const filteredItems = items.filter(item => item.includes(query));
  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

Escriba un único carácter. Se ejecuta handleChange, que llama a setQuery, lo cual programa una nueva renderización de SearchableList. Cuando React finalmente procesa esa nueva renderización, invoca la función SearchableList como una llamada síncrona e ininterrumpida desde el principio hasta el final. En algún punto dentro de esa única llamada, .filter() debe iterar sobre todos los diez mil elementos antes de que la función pueda devolver su nuevo JSX.

Una llamada a función en ejecución no puede ser suspendida a mitad de su propio cuerpo. La interrumpibilidad que ofrecen las fibras opera en la frontera entre ellas, no dentro de la ejecución línea por línea de una función que aún está en curso. Por lo tanto, React no tiene forma de mostrar el valor de entrada actualizado, guardar ningún cambio ni dibujar píxeles en la pantalla hasta que esa llamada a función, incluido el filtrado, haya finalizado. La siguiente tecla que se escribe termina en cola detrás de ella, no porque haya dos actualizaciones separadas compitiendo, sino porque ambas tareas forman parte del resultado de esa misma llamada única.

La solución es dividir esto en dos actualizaciones verdaderamente independientes, cada una capaz de tener su propio nivel de prioridad.

function SearchableList({ items }) {
  const [query, setQuery] = useState("");
  const [deferredQuery, setDeferredQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);
    startTransition(() => {
      setDeferredQuery(value);
    });
  }

  const filteredItems = items.filter(item => item.includes(deferredQuery));

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
    </div>
  );
}

setQuery y setDeferredQuery ahora existen como dos llamadas separadas; cada una desencadena su propia actualización y está etiquetada con su propio canal. La renderización proveniente de setQuery es económica, ya que no implica filtrado alguno, por lo que se realiza rápidamente y el campo de entrada sigue siendo sensible a la escritura. La renderización proveniente de setDeferredQuery es la que realmente ejecuta .filter(), y lleva consigo TransitionLane porque está envuelta en startTransition, el mecanismo que habilita este patrón de actualización diferida. Si se escribe otro carácter mientras esa renderización lenta por filtrado aún está en curso, React inspecciona los lanes, encuentra una actualización de mayor prioridad esperando y descarta el trabajo inacabado de menor prioridad para atender primero la tecla pulsada. Esto solo funciona porque ahora las dos actualizaciones se rastrean de forma independiente.

, cada uno con su propio marcador de prioridad, en lugar de estar agrupados dentro de una sola llamada a función que maneje ambos trabajos simultáneamente.

Una pregunta razonable en este punto es si se espera que los desarrolladores asignen estos valores de prioridad manualmente, actualización por actualización.

La prioridad es en su mayor parte automática

En la mayoría de los casos, no. React suele elegir un canal según lo que causó la actualización en primer lugar, y no porque alguien haya elegido deliberadamente uno. Esta decisión se toma dentro del sistema de eventos de React, el cual sabe qué tipo de evento del navegador se está manejando en el momento exacto en que se ejecuta setState.

Clasificados aproximadamente de lo más urgente a lo menos urgente:

Discrete events    (clicks, key presses, form submissions)
Continuous events  (scroll, drag, pointer-move)
Default            (timeouts, resolved promises, no special context)
Transition         (explicitly deprioritized via startTransition)
Idle               (only when nothing else is pending)

Al llamar a setState desde un manejador de clics, este se coloca automáticamente cerca del principio de esa clasificación. Al hacerlo desde un manejador de desplazamiento, se sitúa un nivel más abajo, ya que tratar cada actualización provocada por el desplazamiento como de máxima prioridad, potencialmente decenas de veces por segundo, generaría problemas de rendimiento. Una actualización desencadenada por una promesa resuelta o un tiempo de espera, sin contexto adyacente del que extraer información, utiliza por defecto DefaultLane. El único control manual al que realmente tiene acceso un desarrollador es startTransition, y su alcance es limitado: o bien la actualización se procesa normalmente y React infiere su prioridad a partir del contexto como con todo lo demás, o bien se envuelve en un proceso que la fuerza a usar TransitionLane independientemente de lo que realmente la haya provocado. useDeferredValue es esencialmente un envoltorio ligero construido sobre el mismo mecanismo. Nadie establece

fiber.lanes = 64 directamente, y nunca hay razón para hacerlo de otra manera.

Una advertencia que vale la pena mencionar antes de concluir: el código real de React define algo cercano a treinta constantes de tipo “lane” distintas, no solo cuatro. Muchas de estas existen únicamente para fines internos de registro, en lugar de representar un nivel de prioridad realmente nuevo y útil para rastrear varias transiciones de forma independiente, en lugar de agruparlas en una sola categoría común e indiferenciada. Los cinco niveles descritos anteriormente son los que merecen ser recordados como modelo mental. Todo lo demás son detalles de implementación que se encuentran debajo de una estructura que ya comprendes.

Cuatro componentes, un mecanismo

Al unir todo en conjunto, esto es lo que realmente sucede durante una nueva renderización, de principio a fin. El estado se almacena en una fibra porque la fibra es el objeto que perdura más allá de un único proceso de renderización, a diferencia de la función del componente, que olvida todo entre llamadas, o del elemento, que se descarta y se reconstruye cada vez. Una fibra aparece como dos versiones vinculadas: la actual y la en proceso, unidas por el puntero alternate, ya que se necesita una captura real antes y después una al lado de la otra para que cualquier comparación sea posible. Llamar a setState nunca afecta directamente a ninguna fibra. En su lugar, deja una nota en una cola y pospone el trabajo real, mientras que un conjunto marcado de rutas permite al recorrido posterior del árbol saber qué ramas realmente merecen ser exploradas. Y ese marcador nunca fue algo simple

En primer lugar, se trata de una máscara de bits que contiene suficiente información codificada como para que un solo tecleo permita saltarse todo el trabajo de renderizado que aún está pendiente en la cola.

Hay cuatro ideas distintas, pero todas se resuelven en un único mecanismo. Cada una solo queda completamente clara una vez que ya se ha comprendido la anterior. Tomadas por separado, cualquiera de estas partes se reduce a un hecho que se puede memorizar sin conocer la lógica que realmente lo justifica.