Performance mit React-Bordmitteln verbessern

Performance mit React-Bordmitteln verbessern

oder: kleines Einmaleins von Listen in React, dem Profiler und Memoisierung

(Meistens) Das Problem: Listen in React

In nahezu jeder App lässt sich eine filterbare, tabellenartige Ansammlung von Daten finden. Das Problem an der Sache? Solange die Liste noch schön übersichtlich ist, können sich hier leicht unbemerkt Probleme einschleichen – und um solche Darstellungen skalierbar zu halten, benötigt man sowohl diverses JavaScript- als auch React-spezielles Wissen.

In dieser Sandbox habe ich so ein Szenario skizziert: eine filterbare Tabelle mit 100 Components im tbody (Direktlink). Leider ist ein Einbinden via iframe nicht möglich.

Profiling 101

Aktuell ist hier noch nichts überraschendes zu sehen, der erste Render benötigt geräteabhängig ~140ms. Woher habe ich diese Zahl? Hierfür gibt es zwei Möglichkeiten:

  1. der Profiler-Tab in den Entwicklertools des Browsers, wird durch die React DevTools mitinstalliert
  2. <Profiler /> direkt aus React selbst (wie StrictMode nur in dev aktiv) – in meiner Demo ist um jeden tbody bereits ein <Profiler /> gewrapped der stumpf in die console loggt.

Die Hürden um zu messen sind also denkbar gering. Die DevTools sind jedoch meiner Meinung nach deutlich zugänglicher und featuretechnisch der Component überlegen: interaktiver, kein Mehraufwand nötig und noch dazu konfigurierbar. Dazu gleich vorweg:

Dringend diese Einstellung aktivieren! Ohne sieht man zwar dass eine Component neu rendered, aber nicht den Grund.

Um mit dem Profiling nun tatsächlich zu beginnen reicht ein Klick auf diesen Button. Möchte man ohne Reload mitschneiden reicht der Button links davon.

Nach dem ersten Render und allen Aktionen die wir ausführen möchten, kann man die Aufzeichnung mit einem Klick auf den nun roten Button beenden.

Nun sehen wir eine gestaffelte Aufstellung unseres React-Trees und die Renderzeiten der einzelnen Elemente. Die Farbgebung gibt uns bereits Hinweise auf Performanceunterschiede, allein darauf würde ich jedoch nicht vertrauen. Bekanntermaßen ist der erste Render einer der teuersten.

Im hervorgehobenen Bereich sieht man nun zwei weitere Render – im Falle meiner Demo einmal einen separaten Render des <Spinners /> sollte die API länger als 1.5s zum antworten brauchen, und den tatsächlichen Render der Tabelle. Bereits hier sehen wir dass der Tabellenrender deutlich länger gebraucht hat als der Rest – plausibel, da ja 100 Rows mit Cells aus Daten herausgemappt werden müssen.

Der Profiler nimmt uns hier gleich viel visuelle Arbeit ab: alle Components mit grauem Hintergrund wurden hier nicht gerendered!

Gleichzeitig kann man mit etwas Übung schnell den Table und die Profiler-Component erkennen.

Ein Hover über einzelne Components gibt Auskunft darüber, weshalb die Component gerendered wurde – aktuell für jede Component derselbe Grund: „This is the first time the component rendered“.

Ein Klick auf eine beliebige Component zoomt rein, ermöglicht also genauere Auswahl der Subcomponents und gibt außerdem Auskunft über die individuellen Renderzeiten sofern es bereits mehrere gibt.

Schön, und wo ist hier ein Problem?

Zwei Dinge spielen bei Listen immer eine Rolle:

  • werden identische Datensätze auf Basis des Filterns unnötig neu gerendered?
  • gibt es Performanceprobleme bei einer höheren Anzahl von Datensätzen?

Aktuell werden beim Filtern sämtliche Datensätze neu gerendered (siehe renderCount-Spalte). Tatsächlich sollten aber nur einzelne verschwinden. Zu einem Performanceproblem führt das bei 100 Datensätzen aber noch nicht.

Erhöht man die Anzahl der Datensätze aber auf 250 dauert nun allein der erste Buchstabe beim Filtern ~100ms und die Suche laggt bereits – schlechtere UX durch schlechtere Performance.

Vorherige Render wurden durch den Wechsel auf die Datensatzgröße 250 verursacht.

React.memo to the Rescue

Ein klassicher Anwendungsfall für Memoisierung in React sind – Trommelwirbel – Listen mit Filterfunktion. React als eher meinungsfreie Library hat auch dafür Bordmittel zur Verfügung gestellt, während beispielsweise Vue 3 intern ahead-of-time-Optimierungen durchführt und somit dem Entwickler abnimmt um ähnliches Verhalten zu gewährleisten.

React.memo erlaubt es uns pro individueller Component den normalerweise berechtigten Rerender zu verhindern:

import { memo } from 'react';

export const Component = memo(({ firstName, lastName }) => {
  return <h1>hello {lastName}, {firstName}</h1>;
});

Von Haus aus vergleicht React bei einem Rerender einer memoisierten Component alle Props individuell via shallow comparison, d.h. beispielsweise ob newProps.firstName identisch zu lastProps.firstName ist. In Class Components hieß diese Methode shouldComponentUpdate.

Das funktioniert prächtig solange die Props nur primitive Typen enthält. Da in Javascript {} === {} false ist, müssen wir als Entwickler entweder dafür sorgen, dass die weitergeleiteten Props nur aus Primitiven bestehen werden, oder, was memo auch erlaubt, eine eigene Vergleichsfunktion zur Verfügung stellen:

import { memo } from 'react';

export const Component = memo(({ firstName, lastName }) => {
  return <h1>hello {lastName}, {firstName}</h1>;
}, comparator);

const comparator = (prevProps, nextProps) => {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}

Üblicherweise ist der erste Ansatz, Props anders zu schreiben, einfacher und erachte es als best practice, in Listen primitives via Props weiterzureichen.

Bei 100 Datensätzen sieht ein Filter auf der optimierten Route in der Sandbox nun stattdessen folgendermaßen aus:

kein einziger Datensatz wurde neu gerendered!

Da es ein Filtervorgang ist, wurden die Datensätze die dem Filter nicht mehr entsprechen entfernt. Alle anderen blieben unangetastet.

Im Vergleich zum vorigen Bild mit n=100 wurde die Rerender-Zeit von ~75ms auf ~13ms reduziert, einem Faktor von rund 6. Dass der Wert hier unter ~16ms bleibt, ist insbesonders wichtig, da ~16ms einem Frame pro Sekunde bei 60 fps entspricht, dem Ziel einer flüssigen Anwendung.

Zur Erinnerung: bei n=250 waren es vorher rund 100ms, nun 15ms, und somit immer noch 60 fps.

Ohne Optimierungen betrug die Steigerung von n=100 zu n=250 +25ms.
Nun sind es +3ms, deutlich skalierbarer!

Wieso verwenden wir memo nicht immer? Wieso übernimmt das React nicht für uns?

Mehrere Gründe einer immer wieder aufkommenden Diskussion:

  • Rerendern ist in 99% der Fälle schnell und günstig. Normalerweise gibt es hier also einfach keine Performanceprobleme.
  • React überlässt deshalb das Optimieren uns, weil wir ein deutlich besseres Wissen über unsere sog. „teuren“ Components haben. Dafür ist die uns zur Verfügung gestellte API sehr simpel.
  • Wie bereits an der Art der Verwendung von memo absehbar, ist es pro Component pro render ein weiterer Funktionsaufruf. Initial wird memo ausgeführt und die Props gespeichert, was dadurch etwas Overhead ist und somit per Definition langsamer! Dass je ein Loop über alle Props aller neuzurendernden Components teuer ist, muss denke ich nicht erwähnt werden. memo sollte daher nur dann verwendet werden, wenn es entsprechende Bedenken gibt.

    Dasselbe gilt übrigens natürlich auch für useCallback.
  • Premature optimization is the root of all evil.

Bandaid Fix: debouncing/throttling

Die Implementierung der Filterfunktion in der Sandbox ist naiv: suche nach jeder Änderung des Feldes im kompletten Datensatz nach Treffern.

Üblicherweise suchen Nutzer nicht nur nach einzelnen Buchstaben und haben ein gewisses Schreibtempo.

debouncing

Daher bietet es sich an, erst nach Zeit x, beispielsweise 100ms, zu suchen und die vorherige Stateänderung abzubrechen sollte ein weiterer Aufruf stattfinden. Mit dem Verzögerungszeitraum sollte man ein wenig spielen, einerseits muss es sich gut anfühlen, andererseits darf die Verzögerung natürlich auch bei langsamer schreibenden Nutzern nicht zu Performanceproblemen führen.

Übliche Lösungen hierfür sind lodash.debounce oder react-debounce-input.

throttling

Alternativ kann man auch immer sofort suchen, aber weitere Anfragen ignorieren die innerhalb Zeit x nach der letzten Anfrage eintreffen. Üblicherweise wird sowas nicht für Filter verwendet sondern findet eher bei API-Requests Anwendung, aber der Vollständigkeit gehört es hier hin.

Lodash hat dafür lodash.throttle.

Escape Hatch: Virtualisierung

Sollten alle Stricke reißen, die Liste sehr groß sein oder andere Probleme auftreten sollte man sich Virtualisierungsoptionen ansehen, beispielsweise react-virtualized.

Bei einer virtualisierten Liste wird nur der derzeitig auch im Browser sichtbare Ausschnitt einer längeren Liste gerendered.

Schreibe einen Kommentar