Startseite / Artikel / Was React tatsächlich auf dem Hauptthread ausgibt

Was React tatsächlich auf dem Hauptthread ausgibt

Render versus Commit in React: Warum unsichtbare Render-Arbeiten weiterhin mit Eingaben auf dem Hauptthread konkurrieren und was Memoisierung wirklich bewirkt.

1665 Wörter

Ein React „Render“ ändert an sich allein nicht das, was im Browser angezeigt wird. Ein Komponente, die einmal ausgeführt wird, und dieselbe Komponente, die hundertmal ausgeführt wird, können für den Benutzer der App identisch aussehen. Sie sehen identisch aus, weil der Browser nur aktualisiert wird, wenn in der Commit-Phase in den DOM geschrieben wird – und ein alleiniger Render verspricht niemals eine solche Schreiboperation. Warum konzentriert sich dann so viel an Leitfäden zur React-Performance darauf, Renders zu vermeiden – useCallback (die Funktionsidentität über Renders hinweg beibehalten), useMemo (einen berechneten Wert über Renders hinweg beibehalten), React.memo (den Render eines Kindes überspringen, wenn sich die Props nicht geändert haben) sowie das Verkleinern von Zustandsaktualisierungen?

Dieser Widerspruch existiert tatsächlich: Es kann so wirken, als würde man etwas optimieren, das der Benutzer niemals bemerken würde.

Falls ein Render den Browser nie berührt, wofür wird er dann eigentlich verwendet?

Zwei Phasen, die in einer Renderung verborgen sind

Menschen bezeichnen oft den gesamten Aktualisierungszyklus als „Render“. React teilt diesen Zyklus in zwei Phasen mit unterschiedlichen Aufgaben auf. In der einen Phase wird eine Beschreibung der nächsten Benutzeroberfläche erstellt. In der anderen Phase wird entschieden, ob diese Beschreibung zu tatsächlichen DOM-Änderungen werden soll.

Die gleichen vier Auslöser starten beide Phasen: eine Zustandsaktualisierung, eine Änderung einer Eigenschaft, eine erneute Renderung eines Elternteils oder eine Änderung des Kontextwerts (ein Wert der Context API, der ohne Übertragung über Eigenschaften gelesen wird). Was jede Phase danach tut – und was sie kostet –, ist der Punkt, an dem sie auseinandergehen.

Was geschieht in der ersten Phase, also jener, die immer ausgeführt wird, wenn React eine Aktualisierung planen?

Aufschlüsselung der Render-Phase

Wenn React entscheidet, dass ein Update erforderlich ist, ruft es die Komponentenfunktion erneut von oben auf. Jede Anweisung in diesem Körper wird ausgeführt: Berechnungen, Schleifen, in-line geschriebene Objekterstellung.

Dann bewertet React den JSX-Code nach dem return. JSX (<div>...</div>-Syntax) ist kein HTML und erreicht niemals allein den Browser. Während der Kompilierung wandeln Babel oder der TypeScript-Compiler es in Funktionsaufrufe um – ursprünglich React.createElement, heute häufiger der moderne jsx()-Hilfsfunktion. Diese Aufrufe bilden schließlich die Beschreibung der Komponente.

Das Ergebnis wird in der Regel als virtueller DOM bezeichnet; Reacts interner Name dafür ist der Elementbaum. Es handelt sich dabei um ein gewöhnliches JavaScript-Objekt, das die beabsichtigte Benutzeroberfläche beschreibt – weder um echtes HTML noch um einen aktiven DOM-Node.

Betrachten wir eine kleine Komponente:

function Greeting({ name }) {
  const message = `Hello, ${name}`;
  return (
    <div>
      <h3>{message}</h3>
    </div>
  );
}

Eine Änderung am name veranlasst React dazu, Greeting erneut aufzurufen. Der Template-String für message wird erneut ausgeführt. Die kompilierten JSX-Aufrufe erzeugen anschließend einen Objektbaum, der wie folgt aussieht:

{
  "type": "div",
  "props": {
    "children": {
      "type": "h3",
      "props": { "children": "Hello, Akshat" }
    }
  }
}

Das Objekt ist der aktualisierte virtuelle DOM von Greeting. Die Konstruktion bleibt im Speicher – es kommt weder zu einer DOM-Änderung, noch zur Darstellung oder Anordnung. Was verarbeitet diesen Baum anschließend?

Aufschlüsselung der Commit-Phase

React vergleicht den neuen Elementbaum mit dem vorherigen – es findet eine Abgleichung statt, bei der herausgefunden wird, was tatsächlich geändert wurde. Nur die Unterschiede aus diesem Vergleich werden in den echten DOM geschrieben. Wenn sich nichts ändert, wird auch nichts geschrieben.

Kehren wir zu Greeting zurück und nehmen an, name wechselt von einem String zum anderen. Der vorherige Baum enthielt den alten Grußtext in einem h3; der neue Baum enthält den aktualisierten Text. Die Struktur bleibt unverändert – ein div, der ein h3 umschließt – sodass die Abgleichsfunktion nur einen Unterschied festhält: jenen Textknoten. In der Commit-Phase wird nur dieser Text aktualisiert; der Rest des Baums bleibt unberührt.

Das ist die einzige Phase, bei der der eigentliche Browser beteiligt ist, und genau deshalb ist sie auch die einzige Phase, die eine erneute Berechnung der Layout-Geometrie oder ein Neumalen erzwingen kann. Ändern Sie genügend Inhalte, muss der Browser diese Arbeit erneut durchführen. Ändern Sie nichts, dann geschieht das nicht.

Hier ist der entscheidende Fall für den Rest der Argumentation. Wenn sich name bei einer erneuten Auslösung nicht ändert, stimmt der neue Baum mit dem alten überein, die Abgleichung erkennt keine Unterschiede und der Commit ist wirkungslos. Keine Schreibvorgänge im DOM, kein Layout-Update, keine Darstellung – nichts, was die Augen des Benutzers wahrnehmen. Dennoch wurde die Render-Phase – der Funktionsaufruf, die erneute Berechnung von message, der neu allokierte Objektbaum – bereits einen Moment zuvor vollständig ausgeführt.

Falls die Render-Phase abgeschlossen werden kann, ohne sichtbare Spuren zu hinterlassen, welchen Aufwand hat diese Arbeit verursacht?

Die Render-Phase kann ablaufen, ohne etwas zu ändern

Dort wird die Debatte kohärent. Eine Renderung, die dasselbe Ergebnis liefert, verbraucht dennoch echte Ressourcen: eine tatsächliche Funktionsaufruf, echte Zuweisungen für jeden Knoten im Baum sowie echte Abgleichsarbeiten, bei denen der Baum durchlaufen wird, bevor festgestellt wird, dass sich nichts geändert hat. Nichts davon erscheint auf dem Bildschirm. Doch all das hat dennoch stattgefunden.

Dieser Unterschied – die tatsächlich erfolgende Arbeit, die unsichtbar bleibt – ist der Grund dafür, warum sowohl „Renderungen spielen keine Rolle“ als auch „Renderungen sind sehr wichtig“ in Bezug auf unterschiedliche Ebenen richtig sein können. Renderungen bestimmen tatsächlich nicht, was der Benutzer sieht; dafür ist der Commit verantwortlich. Renderungen sind jedoch für etwas anderes wichtig, das nichts mit Pixeln zu tun hat.

Was ist dieses Andere?

Der Hauptthread kümmert sich nicht darum, ob die Arbeit sichtbar ist

Dieses Etwas ist der Hauptthread von JavaScript: eine gemeinsame Warteschlange, die Aufgaben nacheinander ausführt. Derselbe Thread führt Ihren JS-Code aus, berechnet das Layout und leitet Eingabeereignisse weiter – Klicks, Scrollen, Tasteneingaben.

Browser wechseln etwa alle 16,7 Millisekunden zu einem neuen Frame, um 60 Frames pro Sekunde anzuzeigen. Alles für einen bestimmten Frame – Logik in der Renderphase, Abgleich, Speicherung von DOM-Änderungen, Layout-Berechnung, Zeichnen – muss in diesen Zeitrahmen passen, und die Renderphase erhält keinen bevorzugten Zugriff nur deshalb, weil ihre Ausgabe möglicherweise verworfen wird. Sie teilt sich dieselbe Warteschlange, die der Benutzer durch Interaktion wahrnimmt.

Sie seien präzise: Nicht jeder Schritt des Browsers bei der Verarbeitung eines Frames findet auf diesem Thread statt. Die Rasterisierung (Umwandlung von Zeichnungsanweisungen in Pixel) sowie das Komponieren (Zusammensetzen der Schichten zum endgültigen Frame) laufen oft an anderer Stelle, weshalb eine vorab gerasterisierte Schicht weiter scrollen kann, während der Hauptthread beschäftigt ist. Die Render-Phase selbst sowie das Ereignis, das sie auslöst, befinden sich weiterhin auf dem Hauptthread. Getrennte Kompositor-Threads erklären zwar einen gewissen Fluss unter Last, sie beseitigen jedoch nicht die Kosten der Render-Phase.

Ein einziger unnötiger Render kann einen Bruchteil einer Millisekunde kosten. Wann wird das für den Benutzer spürbar?

Warum die Render-Phase dennoch ins Gewicht fällt

Denn sie läuft selten nur einmal. Wenn ein Elternteil erneut gerendert wird, führt React standardmäßig die Render-Phase für jedes Kind durch – auch dann, wenn sich die Props dieses Kindes nicht geändert haben – es sei denn, das Kind ist in React.memo eingebettet.

function Dashboard() {
  const [searchTerm, setSearchTerm] = useState('');
  const products = useProducts(); // 200 items
  return (
      <div>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
        />
        <ProductList products={products} />
      </div>
    );
  }

function ProductList({ products }) {
  return (
    <div>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </div>
  );
}

Geben Sie einen Zeichen in das Suchfeld ein, und searchTerm aktualisiert sich. Es wird erwartet, dass das Dashboard wieder läuft – seine Ausgabe hat sich geändert. ProductList sowie alle 200 ProductRow-Komponenten darunter laufen ebenfalls erneut, nicht weil sich ihre Props geändert hätten (die Tasteneingabe steht in keinem Zusammenhang mit Produktdaten), sondern weil Kinderkomponenten neu gerendert werden, sobald die Elternkomponenten es tun – es sei denn, etwas stoppt diese Kaskade.

Jeder dieser 200 Aufrufe stellt weiterhin eine echte Funktionsausführung, einen echten Elementbaum pro Zeile sowie eine echte Abgleichsprozedur dar, die ergeben hat, dass keine der Zeilen eine DOM-Schreibung benötigt. Eine Tasteneingabe kostet wenig. Ein Live-Suchfeld bei normaler Tippgeschwindigkeit löst diese Abfolge mehrmals pro Sekunde auf derselben Schleife aus, die auch die nächste Tasteneingabe verarbeiten muss.

Dies ist der Anwendungsfall, auf den useMemo, useCallback und React.memo abzielen – was schützen sie also tatsächlich?

Was useMemo, useCallback und React.memo tatsächlich schützen

React.memo umhüllt eine Komponente und überspringt ihre Render-Phase, wenn die neuen Props oberflächenbasiert den vorherigen entsprechen (=== pro Prop). Wenn ProductRow umhüllt wird, erzwingt eine Tasteneingabe im Dashboard nicht mehr 200 Renderaufrufe; React vergleicht die Props einmal pro Zeile und stoppt, sobald sich die product-Referenz nicht geändert hat.

useMemo speichert eine Berechnung zwischen den Renderungen, sodass aufwändige Aufgaben im Komponenteninhalt nur dann erneut ausgeführt werden, wenn sich die aufgelisteten Abhängigkeiten ändern. useCallback tut dasselbe für die Funktionseinheit. Sein Zweck besteht in der Regel weniger darin, die Zuweisung einer Funktion zu sparen, sondern vielmehr darin, ein gememorisierter Kindkomponente zu schützen: Jede neue Funktion bei jedem Render bedeutet einen neuen Referenzwert, und ein neuer Referenzwert unterbricht die oberflächliche Überprüfung von React.memo bezüglich dessen, was diesen Prop erhält.

Aus keiner der drei Funktionen ändert sich die Commit-Phase. Das Commit wurde bereits dadurch blockiert, dass Reconciliation einen tatsächlichen Unterschied feststellte. Wenn ohnehin nichts auf dem Bildschirm angezeigt werden würde, ändern diese Werkzeuge nicht, ob eine DOM-Schreibung stattfindet. Was sie sparen, sind die Berechnungen in der Render-Phase – also die Zeit auf dem Hauptthread, die auch dann verbraucht wird, wenn das Endergebnis identisch ist.

Auch diese sind nicht kostenlos. Die Vergleichsfunktion von React.memo sowie die Cacheabfrage von useMemo verursachen bei jeder Ausführung zusätzliche Kosten, wodurch das Umhüllen eines günstigen, selten genutzten Komponenten letztendlich zu einem Nachteil werden kann: Man zahlt Überhead für Schutzmaßnahmen, die eigentlich nie teuer waren.

Wann spielt das für jemanden, der das Produkt verwendet, eine Rolle?

Die Renderkosten beeinflussen den Thread, den der Benutzer tatsächlich wahrnimmt

Nichts innerhalb der Renderphase ändert einen Pixel von selbst – das galt schon immer. Eine Komponente, die einmal oder hundertmal ausgeführt wird, kann genauso aussehen, denn es ist der Commit und nicht das Renderen, der bestimmt, was auf dem Bildschirm angezeigt wird.

Die Renderung ist immer noch nicht kostenlos, weil sie unsichtbar ist. Es handelt sich dabei um echte Arbeit in derselben eingleisigen Warteschlange wie die Layout-Verarbeitung, die Speicherung von DOM-Änderungen sowie jeder Klick, Scroll und Tastenanschlag. Unnötige Arbeiten in der Renderphase von dieser Warteschlange fernzuhalten – insbesondere dann, wenn eine Aktualisierung eines Elternelements sich aufgabelnd in einer tiefen Unterordnungsliste auswirkt oder beim Tippen und Scrollen schnell weitere Aktualisierungen ausgelöst werden – ist der Weg, um die Millisekunden zurückzugewinnen, die eine Interaktion benötigt.

Die Leute sprechen selten über den stillen Aspekt. Weniger Renderungen waren nie das endgültige Ziel. Das Ziel ist es, genügend vom gemeinsamen Zeitbudget von etwa 16,7 ms für die Speicherungsarbeiten und die Eingabeverarbeitung zu reservieren, damit der Benutzer dies bemerkt.