Einer der Vorteile einer deklarativen UI, der wenig besprochen wird, ist, dass wir Entwickler nicht mehr entscheiden müssen, wann wir Updates am DOM vornehmen. Wir übergeben React einfach ein paar Funktionen (oder Klassen) in der Form von Komponenten, die beschreiben, was gerendert werden soll, und es ist Reacts Aufgabe, diese aufzurufen und die resultierenden Updates auszuführen.
Wie wir im Folgenden sehen werden, eröffnet diese subtile Unterscheidung ungeahnte Möglichkeiten. Da es nun in Reacts Zuständigkeitsbereich liegt, Komponentenfunktion aufzurufen, kann React dies auch zu einem späteren Zeitpunkt tun oder die Ausführung einiger Funktionen priorisieren.
Dies ist eine zentrale Idee der React Philosophie. In Push-Ansätzen werden Updates angestoßen, sobald neue Daten verfügbar sind. Das UI-Framework mag den Datenfluss vielleicht kontrollieren, dass und wann etwas passiert, wird von der Datenseite bestimmt. React hingegen verfolgt einen Pull-Ansatz und bestimmt eigenmächtig, was wann passiert. So kann React entscheiden, dass es wichtiger ist, Updates eines kontrollierten Text-Inputs anzuzeigen als die Daten, die soeben vom Server gekommen sind.
Bis React Version 16 wurde diese Möglichkeit jedoch nicht ausgenutzt. Der komplette Renderzyklus wurde innerhalb eines Funktionsaufrufs im Hauptthread vollzogen, wodurch dieser für die Dauer des Renderns blockiert war und keine anderen Aufgaben abarbeiten konnte, weder neue Updates von React noch andere Funktionsaufrufe. Nach einem Update des States einer Komponente (z.B. durch setState) wird ein Re-render angestoßen. Dieser Prozess basiert auf Reacts Kernalgorithmus, welcher unter Zuhilfenahme einiger Heuristiken aus der Graphentheorie jeden Knoten unserer Anwendung durchläuft, um einen Diff aus unserem alten und neuen Zustandsbaum zu erstellen. Dieser Diff enthält die minimale (zumindest approximativ) Menge von Änderungen, die React am DOM vornehmen muss, um vom alten in den neuen Zustand überzugehen. Dieser Prozess heißt bei React Reconciliation, also Versöhnung des alten States mit dem neuen.
React Fiber ist ein kompletter Rewrite dieses Reconciliation-Algorithmus, dessen Ziel es ist, die Vorteile des oben beschrieben Pull-Ansatzes auszunutzen.
Ein minimales Beispiel
Um das Ganze konkreter zu machen, schauen wir uns im Folgenden ein einfaches Beispiel, den Klick Zähler an (wenn man so will, ist das der Drosophila Melanogaster der React Tutorials):
Wir definieren eine funktionale Komponente, welche einen Button und ein span-Element zurückgibt. Beim Klick auf den Button wird über einen Event Handler der Komponenten State geupdatet und als Resultat wiederum der Text des span-Elementes.
Reacts Reconciliation-Algorithmus muss also beim initialen Rendern und anschließenden Update unserer kleinen Anwendung:
- den clickCount Wert des Komponenten State updaten
- die children des ClickCounter und deren Props abrufen und vergleichen
- die Props des span-Elementes updaten
Es finden noch weitere Buchhaltungsaktivitäten während der Reconciliation statt. So muss zum Beispiel auch der useState hook angelegt und in einer Queue getrackt werden.
Alle Aktivitäten des Fiber-Algorithmus werden in der Fiber-Architektur als “work” bezeichnet, welches sich wiederum in “units of work” runterbrechen lässt.
Doch bevor wir im Detail verstehen können, wie der Fiber-Algorithmus sich diese “units of work” einteilt und was genau mit ihnen geschieht, müssen wir noch eine kurze Exkursion machen und uns die Datenstrukturen, die React intern benutzt, genauer ansehen.
Die Datenstrukturen hinter React-Komponenten
Dieser Datenstruktur kommen wir einen Schritt näher, wenn wir schauen, womit der JSX Compiler die Komponenten, die unsere ClickCounter Komponente zurückgibt, ersetzt. Wir rufen uns in Erinnerung, dass JSX Elemente wie <button></button> nur syntaktischer Zucker für den Aufruf von React.createElement sind:
Die Aufrufe von React.createElement wiederum erstellen eine solche Datenstruktur:
React fügt den Key $$typeof hinzu, um die Objekte als React Elemente zu identifizieren. Die type, key und prop Eigenschaften beschrieben das jeweilige Element und entsprechen den an das React.createElement übergebenen Werten. Das React Element für das ClickCounter Element enthält weder Props noch Keys:
Während der Reconciliation wird jede Komponente des Komponentenbaumes aufgerufen und aus den Objektrepräsentationen der zurückgegebenen Elemente wiederum Fiber-Knoten erstellt und in einer Baumstruktur zusammengeführt. Jedes React Element erhält so einen korrespondieren Fiber-Knoten, welcher anders als die Elemente nicht bei jedem Render neu erstellt werden.
Es sind genau diese Fiber-Knoten, die eine ‘unit of work’ verkapseln. Beim ClickCounter bestünden diese daraus, die Funktion aufzurufen und einen useState-Hook zu allozieren, wobei beim span DOM Veränderungen vorgenommen werden müssen.
Hierin liegt der ganz Trick der Fiber-Architektur: Da wir die Render-Arbeit auf in sich geschlossene units of work herunterbrechen können, können wir diese einfach tracken, schedulen, pausieren oder abbrechen.
Wenn beim initialen Render ein React-Element in einen Fiber-Knoten überführt wird, werden dessen Daten kopiert, der Fiber-Knoten von React anschließend wiederverwendet und nur notwendige Updates vorgenommen, um ihn mit dem korrespondierenden Element synchron zu halten. Zudem können Fiber-Knoten in einer Liste neu geordnet (hierin liegt die Bedeutung des key prop) oder gelöscht werden, falls das entsprechende React-Element bei einem Render nicht mehr zurückgegeben wird.
[Eine Übersicht aller Aktivitäten eines Fiber-Knoten findet sich in der ChildReconciler Funktion.]
Der Fiber-Baum unserer ClickCounter-Anwendung kann wie folgt dargestellt werden:
Alle Fiber-Knoten sind als verkettete Liste dargestellt mit Referenzen auf die jeweiligen child, sibling oder return Knoten.
Der Work-in-Progress Baum
Nach dem initialen Render erstellt React einen Fiber-Baum, der den aktuellen Zustand unserer Anwendung reflektiert. Dieser Baum wird als current bezeichnet. Sobald dieser Zustand sich verändert, wird ein workInProgress Baum angelegt, welcher den neuen, jedoch noch nicht im DOM dargestellten Zustand repräsentiert.
Die in einem Render-Zyklus zu erledigende Arbeit wird nur am workInProgress Baum vorgenommen. React iteriert über den current-Baum und erstellt für jeden Knoten einen Klon im workInProgress Baum. Die Daten dieses neuen Knotens erhält React wiederum durch das Rendern der React-Komponente. Erst wenn alle Komponenten gerendert, und die entsprechenden Updates am workInProgress Baum vorgenommen wurden, wird dieser in das DOM gerendert und der Baum wird zum neuen current.
Ein weiteres Kernprinzip von React ist Konsistenz. Daher wird das Rendern des DOMs als atomare Operation verstanden, welche keine partiellen Updates zulässt. So dient der workInProgress Baum als vorläufiger Entwurf, welcher dem Nutzer unserer Anwendung verborgen bleibt, bis nicht wirklich alle Änderungen verarbeitet wurden. Darin spiegelt sich auch der oben genannte Pull-Ansatz wieder, da neue Daten von React nicht direkt in DOM Updates übersetzt werden, sondern React entscheidet, welche Bedingungen erfüllt sein müssen, bevor ein neuer Zustand bereit ist, um final gerendert zu werden.
Der Work-Loop
Wir klicken nun den Button, rufen setClickCount auf, und die Funktion, die wir übergeben haben, wird einer Update-Queue hinzugefügt, bevor React mit dem Scheduling der units of work beginnt.
Dabei benutzt React die requestIdleCallback API und fragt damit den JavaScript Haupt-Thread “lass mich wissen, wenn du etwas Zeit übrig hast und erledige dann folgende Aufgabe”. Der Haupt-Thread meldet sich zurück mit einer Zeitangabe, die React zur Verfügung steht.
Um den workInProgress Baum aufzubauen und über die einzelnen Fiber-Knoten zu iterieren, benutzt React den Work-Loop. Der Kern dieser Funktion ist denkbar simpel:
React checkt, ob noch units of work abzuarbeiten sind und via shouldYield(), ob noch genügend vom Haupt-Thread zur Verfügung gestellte Zeit bleibt, und arbeitet die nächste unit of work ab, falls beide Bedingungen erfüllt sind.
Nehmen wir an, der Haupt-Thread stellt 10ms zur Verfügung. React beginnt damit den workInProgress Baum aufzubauen:
Zuerst wird der HostRoot-Knoten geklont. Der Pointer zu dem Child-Knoten im current Baum wird dabei mitkopiert. Der ClickCounter-Knoten wird als nächste “unit of work” zurückgeben und der Timer aktualisiert:
Der ClickCounter hat eine UpdateQueue, die ebenfalls geklont wird. React führt das Update aus, ändert den State des ClickCounter-Knotens und versieht ihn mit einem Effect-Tag, welches angibt, das DOM Änderungen vorgenommen werden müssen. Um den Baum weiter traversieren zu können, ruft React die ClickCounter-Funktion mit dem aktualisierten State auf und erhält ein Array von Kinder-Elementen.
Diese werden nun mit den Knoten des current-Baumes verglichen (da wir ein explizites key Prop gesetzt haben, wird dieses für den Vergleich verwendet), um zu entscheiden, ob die Kinder-Knoten übernommen werden können. In unserem Fall haben sich die children nicht verändern und werden geklont:
Die State-Änderungen des Eltern-Knoten führen dazu, dass die children des span-Knotens sich ebenfalls ändern und auch hier ein Effect-Tag hinzugefügt wird. Bei der nächsten Iteration des Work-Loops stellt React fest, dass keine ausreichende Zeit mehr für die nächste unit of work verbleibt, ruft requestIdleCallback erneut mit einem Callback für die übrige Arbeit auf und gibt die Kontrolle wieder an den Haupt-Thread ab.
Dieser kann nun andere Aufgaben aus dem Haupt-Event-Loop abarbeiten. Ist inzwischen beispielsweise eine Animation oder Layout-Änderung eingegangen, können diese nun bearbeitet werden. React blockiert den Haupt-Thread nicht mehr, und die Anwendung bleibt auch nach einem angestoßenen Render-Zyklus responsive.
Ist der Haupt-Thread mit dieser Arbeit fertig, wird Reacts Callback aufgerufen, und das Rendern kann abgeschlossen werden. Dazu rufen die Kinderknoten auf ihren Eltern complete auf und fügen die in dem Effect-Tags vermerkten Änderungen zu denen ihrer Eltern hinzu.
Die finale Liste der Effects hängt nun an der HostRoot, welche als pendingCommit markiert wird, und bietet die Grundlage für die im nächsten Schritt vorgenommenen Änderungen am DOM.
Diese werden, wie bereits beschrieben, als atomer Commit vorgenommen, damit keine UI-Inkonsistenzen auftreten können. Sobald das DOM dem workInProgress Baum entspricht, wird dieser zum aktuellen current Baum. Dazu wird lediglich der current Pointer umgebogen und die bereits vorhandenen Objekte wiederverwendet.
Es soll hier noch erwähnt sein, dass React, da es durch Fiber möglich ist, Rendering zu unterbrechen, unterschiedlichen Updates unterschiedliche Prioritäten zuweisen kann. Es soll geplant sein, diese künftig auch vom Anwender bestimmen zu lassen.
Anwendung: Error Boundaries
Der React Fiber Rewrite bringt eine weitere praktische Neuerung mit sich. Da einzelne Komponente nun als Fiber-Knoten separat aufgerufen werden und nicht mehr innerhalb einer Funktion, welche den Komponentenbaum traversiert, können auch Exceptions gesondert gefangen und behandelt werden.
Dazu stellt React ab Version 16 die Lifecycle-Methoden getDerivedStateFromError() und componentDidCatch() zur Verfügung, mit Hilfe derer sich eine Wrapper-Komponente definieren lässt, welche Exceptions, die zur Laufzeit in den Kinder-Komponenten auftreten, fangen und eine Fallback-UI definieren kann.
Bauen wir beispielsweise einen fiesen Bug in unsere ClickCounter Komponente ein, welcher dazu führt, dass nach dem dritten Klick eine Exception geworfen wird:
Dann können wir mit einer ErrorBoundary Komponente verhindern, dass dadurch unsere gesamte Anwendung crasht:
In unserem Fiber-Baum sitzt nun ein weiterer Knoten zwischen der HostRoot und der ClickCounter Komponente. Wenn der Work-Loop beim Ausführen der Effektliste eine Exception fängt, wird die Funktion captureCommitPhaseError mit dem fehlerverursachenden Fiber-Knoten aufgerufen und so lange über dessen Elternknoten iteriert, bis eine Komponente gefunden wird, die die ErrorBoundary Lifecycle-Methoden definiert.
React Fiber – Fazit
Wir sehen, React Fiber treibt das React-Paradigma einen weiteren Schritt voran und liefert uns Optimierungen, die beim Bau komplexer UIs helfen.
Indem React den Renderprozess in kleinere unabhängige Einheiten aufteilt, ist es möglich, effizienter mit den vom Javascript Haupt-Thread zur Verfügung gestellten Ressourcen umzugehen und ermöglicht uns Entwicklern somit, performantere und responsive Anwendungen zu schreiben. Zudem machen Error-Boundaries unsere React-Anwendungen resilienter und nutzerfreundlicher. Hat man schon eine Zeit lang damit gearbeitet, scheint es ein Relikt aus dunkler Vorzeit, die App bei jedem Laufzeitfehler einfach crashen zu lassen.
Wir können gespannt sein, welche weiteren APIs uns in künftigen Versionen auf Grundlage von Fiber bereitgestellt werden. Je mächtiger und komplexer die Abstraktionen, mit denen wir arbeiten, jedoch werden, desto wichtiger wird es, dass wir verstehen, was sich dahinter verbirgt. Ich hoffe dieser Artikel hat einen ersten solchen Blick hinter den Vorhang geboten.
- React Fiber: Was verbirgt sich hinter dem neuen Kernalgorithmus? - 14. Januar 2021