Sechs Asynchrone Zeitplanungsfehler, die als Renderingsfehler in React auftreten
Lernen Sie, sechs asynchrone JavaScript-Muster zu erkennen – von veralteten erfassten Werten bis hin zu fehlenden Promise-Rückgaben –, die dazu führen, dass React-Komponenten einen falschen oder unmöglichen Zustand anzeigen.
Viele Probleme, die als „React-Bugs“ gemeldet werden, haben nie in React ihren Ursprung. Eine Liste mit Ergebnissen einer älteren Abfrage, ein Spinner, der nie verschwindet, eine Erfolgsbenachrichtigung, die bereits vor Bereitstellung der Daten angezeigt wird: React ist einfach nur der Ort, an dem diese Probleme sichtbar werden. Die eigentliche Ursache liegt in der Regel eine Ebene darunter, nämlich in der Art und Weise, wie asynchrone JavaScript-Codeblöcke Werte im Laufe der Zeit weiterleiten.
Asynchrone Codeblöcke bringen Zeit in ein Programm ein, und jeder Aufruf von await oder .then() ist ein Punkt, an dem sich die Struktur des Programms unter den Händen des Benutzers verändern kann. Im Folgenden werden sechs häufige Zeitprobleme aufgeführt, erklärt, warum jedes davon zu verwirrenden UI-Symptomen führt, sowie welche kleinen Anpassungen diese beheben – damit Sie sie bei Code-Reviews erkennen können.
Fehler 1: Veraltete Anfragen schreiben weiterhin in den aktuellen Zustand
Ein Suchfeld ist ein klassisches Beispiel dafür. Die darunter gezeigte Lösung deaktiviert die Eingabe bereits nach 300 Millisekunden und löscht den Timer bei der Aufräumung.
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
const results = await search(query);
setResults(results);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [query]);
Stellen Sie sich nun vor, jemand tippt „Async JavaScript Mistakes“. Er tippt „Async“, wartet gerade so lange, bis die Verzögerung abgelaufen ist, und es wird eine Anfrage gesendet. Danach tippt er weiter, wodurch eine zweite Anfrage für den vollständigen Satz abgesendet wird. Die Verzögerung verringerte die Anzahl der Anfragen, verhinderte aber nicht, dass gleichzeitig zwei Anfragen unterwegs sind.
Die Reihenfolge, in der die Anfragen den Browser verlassen, liegt in Ihrer Kontrolle. Die Reihenfolge, in der die Antworten zurückkommen, nicht. Wenn die längere Abfrage zuerst abgeschlossen wird und die „Async“-Abfrage erst später, gewinnt der zweite Aufruf von setResults, und die Liste zeigt Ergebnisse für „Async“, obwohl der Eingabebereich eindeutig „Async JavaScript Mistakes“ anzeigt.
Es gibt hier nichts, das sich falsch verhalten hat. Netzwerke dürfen die Ergebnisse umordnen, und der Code hat React nie mitgeteilt, welche Antwort noch relevant ist. Die übliche Lösung besteht darin, überflüssige Arbeiten mit einem AbortController zu stornieren, der innerhalb des Effects erstellt und in dessen Cleanup-Funktion abgebrochen wird, sodass eine veraltete Antwort entweder nie ankommt oder ignoriert wird. Wenn der Datenfluss bereits auf RxJS basiert, bietet switchMap dieselbe Semantik von „nur das Neueste zählt“. Für eine ausführlichere Behandlung dieses genauen Szenarios siehe die Behebung von Rennbedingungen, die durch Debouncing nicht gelöst werden können.
Fehler 2: Entscheidungen auf Grundlage von vor dem await erfassten Werten treffen
Betrachten Sie jedes await als eine Grenze. Was Sie davor wussten, ist ein Zeitpunktbild; alles, was danach geschieht, findet zu einem späteren Zeitpunkt statt, möglicherweise nachdem sich der Zustand geändert hat.
const handlePublish = async () => {
const { canPublish } = permissions;
await saveDraft();
if (canPublish) {
publish();
}
};
Dieser Handler liest canPublish aus permissions ab, wartet darauf, dass der Entwurf gespeichert wird, und entscheidet anschließend, ob er veröffentlicht werden soll. Das Problem ist, dass die Entscheidung auf einem Wert beruht, der zu Beginn wahr war. Wenn den Berechtigungen des Benutzers während des Ausführens von saveDraft() entzogen wurden, besagt die lokale Konstante weiterhin „ja“.
Dieselbe Situation tritt bei ausgewählten Elementen, aktiven Filtern, Route-Parametern, Editor-Inhalten und vielen anderen Zustandskomponenten auf. Wenn eine Entscheidung nach einem await vom aktuellen Anwendungsstatus abhängt, lesen Sie diesen Status nach der Grenze erneut ein (aus einem Ref, einem Store oder einer neuen Anfrage), anstatt sich auf die frühere Kopie zu verlassen.
Fehler 3: Annahme, dass der Rest der Funktion immer ausgeführt wird
Hier ist eine Ladeflagge um einen fetch-Aufruf herum gebunden.
setLoading(true);
const dashboard = await getDashboard();
setDashboard(dashboard);
setLoading(false);
Falls getDashboard() abgelehnt wird, springt die Ausführung an der Stelle mit await aus der Funktion heraus, und setLoading(false) wird niemals ausgeführt. Der Ladeindikator bleibt somit endlos auf dem Bildschirm sichtbar.
Wenn ein Teil des Zustandsmodells die Laufzeit einer asynchronen Operation modelliert, muss dessen Zurücksetzen sowohl im Erfolgsfall als auch im Fehlerfall erfolgen. Ein finally-Block drückt diesen Zweck direkt aus:
setLoading(true);
try {
const dashboard = await getDashboard();
setDashboard(dashboard);
} finally {
setLoading(false);
}
Beachten Sie, dass der try-Block weiterhin keinen catch enthält. Der Fehler wird weitergeleitet an denjenigen, der diesen Code aufgerufen hat – was oft gewünscht ist – während die Ladeflagge garantiert gesetzt wird. Fügen Sie nur einen catch hinzu, wenn dies der richtige Ort ist, um den Fehler in eine Benutzeroberflächenanzeige wie eine Fehlermeldung umzuwandeln.
Fehler 4: Unabhängige Anfragen erzeugen inkohere Bildschirmzustände
Es ist oft völlig vernünftig, unzusammenhängende Anfragen parallel auszuführen.
getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);
Jede Antwort landet in ihrem eigenen Zustandsbereich, sobald sie eintrifft. Das bedeutet, React kann jede mögliche Kombination darstellen: ein Profil ohne Berechtigungen oder Präferenzen, Berechtigungen und Präferenzen ohne Profil – und so weiter in jeder Reihenfolge, die das Netzwerk erzeugt.
Einige dieser Kombinationen können sinnlos oder sogar gefährlich für Ihren Bildschirm sein. Wenn mehrere Antworten gemeinsam einen kohärenten Zustand des Bildschirms beschreiben, können die Anfragen parallel verarbeitet werden, während ein einziger Verwalter das Ergebnis zusammenstellt – beispielsweise indem er auf Promise.all wartet und alles in einem einzigen Zustandsupdate speichert, oder indem der Bildschirm als Reducer mit expliziten Lade-, Bereitschafts- und Fehlerrichtungen modelliert wird. Paralleler Abruf und unabhängiger UI-Zustand sind zwei getrennte Entscheidungen.
Fehler 5: Aufbau des nächsten Zustands aus einem veralteten Snapshot
Dieser Fehler verbirgt sich leicht in Event-Handlern.
const handleAdd = async () => {
await saveItem(newItem);
setItems([...items, newItem]);
};
items enthält weiterhin den Inhalt, den die Komponente zum Zeitpunkt des Starts von handleAdd hatte. Während saveItem() noch aussteht, könnten weitere Aktionen Einträge hinzugefügt oder entfernt haben. Wenn der Handler wieder fortgesetzt wird, werden die alten Elemente weiterhin verwendet und die neueren Änderungen überschrieben.
Sobald der nächste Wert von dem vorherigen abhängt, soll React den vorherigen Wert über die Update-Funktion bereitstellen:
setItems(current => [...current, newItem]);
Die Update-Funktion wird mit dem aktuellsten gespeicherten Zustand ausgeführt, wodurch gleichzeitige Änderungen beibehalten werden. Veraltete Schließfunktionen sind nicht nur ein Problem bei Effekten: Jeder asynchrone Callback kann Werte länger festhalten, als man erwartet.
Fehler 6: Bruch in einer Promise-Kette durch fehlende Rückgabe
Diese Kette erscheint streng sequenziell: Speichern, anschließend Widgets aktualisieren und danach als gespeichert markieren.
saveDashboard()
.then(() => refreshWidgets())
.then(() => setSaved(true));
Betrachten Sie nun, wie refreshWidgets geschrieben sein könnte:
const refreshWidgets = () => {
getWidgets().then(setWidgets);
};
Die Funktion startet eine Anfrage, gibt aber undefined zurück und nicht die Promise. Aus Sicht der äußeren Kette scheint refreshWidgets() sofort abgeschlossen zu sein, wodurch der nächste .then sofort ausgeführt wird und setSaved(true) bereits während des Ladevorgangs der Widgets ausgelöst werden kann.
Die Lösung besteht darin, mit einem einzigen Schlüsselwort die Promise zurückzugeben, damit die Kette darauf warten kann:
const refreshWidgets = () => {
return getWidgets().then(setWidgets);
};
Eine async-Funktion erreicht dasselbe auf implizite Weise, da sie stets eine Promise zurückgibt, die dann abgeschlossen wird, wenn ihr Körper beendet ist:
const refreshWidgets = async () => {
const widgets = await getWidgets();
setWidgets(widgets);
};
In der Benutzeroberfläche kann dieses Problem wie eine zu früh erscheinende Erfolgsnachricht, veraltete Daten auf dem Bildschirm oder eine Umleitung aussehen, die vor Abschluss des Aufrufs stattfindet. Keines dieser Phänomene weist eindeutig auf das Fehlen eines return-Befehls hin. TypeScript-Lint-Regeln wie @typescript-eslint/no-floating-promises können viele dieser Fälle automatisch erkennen.
Wichtige Erkenntnisse
Die Grenze zwischen React und JavaScript ist nicht immer offensichtlich. React rendernt den Zustand, doch asynchrone JavaScript-Codeblöcke entscheiden darüber, wann dieser Zustand ankommt und ob er zum Zeitpunkt der Verarbeitung noch aktuell, veraltet oder inkonsistent ist. Bei der Überprüfung asynchronen Codes in Komponenten führen drei Fragen meist zur Aufdeckung dieser Fehler:
- Wann wurde dieser Wert erfasst, und könnte er während eines
await-Befehls geändert worden sein?