Startseite / Artikel

Warum setState niemals sofort neu rendernt: Reacts Aktualisierungsplanung

Verfolgen Sie einen React-Zustandsupdate vom in der Warteschlange befindlichen Update-Objekt über die Markierung und Planung der Arbeitsabläufe bis zum root-first-Arbeitszyklus, der unbeeinflusste Unterbäume überspringt.

Verlag: REACTAPP.TOP8 Min. Lesezeit1762 Wörter

Quellen: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState

Fast jeder React-Entwickler geht stillschweigend davon aus, dass der Aufruf eines State-Setters wie setCount(count + 1) die Komponente direkt dort erneut ausführt, noch bevor die nächste Zeile ausgeführt wird. Das ist jedoch nicht der Fall – der Zeitraum zwischen dem Aufruf und der erneuten Darstellung erklärt das Batching, warum unverwandte Komponenten unberührt bleiben und warum das Lesen des States unmittelbar nach seiner Änderung den alten Wert zurückgibt. Dieser Artikel verfolgt einen einzigen Update- Vorgang durch Reacts Fiber-Architektur, von dem Moment an, in dem er aufgezeichnet wird, bis zum Zeitpunkt, in dem der neue Wert im State der Komponente landet, damit Sie verstehen können, was Ihre Setter tatsächlich bewirken.

Die Komponente unter dem Mikroskop

Ein minimaler Zähler reicht aus, um den gesamten Mechanismus sichtbar zu machen. Er speichert einen Zustandswert und erhöht ihn bei Klick.

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Der Aufruf setCount(count + 1) liest sich wie eine gewöhnliche synchrone Funktion, weshalb es natürlich erscheint, dass React als direkte Folge dieser Zeile erneut Counter() aufruft. So etwas geschieht jedoch nicht. Der Setter gibt ohne jegliche Darstellung zurück. Stattdessen wird eine Anfrage protokolliert und um die Ausführung der Aufgabe zu einem späteren Zeitpunkt gebeten.

Schritt eins: Der Setter protokolliert eine Aktualisierung

Wenn setCount(1) ausgeführt wird, reserviert React ein kleines Aktualisierungsobjekt im Speicher, das die Anfrage beschreibt. Konzeptionell sieht es so aus:

{
  action: 1,
  next: null,
}

Das Feld action enthält den Wert, den Sie übergeben haben – hier der Wert 1. Das Feld next existiert, weil eine einzige Aktualisierung selten die gesamte Geschichte darstellt. Rufen Sie den Setter dreimal innerhalb eines einzigen Handlers auf, und React erstellt drei Aktualisierungsobjekte, die durch next miteinander verbunden werden und so eine kleine Liste bilden, die an das Komponente gebunden ist. Im eigentlichen Quellcode von React enthält die Aktualisierung außerdem eine Priorität sowie wird die Warteliste als zyklische Struktur gespeichert – doch die grundlegende Idee ist eine Warteschlange von Aktionen, nicht ein erneutes Rendern.

In einem vereinfachten Modell der Fiber wird die Aktualisierung wie folgt angehängt:

{
  type: Counter,
  memoizedState: { count: 0 },   // still 0, untouched
  updateQueue: { action: 1, next: null },   // the note, just added
}

Achten Sie darauf, was sich nicht geändert hat. Der gespeicherte Zustand lautet weiterhin 0, der Bildschirm zeigt weiterhin 0, und Counter() wurde nicht aufgerufen. Der einzige Unterschied ist, dass nun ein Objekt in einer Warteschlange wartet. (Genauer gesagt: Mit Hooks speichert React die Warteschlange jedes Hooks in dem Eintrag dieses Hooks in der Hook-Liste des Fiber, anstatt sie direkt in einem Feld namens updateQueue zu speichern. memoizedState bei einer Funktionskomponenten-Fiber ist genau diese Hook-Liste. Das oben dargestellte vereinfachte Modell dient der Veranschaulichung und bewahrt das hier wichtige Verhalten.)

Dies erklärt auch eine klassische Überraschung: Wenn Sie count unmittelbar nach dem Aufruf von setCount ausgeben, sehen Sie den alten Wert, weil die Komponentenfunktion, die count referenziert hat, noch nicht erneut ausgeführt wurde.

Schritt zwei: scheduleUpdateOnFiber markiert einen Pfad und beantragt Arbeiten

Sofort nachdem die Aktualisierung in der Warteschlange platziert wurde, ruft React eine interne Funktion namens scheduleUpdateOnFiber auf. Sie hat zwei getrennte Aufgaben, und ihre Trennung macht den weiteren Ablauf leichter nachvollziehbar.

Markierung des Pfads zur Wurzel

Zunächst geht React von der Counter-Fiber aus nach oben, durch jeden Elternzeiger, bis er zur Wurzel des Baums gelangt. An jedem Vorgänger entlang dieses Weges setzt er ein Feld mit dem Namen childLanes, das im Grunde bedeutet „Etwas unter mir hat ausstehende Arbeiten“. Die Counter-Fiber selbst erhält ein entsprechendes Feld, lanes, was „Ich habe selbst ausstehende Arbeiten“ bedeutet.

Diese Felder sind keine einfachen Booleschen Werte. Es handelt sich um Bitmasken, die die Priorität kodieren – ein eigenständiges Thema; siehe wie React-Lanes Bitmasken nutzen, um die Aktualisierungspriorität zu kodieren. Für diese Übersicht reicht es aus zu wissen, dass sie von leer auf nicht-leer wechseln und so eine Spur vom aktualisierten Komponenten bis nach oben hinterlassen.

Die tatsächliche Darstellung verschieben

Zweitens bittet React darum, dass die Renderarbeiten kurzfristig ausgeführt werden, und nicht innerhalb des Event-Handlers. Das genaue Verfahren hängt von der Priorität ab. Arbeiten, die vom React-Scheduler bearbeitet werden, werden in der Regel über MessageChannel eingereicht (mit setTimeout als Ersatz in Umgebungen, die dieses nicht bieten), sodass sie bei einem späteren Schritt des Event-Loops ausgeführt werden. Bei diskreten, hochpriorisierten Ereignissen wie Klicks leeren moderne React-Versionen die Arbeit in der Regel sofort nach Abschluss des Handlers als Microtask, was ebenfalls nach Ihrem Code erfolgt, aber schneller. Die genaue Strategie hat sich zwischen den Versionen geändert, daher sollten Sie die Details als versionsspezifisch betrachten.

Egal welcher Kanal verwendet wird, die Folge ist dieselbe: Die Darstellung wird absichtlich aufgeschoben, bis Ihr Handler zurückgekehrt ist. Das macht das Batching möglich. Drei Aufrufe von setCount in einem Handler erzeugen drei in der Warteschlange stehende Aktualisierungen, und eine einzige spätere Darstellungsphase verarbeitet sie alle zusammen anstelle von drei separaten Darstellungen.

Schritt drei: Die Darstellung beginnt immer am Wurzelelement

Wenn die geplante Arbeit schließlich ausgeführt wird, beginnt sie nicht bei Counter. Sie beginnt bei der Wurzelfaser, dem speziellen Knoten über Ihrer gesamten Anwendung, der erstellt wurde, als Sie ReactDOM.createRoot aufgerufen haben. Das geschieht unabhängig davon, wie tief in dem Baum die Aktualisierung ihren Ursprung hat.

Es klingt teuer, bei der Wurzel zu beginnen, aber das bedeutet nicht, dass jede Node besucht werden muss. Der Pfad der in Schritt zwei gesetzten childLanes ist genau das, was bei der Durchsuchung berücksichtigt wird. Bei jeder Node prüft React, ob im darunterliegenden Unterbaum noch Arbeit ansteht. Wenn nicht, beendet React die Durchsuchung und überspringt den gesamten Unterbaum, ohne in ihn einzudringen. Die Vorfahren auf dem markierten Pfad werden kostengünstig durchlaufen, und nur die Äste, die zu noch ausstehender Arbeit führen, werden untersucht.

Eine Schleife statt Rekursion

Die Durchsuchung wird als Schleife und nicht als tiefe Rekursion implementiert:

let node = rootFiber;
while (node !== null) {
  node = processNode(node);   // moves to node.child, node.sibling, or up via node.return
}

Jede Faser enthält drei Zeiger: child für ihr erstes Kind, sibling für die nächste Faser auf derselben Ebene und return für ihren Elternteil. Diese drei Verbindungen reichen aus, um Schritt für Schritt nach unten zu den Kindern zu gelangen, zu den Geschwistern weiterzugehen und bei Beendigung eines Zweigs wieder nach oben zurückzukehren. Es gibt kein Array mit Kindern, das durchlaufen werden müsste. Da die Position im Baum in einer Variablen gespeichert wird und nicht auf dem Aufrufstapel, kann React zwischen Arbeitseinheiten pausieren und später wieder fortsetzen – das ist die Grundlage der koncurrenten Darstellung.

Warum unverwandte Komponenten unberührt bleiben

Dies ist der Mechanismus hinter einem Effekt, den Sie wahrscheinlich bereits beobachtet haben. Das Eingeben in ein Feld aktualisiert nur den Teil des Baums, der diesen Zustand enthält. Ein entfernter, nicht verwandter Komponente wird nicht neu gerendert und auch nicht besucht, da keine Aktualisierung seinen Zweig markiert hat. Dieses Überspringen ist keine nachträglich hinzugefügte spezielle Optimierung – es ergibt sich direkt aus der Verfolgung des childLanes-Pfades.

Halten Sie die Grenzen dieses Prinzips im Hinterkopf. Eine Komponente auf dem markierten Pfad, die selbst noch ausstehende Aufgaben hat, wird neu gerendert – standardmäßig werden auch alle ihre Nachkommen, egal ob markiert oder nicht, neu gerendert, sofern sie nicht gememorisert sind. Der Pfad weist React an, wohin es gehen soll; er verhindert jedoch nicht, dass ein neu gerendeter Elternteil seine Kinder erneut rendernt.

Schritt vier: Ankunft bei Counter und Verarbeitung der Warteschlange

Wenn der Walk den Counter erreicht, übernimmt das Doppel-Puffer-Mechanismus. React prüft den alternate-Zeiger der aktuellen Fiber und verwendet entweder die bereits vorhandene Work-in-Progress-Fiber oder erstellt eine neue. In dieser Work-in-Progress-Kopie wird der neue Zustand berechnet.

Nun wird die Warteschlange endlich gelesen. React verarbeitet den ausstehenden Update, wendet die Aktion auf den vorherigen Zustand an und schreibt das Ergebnis in memoizedState der laufenden Fiber. In diesem Schritt wird tatsächlich Counter() aufgerufen, und useState gibt den neuen Wert zurück. Wenn mehrere Updates in der Warteschlange sind, werden sie in der Reihenfolge angewendet; funktionale Updates wie setCount(c => c + 1) erhalten jeweils das Ergebnis des vorherigen Updates, weshalb sie bei Gruppierung korrekt funktionieren, während setCount(count + 1) dreimal nur einen Zuwachs ergibt.

// work-in-progress fiber, right after processing the queue
{
  type: Counter,
  memoizedState: { count: 1 },   // finally updated, using the queued action
  updateQueue: null,             // consumed, nothing left pending
  alternate: Fiber A,
}

Der Warteschlange ist nun nichts mehr enthalten, und der neue Zustand befindet sich im Arbeitsschleifen-Fiber. Drei verschiedene Mechanismen haben zusammengewirkt: der in Schritt eins eingereichte Update-Vorgang, der markierte Pfad sowie das Root-First-Walk-Verfahren in den Schritten zwei und drei, sowie das aktuelle Arbeitsschleifen-Fiber-Paar, das die vorherige und nachfolgende Version speichert. Für eine ausführlichere Darstellung, die auch die Abstimmungsschritte sowie den DOM-Commit umfasst, siehe die Dokumentation zur Verfolgung eines React setState-Aufrufs von der Update-Warteschlange bis zum DOM-Commit.

Die vollständige Abfolge

Wenn man alle Schritte nacheinander betrachtet, durchläuft ein einzelner Klick folgende Phasen:

  • Der Klick-Handler ruft setCount auf, wodurch React ein Update-Objekt erstellt und es in die Warteschlange der Komponente hinzufügt.
  • scheduleUpdateOnFiber setzt lanes auf Counter sowie childLanes auf jeden Vorgänger bis zur Wurzel.
  • Die gleiche Funktion beantragt einen Render-Vorgang, der nach dem Rückruf des Handlers ausgeführt wird.
  • Der Render-Vorgang beginnt bei der Wurzelfaser und bewegt sich über child, sibling und return, wobei Unterbäume ohne ausstehende Lanes übersprungen werden.
  • Auf Counter bereitet React die in Bearbeitung befindliche Faser vor, verarbeitet die angestauten Updates und berechnet den neuen Zustand.
  • In der Commit-Phase werden die DOM-Änderungen angewendet, und der in Bearbeitung befindliche Baum wird zum neuen aktuellen Baum.
  • Nichts davon geschieht auf der Zeile, an der Sie den Setter aufgerufen haben. Alles läuft danach ab, in einer Struktur, deren Felder speziell dazu dienen, diesen verzögerten, wiederaufnahmefähigen Prozess zu unterstützen.

    Haupterkenntnisse

    • Ein State-Setter stellt einen Update in die Warteschlange und plant die Ausführung; er rendernt niemals synchron innerhalb des Handlers.
    • Das Lesen des States unmittelbar nach seiner Änderung gibt den alten Wert zurück, da die Komponente noch nicht erneut ausgeführt wurde.
    • Batching ist eine natürliche Folge der Verzögerung: Jeder vor dem Render-Vorgang in die Warteschlange gegebene Update wird auf einmal verarbeitet.
    • lanes und childLanes markieren den Pfad von der aktualisierten Fiber bis zur Wurzel, wodurch React unberührte Unterbäume überspringen kann.
    • Der Render-Loop verwendet child, sibling und return-Zeiger anstelle von Rekursion, was es React ermöglicht, die Ausführung zu pausieren und wieder aufzunehmen.
    • Eine neu gerenderte Komponente rendernt ihre Kinder standardmäßig erneut; der Lane-Trail begrenzt lediglich, wo React nachschaut, nicht jedoch, was ein Elternteil rendernt.