Comprendre les pistes React : comment les masques binaires encodent la priorité des mises à jour
Découvrez comment React encode plusieurs priorités de mise à jour en un seul entier à l’aide de masques binaires, et pourquoi les opérations bit par bit remplacent les simples flags booléens pour la planification.
Après tout, ce ne sont pas des booléens
Dans la partie précédente, vous avez vu que lanes et childLanes fonctionnaient comme s’ils étaient de simples indicateurs. Un champ passait d’état vide à occupé sur la fibre correspondant à Counter ; le même changement se produisait sur chaque fibre parente située au-dessus, et la navigation à travers l’arbre s’appuyait sur ces changements pour déterminer quels branches valaient la peine d’être explorées. Rien d’autre n’était important pour cette explication : un champ était soit vide, soit il ne l’était pas.
Cependant, cette description omet ce que ces champs contiennent réellement.
{
type: Counter,
memoizedState: { count: 1 },
lanes: 16,
}
lanes stocke un nombre, et non true ou false — un entier réel enregistré sur la fibre. Si React n’avait besoin que de savoir « quelque chose est-il en attente, oui ou non ? », un simple booléen aurait suffi amplement. React a choisi autre chose à la place.
Alors pourquoi un nombre serait-il le choix naturel pour indiquer "cette fibre a des tâches en attente" ?
Qu’est-ce qu’une masque binaire en réalité
React fournit un ensemble de constantes conçues précisément à cette fin, et en les examinant on obtient immédiatement la réponse.
SyncLane = 1 = 00000001
InputContinuousLane = 4 = 00000100
DefaultLane = 16 = 00010000
TransitionLane = 64 = 01000000
Écrits en binaire, chacune de ces constantes contient exactement un 1, et ce 1 se trouve à une position différente pour chaque constante. SyncLane occupe la première case ; DefaultLane occupe la cinquième. Aucune des deux ne se trouve jamais à la même position. Cette disposition est ce qu’on appelle un masque binaire — un nombre dont la valeur n’a pas d’importance numérique, mais qui fonctionne plutôt comme une série d’interrupteurs indépendants allumés/éteints, un par bit, tous stockés ensemble dans un seul champ.
Lorsque la fibre de Counter affiche lanes: 16, cela ne signifie pas « cette mise à jour vaut seize unités de quelque chose ». Cela veut dire que le bit appartenant àDefaultLaneest activé, tandis que tous les autres bits restent désactivés. Ce chiffre sert de conteneur de stockage pour des indicateurs, et non comme une quantité à mesurer.
Que se passe-t-il lorsqu’une seule fibre doit suivre simultanément plus d’un de ces indicateurs ?
Combinaison de deux canaux en une seule opération
Imaginons une fibre qui gère en même temps deux types de tâches en attente : une mise à jour normale ainsi qu’une autre dont la priorité a été délibérément réduite. React fusionne les deux masques binaires à l’aide d’un OU bit par bit : une opération qui compare les nombres bit par bit et met un bit à 1 dans le résultat chaque fois que l’un des nombres sources le possède déjà.
00010000 (16, DefaultLane)
| 01000000 (64, TransitionLane)
-----------
01010000 (80)
Puisque DefaultLane et TransitionLane possèdent chacun une position de bit que aucune autre voie n’atteint, leur OR combiné ajoute simplement les deux valeurs 1 dans le nombre résultant, sans que l’une efface l’autre. Le résultat, 80, est la nouvelle valeur de lanes ; en le convertissant à nouveau en binaire, on constate que les deux bits originaux y sont préservés tels quels.
En examinant le nombre 80 seul — que révèle-t-il vraiment ?
Pourquoi voir 80 seul ne vous dit rien
Par lui-même, 80 ne révèle rien. On ne lit pas ce type de valeur comme on le ferait avec un nombre normal ; on l’interroge, bit par bit, en utilisant une opération ET binaire, qui ne laisse un 1 à une position que lorsque les deux nombres comparés possèdent déjà un 1 à cet endroit.
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)
On n’examine jamais une valeur de voie par elle-même. Au lieu de cela, on pose une question précise concernant une voie spécifique, et la réponse est soit zéro, soit un nombre différent de zéro. C’est exactement ce qui s’est passé en arrière-plan dans la logique de parcours de l’arbre décrite précédemment : chaque fois qu’elle examinait les childLanes d’une fibre pour déterminer s’il valait la peine de descendre dans une branche, elle effectuait cette même vérification par ET binaire sur la voie qu’elle était en train de rechercher.
Revenons donc à la question fondamentale : pourquoi représenter cela par un nombre plutôt que par une simple étiquette booléenne ?
Pourquoi ne pas utiliser simplement un booléen ?
Un masque binaire permet de répondre à ces deux exigences en même temps. Un seul champ numérique peut contenir plusieurs drapeaux en parallèle, car chaque drapeau occupe sa propre case binaire. Tester un drapeau ou combiner plusieurs drapeaux relève d’une seule opération CPU peu coûteuse, qu’il s’agisse d’un ET ou d’un OU, plutôt que d’une action nécessitant de parcourir une structure de données ou de conserver une variable booléenne distincte pour chaque niveau de priorité possible. C’est précisément la raison pour laquelle lanes est stocké sous forme d’entier plutôt que comme true ou false : il ne s’agit pas de consigner l’existence d’une tâche, mais bien de déterminer précisément quels types de tâches existent, tous en même temps, avec presque aucun coût supplémentaire.
Ce qui n’a pas encore été abordé, c’est la raison pour laquelle une mise à jour devrait être considérée comme ayant une priorité inférieure en premier lieu.
Pourquoi les mises à jour ont besoin de priorité
Considérez une zone de recherche conçue en tant que composant contrôlé, où chaque frappe réfiltre une liste de dix mille éléments, et le filtrage lui-même a lieu directement à l’intérieur du corps de la fonction du composant.
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>
);
}
Entrez un seul caractère. La fonction handleChange s’exécute, appelle setQuery, ce qui planifie un nouveau rendu de SearchableList. Lorsque React traite finalement ce nouveau rendu, il exécute la fonction SearchableList sous forme d’une seule appelation synchrone ininterrompue, du début à la fin. À un moment donné au cours de cet appel unique, la méthode .filter() doit itérer sur l’ensemble des dix mille éléments avant que la fonction ne puisse retourner son nouveau JSX.
Une appel de fonction en cours ne peut pas être suspendu au milieu de son propre exécution. La capacité d’interruption offerte par les fibres opère à la frontière entre ces dernières, et non au sein de l’exécution ligne par ligne d’une fonction qui est encore en cours. Par conséquent, React ne peut pas afficher la valeur d’entrée mise à jour, enregistrer quoi que ce soit ou projeter des pixels à l’écran tant que cet appel de fonction, y compris le filtrage, n’est pas terminé. Le prochain caractère saisi est mis en file d’attente derrière lui, non pas parce que deux mises à jour distinctes se disputent l’exécution, mais parce que ces deux tâches font partie intégrante du résultat de cet unique appel.
La solution consiste à diviser cela en deux mises à jour véritablement indépendantes, chacune pouvant disposer de son propre niveau de priorité.
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 et setDeferredQuery existent désormais en tant que deux appels distincts, chacun déclenchant sa propre mise à jour et étant marqué par son propre canal. La rendu provenant de setQuery est peu coûteux, car aucun filtrage n’est impliqué ; il s’exécute donc rapidement et le champ de saisie reste réactif aux frappes. Le rendu provenant de setDeferredQuery est celui qui exécute réellement la méthode .filter(), et il inclut un objet TransitionLane car il est enveloppé dans la fonction startTransition, l’élément qui active ce mode de mise à jour différée. Si vous saisissez un autre caractère pendant que ce rendu de filtrage lent est encore en cours, React examine les canaux disponibles, trouve une mise à jour de priorité plus élevée en attente, et abandonne le travail en cours de moindre priorité pour traiter d’abord la frappe. Cela ne fonctionne que parce que les deux mises à jour sont désormais suivies de manière indépendante.
Une question légitime à ce stade est de savoir si les développeurs doivent attribuer manuellement ces valeurs de priorité, mise à jour après mise à jour.
La priorité est principalement automatique
En grande partie, non. React choisit généralement une voie en fonction de ce qui a provoqué la mise à jour au départ, et non parce que quelqu’un l’a délibérément choisie. Cette décision a lieu au sein du système d’événements de React, qui connaît le type d’événement navigateur traité au moment précis où setState est exécuté.
Classés approximativement du plus urgent au moins urgent :
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)
Appeler setState depuis un gestionnaire de clic le place automatiquement en haut de ce classement. L’appel depuis un gestionnaire de défilement le place à un niveau inférieur, car traiter chaque mise à jour déclenchée par le défilement comme ayant la priorité maximale, potentiellement des dizaines de fois par seconde, provoquerait lui-même des ralentissements. Une mise à jour déclenchée par une promesse résolue ou un timeout, sans contexte environnant sur lequel s’appuyer, reçoit par défaut la valeur DefaultLane. Le seul contrôle manuel auquel un développeur a réellement accès est startTransition, dont la portée est restreinte : soit une mise à jour s’exécute normalement et React en déduit la priorité à partir du contexte comme pour tout le reste, soit elle est enveloppée, ce qui la force dans TransitionLane quel que soit le déclencheur réel. useDeferredValue n’est essentiellement qu’un enveloppement léger basé sur ce même mécanisme. Personne ne définit
fiber.lanes = 64 directement, et il n’y a jamais de raison de le faire autrement. Une précision à mentionner avant de conclure : le code réel de React définit plutôt une trentaine de constantes distinctes pour les canaux, et non seulement quatre. Beaucoup d’entre elles existent uniquement à des fins de suivi interne, sans représenter réellement un nouveau niveau de priorité utile pour suivre plusieurs transitions indépendamment au lieu de les regrouper dans un seul ensemble non différencié. Les cinq niveaux décrits ci-dessus sont ceux qu’il vaut la peine de retenir comme modèle mental. Tout le reste relève de détails d’implémentation intégrés à une structure que vous comprenez déjà.
Quatre éléments, un mécanisme
En résumé, voici ce qui se passe réellement lors d’un re-render, du début à la fin. L’état est stocké dans une fibre car c’est l’objet qui perdure au-delà d’une seule passe de rendu, contrairement à la fonction de composant qui oublie tout entre deux appels, ou à l’élément qui est jeté et reconstruit à chaque fois. Une fibre apparaît sous forme de deux versions liées, la version actuelle et celle en cours de traitement, reliées entre elles par le pointeur alternate, car il est nécessaire d’avoir des captures avant et après côte à côte pour permettre toute comparaison. Appeler setState ne touche jamais directement aucune fibre. Au lieu de cela, il enregistre une note dans une file d’attente et reporte le travail réel, tandis qu’un ensemble de voies marquées permet à la navigation ultérieure dans l’arbre de savoir quels branches méritent réellement d’être explorées. Et ce marqueur n’a jamais été simple
Quatre idées distinctes, mais qui se résument en un seul mécanisme. Chacune n’est pleinement compréhensible que lorsque l’on a déjà saisi celle qui la précède. Prises isolément, chacune de ces composantes se réduit à un fait que l’on peut mémoriser sans comprendre le raisonnement qui la justifie réellement.