Verständnis von React Lanes: Wie Bitmasks Aktualisierungs-Prioritäten kodieren
Erfahren Sie, wie React mehrere Aktualisierungs-Prioritäten mithilfe von Bitmasks in eine einzige Ganzzahl kodiert und warum bitweise Operationen einfache boolesche Flags bei der Scheduling-Logik ersetzen.
Nicht doch Boolesche Werte
In dem vorherigen Teil haben Sie gesehen, wie lanes und childLanes so funktionieren, als wären sie einfache Flags. Ein Feld wechselte auf der Fiber für Counter vom leeren Zustand in den belegten Zustand, derselbe Wechsel trat auf jeder übergeordneten Fiber ein, und die Durchquerung des Baums stützte sich auf diese Zustandswechsel, um zu entscheiden, welche Äste es überhaupt wert waren, betreten zu werden. Für diese Erklärung spielte alles andere keine Rolle: Ein Feld war entweder leer oder es war es nicht.
Diese Beschreibung lässt jedoch unberücksichtigt, was diese Felder tatsächlich enthalten.
{
type: Counter,
memoizedState: { count: 1 },
lanes: 16,
}
lanes enthält eine Zahl – nicht true oder false – ein echter Integer, der auf der Fiber gespeichert ist. Hätte React nur wissen müssen, „ist etwas ausstehend, ja oder nein“, hätte ein einfacher Boolescher Wert völlig ausgereicht. Stattdessen wählte React etwas anderes.
Warum wäre also eine Zahl die natürliche Wahl, um anzuzeigen, dass „diese Faser Aufgaben hat“?
Was eine Bitmaske eigentlich ist
React stellt eine Gruppe von Konstanten bereit, die genau zu diesem Zweck erstellt wurden, und bei genauer Betrachtung wird die Antwort sofort klar.
SyncLane = 1 = 00000001
InputContinuousLane = 4 = 00000100
DefaultLane = 16 = 00010000
TransitionLane = 64 = 01000000
In Binärform enthält jede Konstante genau einen 1, und dieser 1 befindet sich bei jeder Konstante an einer anderen Position. SyncLane belegt den allerersten Platz; DefaultLane den fünften. Keine der beiden Konstanten liegt jemals an derselben Position. Diese Anordnung wird als Bitmaske bezeichnet – eine Zahl, deren numerischer Wert keine Bedeutung hat, sondern die stattdessen wie eine Reihe unabhängiger Ein/Aus-Schalter fungiert, jeweils ein Schalter pro Bit, alle zusammen in einem einzigen Feld gespeichert.
Wenn die Fibers des Counter den Wert lanes: 16 anzeigt, bedeutet das nicht „diese Aktualisierung ist sechzehn Einheiten wert“. Es bedeutet vielmehr, der Bit, der DefaultLane zugeordnet ist, ist eingeschaltet, während alle anderen Bits ausgeschaltet bleiben. Die Zahl dient als Speichercontainer für Flags, nicht als zu messende Menge.
Was passiert, wenn eine einzelne Fiber gleichzeitig mehr als ein solches Flag verfolgen muss?
Zusammenführen von zwei Kanälen mit einer einzigen Operation
Stellen Sie sich eine Fiber vor, die im selben Moment zwei verschiedene Arten von ausstehenden Aufgaben verarbeitet – eine normale Aktualisierung sowie eine, die absichtlich einer niedrigeren Priorität zugeordnet wurde. React fügt die beiden Bitmasks mithilfe einer bitweisen OR-Operation zusammen: Dabei werden die Zahlen Bit für Bit verglichen, und ein Bit im Ergebnis wird auf 1 gesetzt, sobald es in einer der Quelzenahlen bereits auf 1 steht.
00010000 (16, DefaultLane)
| 01000000 (64, TransitionLane)
-----------
01010000 (80)
Weil DefaultLane und TransitionLane jeweils eine Bitposition besitzen, die von keinem anderen Lane berührt wird, führt das OR-Operieren beider Werte einfach dazu, dass beide 1-Werte im Ergebnis erhalten bleiben, ohne dass einer den anderen überschreibt. Das Ergebnis 80 ist der neue Wert von lanes, und wenn man es wieder in Binärform umwandelt, sind beide ursprünglichen Bits genau so erhalten geblieben wie zuvor.
Was verrät die Zahl 80 an sich eigentlich?
Warum allein die Zahl 80 nichts aussagt
Auf sich allein genommen verrät 80 nichts. Solche Werte liest man nicht wie normale Zahlen, sondern prüft sie Bit für Bit mithilfe einer bitweisen AND-Operation – eine Operation, die nur dann einen 1 in einer Position hinterlässt, wenn beide verglichenen Zahlen bereits einen 1 an dieser Stelle haben.
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)
Man prüft niemals den Wert einer Spur für sich allein. Stattdessen stellt man eine spezifische Frage zu einer bestimmten Spur, und die Antwort liegt entweder bei null oder bei einem nicht-null Wert vor. Genau das geschah im Hintergrund in der zuvor erläuterten Logik zum Durchlaufen des Baums: Jedes Mal, wenn geprüft wurde, ob ein Ast weiterverfolgt werden sollte, indem die childLanes einer Faser untersucht wurden, fand diese gleiche AND-Prüfung gegenüber der jeweils aktuell gesuchten Spur statt.
Zurück zur ursprünglichen Frage: Warum wird das überhaupt als Zahl dargestellt, anstatt als einfaches boolesches Flag?
Warum nicht einfach ein Boolean verwenden?
Ein Boolean kann genau eine Art von Frage beantworten: Gibt es etwas Unabgeschlossenes – ja oder nein. Es bietet keinen Raum, um auszudrücken „Es wartet eine normale Aktualisierung, und zusätzlich wartet noch eine mit geringerer Priorität“, denn ein Boolean trägt nur immer einen einzigen Bit an Information. React muss tatsächlich gleichzeitig mehrere unterschiedliche Kategorien von ausstehenden Aufgaben auf einer bestimmten Fiber verfolgen und in der Lage sein, jede einzelne davon zu überprüfen, ohne den Zustand der anderen zu beeinflussen.
Eine Bitmaske erfüllt beide Anforderungen gleichzeitig. Ein einzelnes numerisches Feld kann mehrere Flags parallel speichern, da jedes Flag seinen eigenen Bitplatz einnimmt. Zudem ist das Prüfen eines Flags oder die Kombination mehrerer Flags eine einfache, kostengünstige CPU-Operation – entweder ein AND- oder OR-Operator – anstatt etwas, das das Durchlaufen einer Datenstruktur oder das Halten einer separaten booleschen Variablen für jedes mögliche Prioritätsniveau erfordert. Genau deshalb wird lanes als Integer gespeichert und nicht als true oder false: Es wird nicht festgehalten, ob Arbeit vorhanden ist, sondern genau welche Arten von Arbeit gleichzeitig und nahezu ohne Kosten vorhanden sind.
Was bisher noch nicht erörtert wurde, ist, warum eine Aktualisierung überhaupt als niedrigere Priorität behandelt werden muss.
Warum Updates überhaupt eine Priorität benötigen
Betrachten Sie eine Sucheingabe, die als kontrolliertes Komponente konstruiert ist, bei der jede Tastenbetätigung eine Liste von zehntausend Einträgen erneut filtert – wobei das Filtern direkt im Funktionskörper der Komponente stattfindet.
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>
);
}
Man gibt einen einzigen Zeichen ein. Daraufhin wird handleChange aufgerufen, dieser ruft wiederum setQuery auf, wodurch eine erneute Darstellung von SearchableList angefordert wird. Wenn React schließlich diese erneute Darstellung verarbeitet, ruft es die Funktion von SearchableList als eine durchgehende, synchrone Aufrufkette von Anfang bis Ende auf. Irgendwo innerhalb dieses einzigen Aufrufs muss .filter() alle zehntausend Elemente durchlaufen, bevor die Funktion überhaupt ihren neuen JSX-Code zurückgeben kann.
Ein laufender Funktionsaufruf kann nicht mitten in seinem eigenen Ablauf unterbrochen werden. Die Unterbrechbarkeit, die Fibers bieten, wirkt an der Grenze zwischen Fibern – nicht während der Zeile für Zeile stattfindenden Ausführung einer noch laufenden Funktion. Deshalb hat React keine Möglichkeit, den aktualisierten Eingabewert anzuzeigen, etwas zu speichern oder Pixel auf dem Bildschirm darzustellen, bis dieser eine Funktionsaufruf, einschließlich des Filterns, abgeschlossen ist. Der nächste Tastendruck wird hinter ihm in der Warteschlange platziert – nicht weil zwei separate Aktualisierungen miteinander konkurrieren, sondern weil beide Aufgaben in der Ausgabe desselben einzigen Aufrufs enthalten sind.
Die Lösung besteht darin, dies in zwei wirklich unabhängige Aktualisierungen aufzuteilen, von denen jede ihr eigenes Prioritätsniveau haben kann.
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 und setDeferredQuery existieren nun als zwei separate Aufrufe, wobei jeder seine eigene Aktualisierung auslöst und mit seiner eigenen Lane versehen ist. Die Ausgabe, die von setQuery stammt, ist kostengünstig, da kein Filtern erforderlich ist; daher wird sie schnell durchgeführt und das Eingabefeld bleibt bei der Eingabe reaktiv. Die Ausgabe, die von setDeferredQuery stammt, führt tatsächlich .filter() aus und enthält eine TransitionLane, da sie in startTransition eingebettet ist – dem Hook, der dieses Muster der verzögerten Aktualisierung ermöglicht. Wenn Sie während dieser langsamen Filterung noch einen weiteren Zeichen eingeben, prüft React die lanes, findet eine mit höherer Priorität wartende Aktualisierung und lässt die unvollendete, weniger wichtige Aufgabe zurück, um zunächst die Tasteneingabe zu verarbeiten. Dies funktioniert nur, weil die beiden Aktualisierungen nun unabhängig voneinander verfolgt werden.
Eine berechtigte Frage an dieser Stelle ist, ob von Entwicklern erwartet wird, diese Prioritätswerte manuell, nach und nach, zuzuweisen.
Priorität ist größtenteils automatisch
In den meisten Fällen nicht. React wählt in der Regel einen Weg basierend darauf, was ursprünglich die Aktualisierung ausgelöst hat, und nicht weil jemand bewusst einen bestimmten Weg gewählt hat. Diese Entscheidung erfolgt innerhalb von Reacts Ereignissystem, das genau weiß, um welche Art von Browserereignis es sich im Moment des Ausführens von setState handelt.
Ungefähr vom dringendsten bis zum am wenigsten dringenden geordnet:
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)
Die Aufrufung von setState innerhalb eines Klick-Handlers wird automatisch an die Spitze dieser Rangliste gesetzt. Der Aufruf aus einem Scroll-Handler versetzt sie eine Stufe tiefer, da jede durch Scrollen ausgelöste Aktualisierung als höchste Priorität behandelt würde – potenziell Dutzende Male pro Sekunde – was wiederum zu Verzögerungen führen würde. Eine durch eine abgeschlossene Promise oder einen Timeout ausgelöste Aktualisierung, bei der kein umgebender Kontext zur Verfügung steht, verwendet standardmäßig DefaultLane. Die einzige manuelle Kontrolle, die Entwickler tatsächlich nutzen können, ist startTransition, und ihr Anwendungsbereich ist begrenzt: Entweder erfolgt die Aktualisierung normal und React bestimmt ihre Priorität aus dem Kontext wie bei allen anderen Fällen, oder sie wird eingebettet, wodurch sie unabhängig vom eigentlichen Auslöser in TransitionLane gelangt. useDeferredValue ist im Grunde ein leichtgewichtiger Wrapper, der auf demselben Mechanismus basiert. Niemand setzt
fiber.lanes = 64 direkt – und es gibt niemals einen Grund dafür. Eine Einschränkung, die vor Abschluss erwähnt werden sollte: Die eigentliche Codebasis von React definiert etwas in der Größenordnung von dreißig verschiedenen Lane-Konstanten, nicht nur vier. Viele davon existieren ausschließlich zur internen Verwaltung und repräsentieren keine wirklich neuen Prioritätsstufen, die nützlich wären, um mehrere Übergänge unabhängig voneinander zu verfolgen, anstatt sie in einen gemeinsamen, nicht differenzierten Bereich zusammenzufassen. Die oben beschriebenen fünf Ebenen sind diejenigen, die man sich als mentales Modell merken sollte. Alles Weitere sind Implementierungsdetails, die unter einer bereits verstandenen Struktur liegen.
Vier Bestandteile, ein Mechanismus
Zusammenfassend zeigt sich hier, was bei einer erneuten Darstellung von Anfang bis Ende tatsächlich geschieht. Der Zustand wird in einer Fiber gespeichert, weil die Fiber das Objekt ist, das länger besteht als eine einzelne Render-Passung – im Gegensatz zur Komponentenfunktion, die zwischen den Aufrufen alles vergisst, oder zum Element, das jedes Mal weggeworfen und neu erstellt wird. Eine Fiber besteht aus zwei miteinander verbundenen Versionen, der aktuellen und der noch in Bearbeitung befindlichen, die durch den alternate-Pointer miteinander verbunden sind, da man für jeden Vergleich tatsächlich einen vorherigen und nachfolgenden Schnappschuss nebeneinander benötigt. Der Aufruf von setState berührt niemals direkt eine der Fibers. Stattdessen wird eine Notiz in einer Warteschlange abgelegt und die eigentliche Arbeit aufgeschoben, während eine markierte Gruppe von Pisten dem späteren Durchlaufen des Baums ermöglicht zu erkennen, welche Äste tatsächlich untersucht werden sollten. Und dieser Marker war niemals einfach
Vier unterschiedliche Konzepte, die sich jedoch zu einem einzigen Mechanismus zusammenfassen lassen. Jedes wird erst vollständig verständlich, nachdem man das vorherige bereits begriffen hat. Isoliert betrachtet reduziert sich jeder dieser Aspekte auf einen Fakt, den man auswendig lernen kann, ohne die eigentlichen Begründungen dafür zu kennen.