Startseite / Artikel / Erledigen Sie zuerst weniger Arbeit: Eine Leistungsüberprülliste für React vor der Memoisierung

Erledigen Sie zuerst weniger Arbeit: Eine Leistungsüberprülliste für React vor der Memoisierung

Senken Sie die Kosten einer React-App, indem Sie Arbeit vermeiden: Debouncing für Suchfunktionen anwenden, Seitenbereichung nutzen, Zustände gemeinsam platzieren, stabile Schlüssel verwenden, die Verarbeitung auf der Serverseite durchführen und Inhalte verspätet laden – bei Bedarf anschließend mit Memoisierung arbeiten.

2118 Wörter

Wenn eine React-Anwendung langsam wirkt, ist die erste Reaktion, zu useMemo, useCallback oder React.memo zu greifen. Diese Werkzeuge machen bereits vorhandene Aufgaben effizienter, doch in vielen Anwendungen entsteht der eigentliche Aufwand durch Arbeiten, die überhaupt nicht notwendig wären: redundante Anfragen, zu große Datenmengen, Zustandsänderungen, die sich auf einen großen Teil der Anwendung auswirken, sowie Code, den die Benutzer noch nicht einmal angefordert haben. Dieser Leitfaden zeigt sieben Möglichkeiten auf, unnötige Arbeiten zu beseitigen, bevor man das Verbleibende optimiert – mit einer Checkliste, die bei Code-Reviews angewendet werden kann.

Die zentrale Frage bleibt stets einfach: Kann diese Arbeit ganz vermieden werden?

Weniger Anfragen senden: Sucheingabe deaktivieren

Suchfelder sind eine klassische Quelle für verschwendete Anfragen. Ein naiver Handler ruft die API bei jeder Änderung auf:

const handleSearch = (value) => {
  fetchUsers(value);
};

Das Eingeben des Wortes „React“ in dieses Feld erzeugt eine Anfrage pro Tastendruck:

R
Re
Rea
Reac
React

Fünf Hin- und Rückreisen für eine Suche, wobei vier davon Ergebnisse liefern, die niemand ansehen wird. Ein besseres Vorgehen besteht darin, zu warten, bis der Benutzer kurz pausiert, und erst dann die Anfrage zu senden. Genau das macht Debouncing: Jeder neue Aufruf setzt einen Timer zurück, und die umschlossene Funktion wird nur ausgeführt, wenn der Timer ununterbrochen abgelaufen ist.

const handleSearch = debounce((value) => {
  fetchUsers(value);
}, 300);

Mit einem Zeitfenster von 300 ms lösen schnelle Tippenden am Ende nur eine einzige Anfrage aus. Dadurch wird der Netzwerkverkehr, die Serverlast sowie die Verarbeitung von verworfenen Antworten auf der Client-Seite eingespart. Beachten Sie, dass hier nichts mit dem Rendering zu tun hat; der Vorteil entsteht ausschließlich dadurch, dass die Arbeit nicht erledigt werden muss.

Eine Einschränkung bei der Verwendung eines solchen Hilfsfunktionen innerhalb einer Komponente: Wenn debounce(...) direkt im Komponentenkörper aufgerufen wird, wird bei jeder Neuzeichnung eine neue degebouncede Funktion (und ein neuer Timer) erstellt, was die Debouncing-Funktion beeinträchtigt. Erstellen Sie sie daher einmal – beispielsweise mit useMemo oder useRef – oder verwenden Sie das unten beschriebene Muster basierend auf Effects.

Eine eigenständige, degebouncede Suchkomponente

Dieselbe Idee kann auch ausschließlich mit React-Primitiven ohne Hilfsbibliotheken umgesetzt werden. Beginnen Sie mit den Imports sowie zwei Zuständen: dem aktuellen Eingabetext und den heruntergeladenen Benutzern.

import { useEffect, useState } from "react";
function UserSearch() {
  const [search, setSearch] = useState("");
  const [users, setUsers] = useState([]);

Ein Effect wird jedes Mal ausgeführt, wenn sich search ändert. Anstatt sofort Daten herunterzuladen, wird die Ausführung mit setTimeout verschoben. Im Callback führt eine leere oder nur aus Leerzeichen bestehende Abfrage dazu, dass die Ergebnisse gelöscht werden und der Vorgang ohne Anfrage früh beendet wird.

useEffect(() => {
    const timer = setTimeout(async () => {
      if (!search.trim()) {
        setUsers([]);
        return;
      }

Bei einer echten Abfrage fordert die Callback-Funktion passende Benutzer an, wobei der Suchbegriff kodiert wird, damit Sonderzeichen die URL nicht stören:

const response = await fetch(
        `/api/users?search=${encodeURIComponent(search)}`
      );

Anschließend wird das JSON analysiert und das Ergebnis gespeichert, alles noch innerhalb des 300-Millisekunden-Timers:

const data = await response.json();
      setUsers(data);
    }, 300);

In der Aufräumfunktion findet tatsächlich die Verzögerungstechnik statt. React ruft sie vor dem erneuten Ausführen des Effects auf, sodass jede Tastenbetätigung den vorherigen wartenden Timer stört:

return () => clearTimeout(timer);
  }, [search]);

Schließlich rendernt das Komponente ein kontrolliertes Eingabefeld, das mit search verknüpft ist:

return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />

Und es listet die Benutzer unter Verwendung ihrer IDs auf:

{users.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

Jeder eingegebene Zeichen setzt den Timer zurück, und die Anfrage wird erst nach 300 ms Stille abgesendet. Bedenken Sie, dass das Debouncing die Anzahl der gesendeten Anfragen verringert, aber nicht garantiert, dass sie in der richtigen Reihenfolge abgeschlossen werden; eine langsame frühere Antwort kann dennoch eine neuere überschreiben. Wenn das für Ihre Benutzeroberfläche wichtig ist, kombinieren Sie dies mit der Stornierung von Anfragen, wie in „Fixing race conditions: Debouncing kann in Such-Benutzeroberflächen nicht helfen“ beschrieben.

Die allgemeine Lektion: Die Verhinderung von Arbeit ist in der Regel besser als die Beschleunigung bereits vorhandener Arbeit.

Laden Sie nur die Daten, die der Bildschirm benötigt

Eine weitere häufige Ursache für Ressourcenverbrauch ist das Herunterladen von weitaus mehr Daten, als angezeigt wird. Angenommen, ein Endpunkt gibt 10.000 Benutzer an, während die Ansicht jeweils nur 20 zeigt. Der Browser muss trotzdem alle Daten herunterladen, verarbeiten und im Speicher halten, und React muss mit viel größeren Arrays arbeiten, als notwendig wäre.

Soweit der Anwendungszweck es zulässt, nutzen Sie Paginierung, sodass jede Anfrage nur eine Seite enthält:

API
 ↓
20 users
 ↓
Browser
 ↓
Display

Jedes Mal, wenn der Benutzer weiterblättert, wird der nächste Datensatz angefordert. Dadurch wird die Netzwerknutzung, der Speicherverbrauch, die Verarbeitung auf der Client-Seite sowie das Datenvolumen, das durch Ihre Komponenten fließt, reduziert. Infinite Scroll und cursorbasierte APIs folgen demselben Prinzip. Die Änderung der Datenabfragemethode hat oft einen weitaus größeren Einfluss als die Optimierung einer einzelnen Komponente.

Bewahren Sie stark veränderliche Zustände in der Nähe ihrer Verarbeiter auf

Die Stelle, an der sich der Zustand befindet, bestimmt, wie viel des Baums neu gerendert wird, wenn sich dieser ändert. Betrachten wir ein Dashboard, das den Suchtext verwaltet:

function Dashboard() {
  const [search, setSearch] = useState("");
return (
    <>
      <SearchBox value={search} onChange={setSearch} />
      <Analytics />
      <UserTable />
    </>
  );
}

Jeder Tastendruck aktualisiert Dashboard, wodurch auch Analytics und UserTable neu gerendert werden, obwohl keines dieser Komponenten den Suchwert verwendet. Bei einem großen Dashboard summieren sich diese Effekte. Wenn der Zustand nur für einen kleinen Teil der Benutzeroberfläche relevant ist, führt seine Verlegung in diesen Teil (hier direkt in SearchBox) dazu, dass Updates nur dort stattfinden, wo sie benötigt werden, und die Struktur der Komponenten wird übersichtlicher.

Es handelt sich dabei nicht um eine Regel, den Zustand immer nach unten zu verlagern. Wenn mehrere Komponenten tatsächlich denselben Wert benötigen, ist ihr nächster gemeinsamer Elternteil der richtige Eigentümer. Ziel ist es, den Zustand auf dem niedrigsten Niveau zu halten, das dennoch von jeder Komponente gelesen werden kann, die ihn tatsächlich benötigt.

Geben Sie dynamischen Listeneinträgen stabile Schlüssel

Listen sind Orte, an denen kleine Details überproportional große Auswirkungen haben. Ein gängiges Muster verwendet den Array-Index als Schlüssel:

{users.map((user, index) => (
  <UserCard key={index} user={user} />
))}

Index-Schlüssel sind nicht immer falsch. Für eine statische Liste, deren Reihenfolge sich nie ändert, funktionieren sie. Bei einer dynamischen Liste gibt ein stabiles ID aus den Daten jedem Element eine dauerhafte Identität:

{users.map((user) => (
  <UserCard key={user.id} user={user} />
))}

Der Unterschied liegt darin, was der Schlüssel repräsentiert:

index → position
id    → identity

Falls Elemente hinzugefügt, entfernt, umgeordnet oder gefiltert werden können, verschieben sich die Positionen und React vergleicht falsche Elemente: Es kann mehrfach als nötig neu rendern, und schlimmer noch, es kann den internen Zustand eines Elements mit dem eines anderen verknüpfen. Das wird zu einem sichtbaren Fehler, wenn die Listenelemente Eingabefelder, lokalen Zustand oder andere interaktive Elemente enthalten – beispielsweise ein Textfeld, das seinen eingegebenen Wert behält, während die Zeile darunter sich ändert. Wählen Sie immer ein stabiles ID, wenn sich die Liste ändern kann.

Fragen Sie nach der Verarbeitung, nicht nur nach ihrer Geschwindigkeit

Umfangreiche Transformationen auf der Client-Seite sind eine weitere Quelle für vermeidbare Kosten. Dabei werden Benutzer gefiltert, sortiert und in neue Objekte umgewandelt:

const filteredUsers = users
  .filter((user) => user.isActive)
  .sort((a, b) => a.name.localeCompare(b.name))
  .map((user) => ({
    ...user,
    displayName: user.name.toUpperCase()
  }));

Bei einigen tausend Datensätzen wird das Wiederholen dieser Vorgänge bei jeder Darstellung teuer. Die Speicherung in einem Cache ist eine Möglichkeit, doch zuerst sollte geprüft werden, ob der Client diese Arbeit überhaupt durchführen muss. Oft kann die Endpunkt-Instanz bereits aktivierte Benutzer filtern, die Datenbank kann die Sortierung übernehmen, wobei Indizes dies kostengünstig machen, und die Paginierung kann den Datensatz verkleinern, sodass die verbleibende Verarbeitung unbedeutend ist. Die Reduzierung der Eingabedaten ist in der Regel effektiver als die Beschleunigung der Berechnungen.

Funktionen auf Anfrage laden

Große Anwendungen enthalten Funktionen, die viele Benutzer in einer bestimmten Sitzung nie öffnen. Eine Berichtsseite zum Beispiel kann völlig getrennt vom Haupt-Dashboard sein. Anstatt sie im initialen Download mitzuliefern, sollte sie verspätet geladen werden:

const Reports = lazy(() => import("./Reports"));

Mit React.lazy wird die Datei des Moduls zum ersten Mal geladen, wenn der Komponente rendernt – und das muss innerhalb eines Suspense-Bereichs geschehen, der während des Ladevorgangs eine Ersatzanzeige zeigt. Dies bringt vor allem Vorteile in Apps mit vielen Routen, großen Funktionsbereichen, schweren Abhängigkeiten wie Diagramm- oder Editor-Bibliotheken oder Seiten, die nur von wenigen Nutzern aufgerufen werden.

Sie sollten klar wissen, was Sie dadurch gewinnen: eine kleinere anfängliche JavaScript-Größe sowie eine schnellere erste Ladezeit. Der Code innerhalb der lazy-Komponente läuft nach dem Laden nicht schneller, und es entsteht eine kurze Ladeverzögerung beim ersten Öffnen der Funktion.

Memoisieren mit Grund

Ein häufiger Fehler besteht darin, Optimierungs-APIs standardmäßig über den gesamten Codebase zu verteilen – beispielsweise indem jeder Handler umhüllt wird:

const handleClick = useCallback(() => {
  setSelectedUser(id);
}, [id]);

Oder jeder abgeleitete Wert:

const data = useMemo(() => {
  return processData(users);
}, [users]);

Diese Hooks haben legitime Anwendungsfälle, doch jeder von ihnen fügt Code hinzu, Abhängigkeitslisten, die korrekt gehalten werden müssen, sowie einen eigenen geringen Laufzeitkostenanteil. Bevor man einen hinzufügt, prüfen Sie:

  • Ist die Berechnung tatsächlich aufwendig?
  • Wird sie häufig ausgeführt?
  • Wird das Ergebnis bei mehreren Darstellungen wiederverwendet?
  • Hängt ein gememorisierter Kindkomponente oder ein Effect von einer stabilen Referenz ab?
  • Wird die Änderung einen messbaren Unterschied bewirken?

Falls die Antworten größtenteils „Nein“ lauten, ist der einfachere Code der bessere Code. Die Leistung entsteht durch die richtige Optimierung am richtigen Ort – nicht durch die Menge an Optimierungscode. Der React Compiler, sofern er verwendet wird, automatisiert einen Großteil dieser Memoisierung, was ein weiterer Grund ist, sie nicht überall von Hand zu schreiben; siehe was der React Compiler optimiert und was er übrig lässt.

Überprüfungsliste

Gehen Sie bei der Überprüfung einer React-Funktion diese Fragen durch:

  1. Unnötige Anfragen? Achten Sie auf Aufrufe pro Tastendruck, doppelte Anfragen sowie Abfragen nach Daten, die derzeit nicht angezeigt werden.
  2. Zu viele Daten? Nutzen Sie Paginierung, Filtern auf dem Server und verschieben Sie Abfragen auf den Zeitpunkt, an dem die Daten benötigt werden.
  • Staat zu hoch? Überprüfen Sie, ob ein Update einen größeren Teil des Baums erneut rendern lässt, als nötig wäre.
  • Instabile Listeindizes? Verwenden Sie stabile IDs, wenn sich die Elemente verschieben oder deren Zugehörigkeit ändern kann.
  • Übermäßige Verarbeitung? Prüfen Sie, ob die Arbeit auf den Server verlagert oder vor der Optimierung übersprungen werden kann.
  • Vorzeitige Funktionalitäten? Laden Sie große oder selten genutzte Module verspätet (lazy load).
  • Unangemessene Memoisierung? Fügen Sie useMemo, useCallback oder React.memo nicht einfach nur deshalb hinzu, weil sie vorhanden sind.
  • Leistung ist ein Prozess, keine einzelne Renderung

    Die Leistung von React betrifft nicht nur React selbst. Kosten entstehen entlang der gesamten Arbeitskette:

    API calls
       ↓
    Amount of data
       ↓
    State updates
       ↓
    Component structure
       ↓
    Data processing
       ↓
    Rendering
       ↓
    Bundle size
    

    Wenn man sich nur auf die Render-Schicht konzentriert, kann dadurch ein weitaus größeres Problem weiter oben in der Kette übersehen werden. Das Beschleunigen des Renderings um Millisekunden nützt wenig, wenn die Seite weiterhin überflüssige Anfragen sendet, und das Merken von Komponenten hilft nicht, wenn Tausende von Datensätzen heruntergeladen werden, die nie angezeigt werden. Man sollte bei der Lösung von oben anfangen und sich nach unten arbeiten.

    Kernpunkte

    • Das Weglassen von Aufgaben (Anfragen, Bytes, Aktualisierungen, Code) bringt in der Regel mehr als das Beschleunigen solcher Aufgaben.
    • Debouncen Sie anfragengetriebene Anfragen und kombinieren Sie dies mit der Stornierung, wenn Reihenfolge wichtig ist.
    • Lassen Sie den Server filtern, sortieren und paginieren; senden Sie dem Client nur das, was angezeigt wird.
    • Platzieren Sie den Zustand auf der niedrigsten Ebene, die allen Lesern dient, und kennzeichnen Sie dynamische Listen nach ihrer Identität.
    • Verwenden Sie Lazy Loading für eine leichtere erste Ladezeit und greifen Sie nur dann auf Memoisierung zurück, wenn ein konkreter Nutzen dies rechtfertigt.
  • Bevor Sie fragen, wie man etwas optimieren kann, fragen Sie erst, ob es überhaupt notwendig ist.
  • Verwandte Artikel