Nachverfolgen eines React setState Aufrufs von der Update-Queue bis zur DOM-Bearbeitung
Verfolgen Sie einen React-Zustumsatz Schritt für Schritt durch die Hook-Update-Warteschlange, den Scheduler, die Render-Phase, die Abgleichung und das Commit – und erkennen Sie, warum sich der Zustand niemals sofort ändert.
Fast jeder React-Entwickler schreibt irgendwann einen State-Setter, gefolgt von einem console.log, und ist überrascht, den alten Wert ausgegeben zu sehen. Der Name setState lässt auf eine sofortige Zuweisung schließen, doch das ist nicht das, was React tut: Der Aufruf eines Setters erfasst lediglich einen Antrag auf einen zukünftigen Zustand, und es läuft ein ganzes Verarbeitungssystem ab, bevor etwas auf dem Bildschirm angezeigt wird. Dieser Artikel verfolgt einen einzigen Update- Vorgang durch dieses System – von der Warteschlange des Hooks über die Planung, das Rendering, die Abgleichung bis hin zur Commit-Phase. Sobald man sich jede dieser Stufen vorstellen kann, werden mehrere anfangs seltsam erscheinende Verhaltensweisen vorhersehbar: Warum der Zustand asynchron erscheint, warum mehrere Updates zu einem einzigen Render zusammengefasst werden können, warum React manchmal Arbeit überspringt und warum funktionale Updates existieren.
Der Codeausschnitt, der alle überrascht
Stellen Sie sich eine Code-Review vor, in der das folgende Vorgehen völlig vernünftig erscheint:
setCount(count + 1);
console.log(count);
Es wird erwartet, dass die Konsole den erhöhten Wert anzeigt. Stattdessen wird der vorherige Wert angezeigt. Hier ist die gleiche Situation innerhalb eines vollständigen Komponenten:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count);
};
return (
<button onClick={handleClick}>
{count}
</button>
);
}
Beim ersten Klick erwarten viele Entwickler, Folgendes zu sehen:
1
Was tatsächlich in der Konsole angezeigt wird, ist:
0
Der Grund dafür ist, dass React noch nicht erneut gerendert wurde. Zum Zeitpunkt des Ausführens von console.log wurde React lediglich eine Anfrage übermittelt. Noch nichts aus der Warteschlange wurde angewendet, die Komponentenfunktion wurde nicht erneut aufgerufen und der DOM hat sich nicht geändert. Der Code, den Sie ausführen, gehört zur aktuellen Renderung, und in dieser Renderung ist count eine einfache Konstante, die bereits beim Ausführen der Funktion festgelegt wurde. Nichts kann sie neu zuweisen.
Das ist der erste Wandel im mentalen Modell: Ein Setter verändert nicht die Zustandsvariable, die man liest. Er plant stattdessen Arbeit für React ein, die später ausgeführt wird.
Was speichert React, wenn man einen Setter aufruft
Wenn man schreibt:
setCount(count + 1);
ist es verlockend, sich vorzustellen, dass React intern etwas Ähnliches tut:
count = count + 1
Das tut es nicht. React erstellt ein Update-Objekt und fügt es einer Warteschlange hinzu, die zu diesem spezifischen Hook gehört. Konzeptionell sieht die Situation nach dem Klick so aus:
Current State
|
▼
count = 0
|
▼
User Clicks
|
▼
setCount(1)
|
▼
Update Queue
[ Update: 1 ]
Der Zustand selbst bleibt unverändert. Alles, was React getan hat, ist, sich eine Notiz zu machen: Beim nächsten Mal, wenn Updates für diesen Hook verarbeitet werden, sollte count 1 werden.
Warum Updates über eine Warteschlange laufen
Eine Warteschlange macht Sinn, sobald mehrere Updates kurz nacheinander eintreffen. Betrachten wir einen Handler, der den Setter dreimal aufruft:
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
Jemand, der neu mit React ist, könnte erwarten, dass ein einziger Klick folgendes ergibt:
count = 3
Das tatsächliche Ergebnis ist jedoch:
count = 1
Sämtliche drei Aufrufe wurden während derselben Render-Ausführung vorgenommen, und bei dieser Render-Ausführung wurde Folgendes gesehen:
count = 0
Daher ergibt sich bei jedem count + 1 derselbe Wert, und React erhält drei identische Anfragen:
setCount(1)
setCount(1)
setCount(1)
Die Warteschlange enthält daher drei Einträge, die alle „auf 1 setzen“ besagen:
[1]
[1]
[1]
Auch die Bearbeitung in dieser Reihenfolge endet weiterhin damit:
1
und nicht damit:
3
Die gleiche Logik gilt auch, wenn die Werte unterschiedlich sind. Angenommen, eine Render-Ausführung führt diese drei Aufrufe aus:
setCount(1)
setCount(6)
setCount(4)
Dann enthält die Warteschlange:
[1]
[6]
[4]
Jeder Eintrag ersetzt den vorherigen Zustand vollständig, sodass nach der Verarbeitung nur noch der letzte zählt und das Ergebnis 4 ist. Einfache Werte sind lediglich Ersatzwerte, keine Anweisungen, auf dem Vorherigen aufzubauen. Genau diese Einschränkung ist der Grund für die Existenz funktionaler Updates.
Funktionale Updates berechnen vom neuesten Zustand aus
Ändern Sie nun den Handler so, dass jede Aufruf eine Funktion überlässt:
const handleClick = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
Dieses Mal enthält die Warteschlange eher drei kleine Programme als drei Werte:
prev => prev + 1
prev => prev + 1
prev => prev + 1
Wenn React die Warteschlange verarbeitet, gibt es die Ausgabe jeder Funktion an die nächste weiter:
0 → 1
1 → 2
2 → 3
und der endgültige Zustand ist:
count = 3
Der Unterschied besteht darin, dass eine Aktualisierungsfunktion keinen Wert aus der aktuellen Darstellung abruft. Sie beschreibt, wie vom Zustand, den React zum Zeitpunkt der Verarbeitung dieser Einträge hat, der nächste Zustand abgeleitet werden kann. Verwenden Sie diese Form immer dann, wenn der neue Zustand vom vorherigen abhängt – insbesondere wenn mehrere Aktualisierungen zusammen in der Warteschlange sein können oder die Aktualisierung innerhalb einer während einer früheren Darstellung erstellten Callback-Funktion stattfindet.
Der gesamte Lebenszyklus einer Aktualisierung
Unter Berücksichtigung der Warteschlange verfolgen Sie einen einzelnen Klick, der Folgendes aufruft:
setCount(count + 1);
Eine vereinfachte Darstellung dessen, was danach geschieht:
User Click
|
▼
Create Update
|
▼
Place Update Into Queue
|
▼
Notify React Scheduler
|
▼
Schedule Render
|
▼
Render Phase
|
▼
Reconciliation
|
▼
Commit Phase
|
▼
DOM Updated
Die folgenden Abschnitte erläutern jede Phase im Detail.
Schritt 1: Es wird ein Aktualisierungsprotokoll erstellt
Der Aufruf von setCount(1) führt niemals direkt zu einer erneuten Darstellung:
setCount(1);
React erstellt ein Aktualisierungsprotokoll, das man sich wie eine Notiz vorstellen kann, die besagt:
Apply this update later.
Diese Eintragung ist mit dem internen Zustand des Hooks verknüpft. Die Hook-Daten befinden sich zusammen mit der Fiber des Komponenten – dem internen Knoten, den React für jede Komponenteninstanz speichert – sowie mit der Aktualisierungsqueue dort:
Fiber
|
└── useState
|
├── Current State
└── Update Queue
Deshalb müssen Hooks bei jeder Renderung auch in derselben Reihenfolge aufgerufen werden: React findet den Zustand und die Queue jedes Hooks anhand seiner Position in dieser Liste.
Schritt 2: React plant die Ausführung
Sobald eine Aktualisierung vorliegt, muss React entscheiden, wann sie verarbeitet werden soll. Das ist die Aufgabe des Planers. React rendernt nicht unbedingt sofort, sobald ein Setter aufgerufen wird; es abwägt Reaktionsfähigkeit und Vermeidung unnötiger Arbeit.
Stellen Sie sich vor, ein Benutzer tippt schnell in ein Feld:
A
AB
ABC
ABCD
ABCDE
Das synchronische Rendern teurer UI-Bereiche nach jedem Tastendruck, ohne Möglichkeit zur Priorisierung, würde dazu führen, dass komplexe Benutzeroberflächen träge wirken. Durch das Planen ermöglicht React es, Updates, die gleichzeitig eintreffen, zusammenzufassen und mithilfe von konkurrierenden Funktionen wie Übergängen dringende Updates – beispielsweise die Eingabe selbst – anders zu behandeln als weniger dringende Updates wie eine gefilterte Ergebnisliste. Diese Fähigkeit ist ein wesentlicher Grund dafür, warum sich Reacts Architektur von sofortigen zu geplanten Updates entwickelt hat.
Schritt 3: In der Render-Phase wird die Komponente erneut ausgeführt
Wenn React entscheidet, dass es Zeit ist, ausstehende Updates zu verarbeiten, startet es einen neuen Render. Mit Rendern ist im Grunde nur das erneute Aufrufen der Komponentenfunktion gemeint:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Der wichtige Punkt ist, was useState während dieses Aufrufs tut. Bevor ein Wert zurückgegeben wird, verarbeitet es die in der Warteschlange befindlichen Aktualisierungen im Vergleich zum vorherigen Zustand. Beginnend mit:
Previous State = 0
React durchläuft die Warteschlange:
Queue:
[ +1 ]Process QueueResult:
1
und der Wert, den useState nun zurückgibt, ist:
count = 1
den die Komponente für diese Darstellung verwendet. Deshalb wird der neue Wert erst in der nächsten Darstellung sichtbar: Er wird dort berechnet, nicht zum Zeitpunkt des Aufrufs des Setters.
Schritt 4: Ein neuer Elementbaum wird erzeugt
Durch Ausführen der Komponente erhält man einen frischen Baum aus React-Elementen. Im Vergleich zur vorherigen Darstellung sieht er so aus:
Previous Render
<h1>0</h1>
New Render
<h1>1</h1>
Der Browser-DOM wurde noch nicht verändert. Alles, was React zu diesem Zeitpunkt besitzt, ist ein aktualisierter Entwurf der gewünschten Benutzeroberfläche.
Schritt 5: Die Abgleichung ermittelt die Unterschiede
Danach vergleicht React das vorherige Ergebnis:
Old Tree
mit dem neuen Ergebnis:
New Tree
um herauszufinden, was sich tatsächlich geändert hat. In diesem Beispiel vorher:
Before:
<h1>0</h1>
und danach:
After:
<h1>1</h1>
Nur der Text innerhalb des Überschriftenblocks unterscheidet sich, daher ist das die einzige Änderung, die React erfasst. Dieser Vergleich ist es, was unter dem Begriff Reconciliation verstanden wird. Um genauer zu sehen, wie React entscheidet, was beibehalten und was ersetzt werden soll, lesen Sie ein mentalen Modell für React Reconciliation, State und Hooks erstellen.
Schritt 6: Die Commit-Phase beeinflusst den DOM
Sobald die Liste der Änderungen bekannt ist, tritt React in die Commit-Phase ein und wendet sie auf den tatsächlichen DOM an:
DOM Before
<h1>0</h1>
DOM After
<h1>1</h1>
Nur jetzt sieht der Benutzer die neue Zahl. Das ist der Moment, den sich die meisten Menschen vorstellen, wenn sie einen Setter aufrufen – doch es handelt sich dabei um die letzte von mehreren Stufen.
Warum React Updates nicht sofort anwendet
Betrachten Sie einen Handler, der mehrere Zustandswerte gleichzeitig aktualisiert:
setCount(c => c + 1);
setLoading(false);
setUser(data);
Falls jeder Aufruf seine eigene Darstellung auslösen würde, käme es zu folgendem Ergebnis:
Render 1
Render 2
Render 3
Das wären insgesamt drei Darstellungen, davon zwei unnötig, sowie möglicherweise Zwischenseiten mit inkonsistenten Kombinationen des Zustands. Stattdessen gruppiert React die Updates. Die Aufrufe werden in einer Warteschlange angeordnet:
Update
Update
Update
und anschließend gemeinsam verarbeitet:
|
▼Single Render
Durch Batching werden überflüssige Neuzeichnungen vermieden und es wird sichergestellt, dass der Benutzer stets denselben endgültigen Zustand sieht. Das ist der Hauptgrund, warum React das Planen von Updates vorzuziehen scheint anstelle deren sofortiger Ausführung. React kann Arbeit sogar ganz überspringen: Wenn ein Update einen Wert ergibt, der laut Object.is identisch mit dem aktuellen ist, kann React darauf verzichten, die Kinderkomponenten neu zu rendern.
Wie das in der Praxis aussieht
Nehmen wir ein Suchfeld, bei dem jede Tastenbetätigung drei Zustände aktualisiert:
query
results
loading
Es ist verständlich, anzunehmen, dass jeder dieser Setter zu einem separaten Render führt. Eine Profilierung einer solchen Komponente zeigt in der Regel etwas anderes: Updates, die im selben Ereignis zusammen ausgeführt werden, werden gebündelt, sodass die Komponente seltener gerendert wird, als es aus einer naiven Betrachtung des Codes hervorgeht. Reacts Aufgabe besteht nicht nur darin, Updates anzuwenden, sondern diese auch effizient umzusetzen.
Wenn mehrere Komponenten ausstehende Updates haben
Updates beschränken sich nicht auf eine einzige Komponente. Betrachten Sie einen solchen Baum:
App
├── Header
├── Sidebar
└── Dashboard
Falls mehrere Updates in unterschiedlichen Teilen dieses Baums ankommen, muss React nicht alles blind neu erstellen. Der Fiber-Baum ermöglicht es React, Folgendes zu verfolgen:
- welche Komponenten Ausarbeitungsarbeiten haben
- wo in dem Baum jedes Update seinen Ursprung hat
- welche Unterbäume besucht werden müssen und welche übersprungen werden können
Dadurch kann React viel selektiver vorgehen als bei dem Ansatz „die gesamte App bei jeder Änderung neu rendern“. Beachten Sie, dass eine Komponente, die neu gerendert wird, standardmäßig auch ihre Kinder neu rendernt; Memoisierung ermöglicht es, unveränderte Unterbäume zu überspringen.
Der veraltete console.log nochmals betrachten
Zurück zum Auszug am Anfang:
setCount(count + 1);
console.log(count);
Die Konsole gibt aus:
0
Denn der Code läuft noch innerhalb der aktuellen Darstellung. Die Warteschlange wurde noch nicht verarbeitet, die nächste Darstellung hat noch nicht stattgefunden und der Update wartet auf seine Reihe. Eine genaueere Beschreibung lautet:
Current Render
count = 0
Request Update
Future Render
count = 1
Falls Sie den neuen Wert sofort benötigen, berechnen Sie ihn in einer lokalen Variablen und verwenden diese, oder lesen Sie ihn in der nächsten Darstellung oder in einem Effekt ein, der davon abhängt. So betrachtet ist das Verhalten überhaupt nicht seltsam.
Wie die Komponenten miteinander verbunden sind
Insgesamt bilden die einzelnen Schritte eine einzige Kette:
- Der Zustand wird zusammen mit den Hook-Daten einer Komponente gespeichert.
- Die Hook-Daten werden dem Fiber der Komponente zugeordnet.
- Durch Aufruf eines Setters entsteht ein Update.
- Updates werden der Warteschlange des Hooks hinzugefügt.
- Der Scheduler wählt den geeigneten Zeitpunkt, um sie zu verarbeiten.
- In der Darstellungsphase wird die Komponente erneut aufgerufen und die Warteschlange angewendet.
Konzepte, die oft getrennt gelehrt werden, sind in Wirklichkeit aufeinanderfolgende Schritte in einem Prozessfluss. Zusammengefasst: Ein Aufruf eines Setters lässt den Zustand unverändert und bittet stattdessen um einen geplanten Update, das React bei einer späteren Darstellung anwendet, mit dem vorherigen Ausgabezustand vergleicht und nur dort im DOM speichert, wo sich etwas geändert hat.
Wichtige Erkenntnisse
- Ein Setter verändert den Zustand niemals direkt; er stellt einen Update-Vermerk an, der von React später bearbeitet wird.
- Updates werden pro Hook in die Warteschlange gegeben, sodass mehrere davon bei einer einzigen Darstellung bearbeitet werden können.
- Einfache Werte ersetzen den Zustand; Aktualisierungsfunktionen ergeben den nächsten Zustand aus dem aktuellen, wodurch veraltete Werte vermieden werden.
- React plant die Ausführung von Aufgaben anstelle einer sofortigen Darstellung bei jedem Aufruf, was das Batching und die Priorisierung ermöglicht.
- Die Darstellung und Aktualisierung des DOM sind getrennte Schritte: Die Render-Phase erzeugt eine Beschreibung, während die Commit-Phase den Browser ändert.
- Die Update-Warteschlange befindet sich zusammen mit dem Zustand des Hooks im Fiber der Komponente.
Die Behandlung von setState als Anfrage statt als Befehl ist eine kleine Wortänderung mit großen Konsequenzen. Sie ist der Grund, warum Batching, Planung, Abgleich und gleichzeitige Darstellung überhaupt möglich sind. Die natürliche nächste Frage ist, wie React entscheidet, ob wartende Updates zu einer oder mehreren Darstellungen führen – das ist genau das, was das automatische Batching, das in React 18 eingeführt wurde, löst.