Schnappschuss oder aktueller Wert? Entscheidung darüber, was verzögerte JavaScript-Callbacks sehen
Erfahren Sie, warum Schließfunktionen auf Bindings statt auf Kopien zugreifen, und wie Sie zwischen Snapshot- und Echtzeitdaten in Timern, React Effects, Listenern sowie asynchronem Code wählen können.
Eine Schaltfläche wirkt auf Daten aus einer vorherigen Darstellung. Ein Timer protokolliert einen Wert, der zum Zeitpunkt der Planung noch nicht existierte. Jeder in einer Schleife erstellte Handler scheint zum letzten Element zu gehören. Eine langsame Antwort überschreibt den Bildschirm mit Ergebnissen einer Seite, die der Benutzer bereits verlassen hat. Solche Fehler werden in der Regel unter „Closure-Problemen“ klassifiziert, doch diese Bezeichnung hilft selten dabei, sie zu beheben. Dieser Artikel ersetzt diese Bezeichnung durch ein präzises Modell: Eine Closure behält den Zugriff auf Variablebindungen bei, nicht Kopien der Werte, und jede Aufgabe mit verzögerter Ausführung benötigt eine explizite Entscheidung darüber, ob sie einen Snapshot oder den aktuellen Wert erhalten soll.
Die Regel hinter jedem Closure-Fehler
Schließungen sind nicht unvorhersehbar. Eine JavaScript-Funktion behält eine Referenz auf die lexikalische Umgebung bei, in der sie erstellt wurde, und jedes Mal, wenn ihr Körper auf eine externe Variable verweist, wird dieser Name in der Umgebung zum Zeitpunkt des Code-Ausführens abgefragt. Das Schlüsselwort hierfür ist „Access“. Die Funktion erhält keine eingefrorene Kopie aller sichtbaren Elemente zum Zeitpunkt ihrer Definition; sie behält dieselben Bindungen bei, und wenn eine dieser Bindungen später neu zugewiesen wird, liest die Funktion den neuen Wert.
Es gibt zwei gegensätzliche Möglichkeiten, dies falsch zu machen. Die erste besteht darin, eine Momentaufnahme zu erwarten, obwohl der Code tatsächlich einen Live-Link zu einer sich ändernden Variablen erstellt hat. Die zweite, die in Rendering-Frameworks häufig vorkommt, besteht darin, den neuesten Wert zu erwarten, obwohl die Callback-Funktion innerhalb eines älteren Scope erstellt wurde, dessen Bindungen sich nie wieder ändern werden. Beide Fehler resultieren aus derselben Auslassung: Niemand hat entschieden, zu welchem Zeitpunkt die verzögerte Ausführung stattfinden soll.
Sobald diese Entscheidung klar ist, wirkt das Verhalten nicht mehr rätselhaft. Der Callback tut genau das, was dem Programm befohlen wurde. Das Programm hat lediglich etwas anderes gesagt, als der Entwickler gemeint hat.
Zugriff – keine Fotografie
Das Beispiel zur Schließung von Lehrbüchern enthält eine innere Funktion, die nach dem Rückgang der äußeren Funktion weiterhin eine äußere Variable verwendet. Es zeigt, dass die Umgebung länger besteht als der Aufruf, fördert aber unauffällig eine falsche Intuition: dass die innere Funktion den Wert speichert, den sie zum Zeitpunkt der Erstellung gesehen hat. Eine kleine Variation macht den Unterschied deutlich. Hier wird der Logger erstellt, während message den Wert "Starting" hat, und die Variable wird vor dem Rückgang der Factory neu zugewiesen.
function createLogger() {
let message = "Starting";
const logMessage = () => {
console.log(message);
};
message = "Finished";
return logMessage;
}
const logger = createLogger();
logger();
Der Aufruf von logger() gibt Finished aus. Die Pfeilfunktion hat niemals den String "Starting" gespeichert; sie hielt lediglich die Bindung zu message, und zum Zeitpunkt ihrer Ausführung wies diese Bindung auf einen anderen String hin.
Dies ist eine Eigenschaft, keine Fehlerquelle. Der private, veränderliche Zustand in Zählern, Factory-Funktionen, Memoisierungsspeichern sowie im Modulmuster hängt alle davon ab, dass eine Schließung die Aktualisierungen ihrer eigenen Variablen sehen kann. Probleme entstehen erst, wenn jemand glaubt, der Callback sei an den ursprünglichen Wert statt an die Variable gebunden.
Primitiven begünstigen dieses Missverständnis, denn Zeichenketten und Zahlen wirken selbstständig, und es scheint natürlich, dass eine Funktion einfach „die Zeichenkette“ beibehält. Doch der Funktionskörper enthält einen Namen, nicht einen Wert, und Namen werden beim Ausführen des Codes gelöst. Wenn Sie genauer erfahren möchten, wie diese Abfrage durch verschachtelte Scopes erfolgt, erklärt wie JavaScripts Scope-Chain tatsächlich Variablen löst die Mechanismen dazu.
Die praktische Gewohnheit, die daraus resultiert, besteht darin, sich immer dann eine einzige Frage zu stellen, wenn später ein Callback ausgeführt wird und auf etwas von außen verweist: Soll der Wert so verwendet werden, wie er zum Zeitpunkt der Erstellung des Callbacks war, oder wie er zum Zeitpunkt der Ausführung des Callbacks ist? Die Antwort gibt an, ob ein Snapshot aufgenommen, eine aktuelle Referenz gelesen oder der Wert als Argument übergeben werden soll. Ohne diese Frage bleibt der Code zwar weiterhin korrektes JavaScript, doch sein Verhalten ist dann zufällig.
Der Loop-Bug betrifft die Bindung der Identität
Der bekannteste Closure-Bug betrifft einen for-Loop, der mit var deklariert wurde und mehrere Timeout-Funktionen anlegt. Jeder Callback scheint mit seiner eigenen Iteration verbunden zu sein.
for (var index = 0; index < 3; index++) {
setTimeout(() => {
console.log(index);
}, 100);
}
Es wird 3 dreimal ausgegeben. Eine var-Deklaration ist funktionsbasiert gebunden, sodass der gesamte Schleifenzyklus genau eine index-Bindung teilt. Alle drei Callbacks beziehen sich auf diese gleiche Bindung, und zum Zeitpunkt des Auslösens der Timer hat der Schleifenzyklus bereits abgeschlossen und den Wert auf 3 gesetzt.
Durch die Verwendung von let ändert sich das Ergebnis, denn die Sprache erstellt für jede Iteration einer for-Schleife mit einem let-Anfang einen neuen Bindungswert und kopiert den aktuellen Wert hinein.
for (let index = 0; index < 3; index++) {
setTimeout(() => {
console.log(index);
}, 100);
}
Diese Version gibt 0, 1 und 2 aus, da jeder Callback nun auf einen unterschiedlichen index verweist.
Die übliche Zusammenfassung lautet „let behebt Closures“, doch das verdeckt die eigentliche Lektion. Die Closures verhielten sich in beiden Schleifen identisch. In der ersten wurden drei Callbacks absichtlich mit einer gemeinsamen, veränderbaren Variable ausgestattet; in der zweiten erhielt jeder seine eigene. Was sich änderte, war die Bindungsidentität, nicht das Verhalten der Closures.
Dieser Unterschied ist wichtig, denn derselbe Fehler tritt auch ohne var auf. Deklariert man eine veränderliche Variable mit let außerhalb der Schleife, aktualisiert sie in jeder Iteration und liest sie in den Callbacks aus, dann sehen alle Callbacks wieder nur den endgültigen Wert. Das Wechseln eines Schlüsselworts hilft nicht, wenn der Code weiterhin mehrere Callbacks auf dieselbe veränderliche Quelle verweist.
Die bessere Vorgehensweise ist, zu prüfen, ob die Callbacks den Zustand teilen sollen. Wenn sie alle einen sich verändernden Wert überwachen sollen, ist eine einzige Bindung richtig. Wenn jeder Callback Daten speichern soll, die nur für seine eigene Iteration gelten, benötigt er eine eigene Bindung oder ein explizites Argument. Wenn man mehrere Callbacks im Quellcode sieht, ist es verlockend anzunehmen, dass jeder für die Variablen, die er verwendet, verantwortlich ist; der lexikalische Gültigkeitsbereich gibt lediglich an, wo nach Namen gesucht wird – nicht, wer sie besitzt.
Wenn Zeit die Erstellung von der Ausführung trennt
Closure-Probleme werden schlimmer, wenn zwischen der Erstellung einer Funktion und ihrer Ausführung eine Verzögerung besteht: ein Timer, eine Netzwerkverbindungsaufnahme, eine Benutzeraktion oder eine Aufgabe im Warteschlangensystem. Alles, was die Funktion von außen abruft, kann während dieser Verzögerung sich ändern. Betrachten Sie beispielsweise eine Speicherungsroutine, die von einer projektbezogenen ID auf Modulebene abhängt.
let activeProjectId = 42;
async function saveChanges(changes) {
await saveProject(activeProjectId, changes);
}
activeProjectId = 84;
Ob dies korrekt ist, hängt vom Zeitpunkt ab. Argumente werden beim Aufruf einer Funktion ausgewertet, sodass, wenn saveChanges vor der Neuzuweisung ausgeführt wird, activeProjectId synchron gelesen wird und die Zahl 42 an saveProject übergeben wird, obwohl dieser Aufruf anschließend wartet. Jeder Callback jedoch, der activeProjectId nach einer gewissen Verzögerung liest, erhält den Wert, den die Variable zu diesem Zeitpunkt enthält – möglicherweise 84 – und speichert ihn in einem völlig anderen Projekt.
Deshalb ist der Ausdruck „Closures erfassen Werte“ eine gefährliche Abkürzung. In einigen Codebeispielen wird ein Wert vor der Pause in ein Argument kopiert; in anderen Codebeispielen wird eine gemeinsame Bindung danach gelesen. Zwei Funktionen können fast identisch aussehen, während sie unterschiedliche Regeln bezüglich des Zeitpunkts befolgen.
Benutzeroberflächen sind voller dieses Musters. Stellen Sie sich ein Bestätigungsdialog vor, der für ein bestimmtes Datensatz geöffnet wird. Der Benutzer wechselt zu einem anderen Datensatz und klickt anschließend auf Bestätigen. Wenn der Handler die Variable „aktueller Datensatz“ liest, löscht er den Datensatz, der gerade auf dem Bildschirm angezeigt wird, anstatt denjenigen, für den der Dialog geöffnet wurde. Die Klammerfunktion funktioniert einwandfrei; das Produkt ist jedoch fehlerhaft, denn die Aktion hätte den Kontext beibehalten sollen, mit dem sie begonnen hat.
Die Lösung besteht nicht darin, automatisch „die Variable zu kopieren“. Zuerst muss entschieden werden, in welchem Moment die Aktion stattfindet:
- Eine zerstörerische Aktion gehört in der Regel zum Moment ihrer Auslösung und sollte die damals erfasste ID verwenden.
- Ein Statusanzeiger benötigt jedes Mal den neuesten Wert, wenn er aktualisiert wird.
- Eine für später geplante Wiederholung kann beides kombinieren: die ursprüngliche Operation-ID sowie das Authentifizierungstoken, das zum Zeitpunkt der Ausführung gültig ist.
Closures zwingen JavaScript-Entwickler dazu, explizit über Zeit nachzudenken. Die wichtige Frage ist nicht nur, welche Variable eine Callback-Funktion liest, sondern auch, welche Version davon der Ablauf verwenden soll.
Objekte halten die Referenz stabil, während sich der Inhalt ändert
Wenn die erfasste Bindung auf ein Objekt verweist, entsteht eine weitere Ebene der Verwirrung. Die Festlegung der Bindung als const friert nichts anderes ein als die Bindung selbst. Wenn das Objekt veränderbar ist, kann ein verzögert ausgelöster Callback dennoch alle durch die gemeinsame Referenz vorgenommenen Änderungen beobachten.
const settings = {
retries: 2,
};
setTimeout(() => {
console.log(settings.retries);
}, 100);
settings.retries = 5;
Der Timer gibt 5 aus. Die Konstante settings hat niemals geändert, auf welches Objekt sie verweist, doch die Eigenschaft retries des Objekts wurde vor dem Ausführen des Callbacks aktualisiert.
Die Browser-Konsole kann die Verwirrung verstärken. Man protokolliert ein Objekt vor dem Start von asynchroner Arbeit, erweitert es später in den Entwicklerwerkzeugen und sieht Felder, die nach Ausführung der Protokollierungsanweisung geändert wurden. Mehrere Konsoles zeigen beim Erweitern des Objekts eine Echtzeitansicht davon anstelle eines Zustandsbildes zum Zeitpunkt der Protokollierung, wodurch es so aussieht, als wäre die Protokollierung rückwärts in der Zeit erfolgt. Das Protokollieren von JSON.stringify(obj) oder einem strukturierten Klon ist eine schnelle Methode, um während des Debuggens ein echtes Zeitpunkt-Bild zu erhalten.
Um in Code ein echtes Abbild zu erstellen, reicht eine neue Variable nicht aus – die Tiefe der Kopie hängt von der Struktur dessen ab, was geschützt werden soll. Eine flache Kopie mittels Spread-Operator oder Object.assign trennt die Eigenschaften der obersten Ebene, teilt sich aber weiterhin eingebettete Objekte und Arrays. Eine tiefe Kopie trennt noch mehr Elemente, kann jedoch aufwändig sein, Klassenprototypen und -methoden verlieren sowie Referenzen duplizieren, die eigentlich geteilt werden sollten.
Oft ist die sauberere Lösung gar nicht zu klonen, sondern nur die kleinen, unveränderlichen Werte herauszunehmen, die für die Operation benötigt werden.
const retryLimit = settings.retries;
setTimeout(() => {
console.log(retryLimit);
}, 100);
Der Callback liest nun eine Bindung, deren Wert sich niemals ändern wird – und genauso wichtig ist, dass der Code angibt, auf welchen historischen Kontext sich der Timer bezieht.
Geben Sie verzögerten Code, der große, veränderliche Objekte bearbeitet, mit Misstrauen gegenüber. Anfragen nach Kontexten, Konfigurationskontainern, Zustandsobjekten von Komponenten sowie gemeinsamen Caches sind typische Beispiele. Ein Callback, der später auf eines dieser Objekte zugreift, ist stumm von jeder Zwischenmutation abhängig. Das Übergeben eines eingeschränkten Datensatzes an den verzögerten Code bietet einen viel klareren Vertrag.
React zeigt das gegenteilige Versagen
In React deuten Schließungsfehler in der Regel in eine andere Richtung. Der Callback liest keinen zu neuen Wert, sondern weiterhin einen zu alten.
Jede Ausführung eines Funktionskomponenten ist ein neuer Funktionsaufruf mit eigenen lokalen Bindungen für Props, Zustand und abgeleitete Werte. Ein Callback, der während einer Ausführung erstellt wird, bezieht sich auf die Bindungen dieser Ausführung. Wenn sich der Zustand ändert, ruft React die Komponente erneut auf und erstellt neue Bindungen, doch jeder noch aktive Callback aus der früheren Ausführung verweist weiterhin auf die alten Bindungen.
Die Symptome sind vertraut:
- Ein in einer Ausführung erstellter Timer protokolliert für immer einen veralteten Wert.
- Ein einmal registrierter Event-Listener verwendet weiterhin einen Prop aus der ersten Ausführung.
- Ein Effect mit einer unvollständigen Abhängigkeitsliste ruft weiterhin eine Funktion auf, die sich auf veralteten Zustand bezieht.
Das wird als „veraltete Schließung“ bezeichnet, doch die Schließung ist nicht beschädigt. Sie bleibt ihrem ursprünglichen Aufruf treu, und nichts im Programmiersprachkonzept bewegt sie voran, wenn React erneut ausführt.
Dies mag im Widerspruch zu den früheren Beispielen stehen, in denen die Closures die Aktualisierungen problemlos mitbekamen. Der Unterschied liegt erneut in der Bindungsidentität. Im Logger-Beispiel wurde eine Bindung neu zugewiesen, und die Closure erhielt den neuen Wert. React weist die alten Bindungen nicht um; es erstellt bei jeder Renderung völlig neue Umgebungen. Der alte Callback bleibt an die alte Umgebung gebunden, deren Werte sich niemals ändern.
Wenn man es so betrachtet, ergeben sich die Lösungen aus der Aufgabe des Callbacks:
- Eine Zustandsaktualisierung, die vom aktuellen Wert abhängt, kann die funktionale Form verwenden, wie zum Beispiel
setCount(c => c + 1), sodass React den neuesten Zustand bereitstellt. - Eine Abonnierung, die den neuesten Wert benötigt, kann neu erstellt werden, wenn sich ihre Abhängigkeiten ändern, oder sie kann von einem absichtlich gepflegten ref gelesen werden.
Die Frage bleibt dieselbe wie zuvor: Soll dieses verzögerte Verhalten den historischen Zustand oder den aktuellen Zustand verwenden? Viele React-Bugs entstehen dadurch, dass diese Frage unbeabsichtigt beantwortet wird, indem man eine Abhängigkeitsliste ändert, anstatt darüber nachzudenken, was die Funktion eigentlich tun soll. Neuere React-Versionen bieten außerdem useEffectEvent, um die neuesten Werte innerhalb eines Effects ohne Neustart abzurufen; die Abschaffung des latest-value-Refs mit useEffectEvent erläutert dieses Muster.
Abhängigkeitslisten beschreiben die Realität, nicht Präferenzen
Eine gängige Methode, um veraltete Schließungen zu bekämpfen, besteht darin, den Abhängigkeitsarray eines Effekts anzupassen, bis sich das Verhalten richtig verhält. Ein Wert wird hinzugefügt, weil der Linter sich beschwert, wieder entfernt, weil der Effekt zu oft ausgeführt wird, und letztendlich wird das Array leer, weil der Effekt „nur einmal ausgeführt werden sollte“.
Dabei wird das Array wie ein Frequenzregler behandelt. Tatsächlich handelt es sich dabei um eine Angabe darüber, welche Render-Werte die Schließung des Effekts liest.
Falls ein Effekt eine Variable aus dem umgebenden Render verwendet, gehört diese Variable zu seiner Schließung – unabhängig davon, ob sie im Array erscheint oder nicht. Sie wegzulassen beseitigt die Abhängigkeit nicht. Es wird lediglich sichergestellt, dass der Effekt weiterhin die Version verwendet, die zuletzt von irgendeinem Render gesetzt wurde.
Die Einbeziehung aller Abhängigkeiten kann ein anderes Problem aufwerfen: Der Effekt zerstört und erstellt eine Abonnementverbindung, startet einen Timer erneut oder sendet Anfragen weitaus häufiger, als beabsichtigt. Das lässt sich leicht als zu strenge Prüfung durch den Linter interpretieren. Häufiger ist es jedoch ein Zeichen dafür, dass der Effekt mehrere Aufgaben erledigt oder dass ein Objekt oder eine Funktion, von der er abhängt, ohne Grund bei jeder Darstellung neu erstellt wird.
Typische Lösungsansätze sind:
- Die Stabilisierung eines Callbacks, sodass er nur dann geändert wird, wenn seine eigenen Eingaben sich ändern
- Die Aufteilung eines Effekts in mehrere mit engeren Verantwortlichkeiten
- Das Verschieben einer Hilfsfunktion in den Effekt, damit sie keine externe Abhängigkeit mehr darstellt
- Die Verwendung einer funktionalen Zustandsaktualisierung anstelle des direkten Lesens des Zustands
- Die Überprüfung, ob die Logik überhaupt als Effekt implementiert werden muss
Ziel ist es nicht, React dazu zu bringen, den Effekt in einem gewünschten Rhythmus auszuführen. Vielmehr soll dem Effekt eine Schließfunktion gegeben werden, deren Lebensdauer mit dem Verhalten übereinstimmt, für das er verantwortlich ist. Wenn ein Effekt frische Werte benötigt, ohne jedes Mal abgebaut zu werden, sobald sich diese ändern, sollte ihm eine gezielte Möglichkeit zur Lesezugriff auf diese Werte gegeben werden – beispielsweise über einen ref oder ein Effektereignis. Falls der Effekt bei einem Wertwechsel neu gestartet werden soll, gehört dieser Wert in den Array. Und wenn der Effekt einen Wert tatsächlich nicht benötigt, sollte der Lesezugriff entfernt werden und nicht die Abhängigkeit.
Probleme mit Abhängigkeiten sind ein Design-Feedback. Das problematische Verhalten tritt auf, wenn der Code einer Schließfunktion eine bestimmte Lebensdauer zuweist, während die Funktion selbst eine andere benötigt.
Ereignislistener überdauern den Kontext, der sie erstellt hat
Durch die Konzeption entsteht zwischen der Registrierung und der Ausführung eines Listeners eine Lücke. Der Handler wird einmal angehängt und läuft jedes Mal, wenn das Ereignis ausgelöst wird – möglicherweise lange nachdem sich die umgebenden Variablen geändert haben.
In reinem JavaScript sieht ein Listener, der eine auf Modulebene liegende Variable liest, ihren neuesten Wert. In einem Komponentenframework behält ein während einer frühen Renderung angehängter Listener die Bindungen dieses Renders bei. In beiden Fällen ist diese Beziehung leicht übersehen zu werden, da der Handler nur dann läuft, wenn der Benutzer später etwas ausführt.
Die Reinigung fügt eine weitere Dimension hinzu. Wenn bei jeder Darstellung ein neuer Zuhörer hinzugefügt wird, ohne den vorherigen zu entfernen, reagieren mehrere Schließfunktionen letztendlich auf dasselbe Ereignis, wobei jede eine andere Version des Zustands enthält. Ein einziger Klick kann dann zu mehreren Ergebnissen führen, die aus verschiedenen Zeitpunkten in der Historie der Anwendung stammen. Das sichtbare Ergebnis kann eine doppelte Aktualisierung, das Wiedererscheinen eines alten Wertes oder die Ausführung eines Handlers nachdem seine Komponente bereits abgemontiert wurde sein. Die Ursache in jedem Fall ist, dass die Lebensdauer des Zuhörers niemals mit der Lebensdauer des Zustands übereinstimmte, von dem er abhängt.
Verlässlicher Zuhörercode macht das Eigentumsverhältnis explizit:
- Bewahren Sie eine Referenz auf die genaue Funktion auf, die Sie registriert haben, denn
removeEventListenerbenötigt dieselbe Referenz.
Das alles ist keine Formalität. Das Registrieren eines Callbacks schafft eine Verbindung zwischen zukünftigen Ereignissen und dem derzeit verfügbaren Umfeld. Wenn diese Verbindung beendet werden soll, muss der Code dies tun.
Asynchrone Antworten übertragen alte Kontexte auf ein neues Bildschirm
Netzwerkanfragen verursachen einige der kostspieligsten Fehler aufgrund veralteter Kontexte. Eine Anfrage wird gestartet, während der Benutzer sich mit einer Suchanfrage, einem Projekt oder einer Route beschäftigt. Bevor sie abgeschlossen ist, wechselt der Benutzer an einen anderen Ort. Wenn die Antwort eintrifft, schreibt ihr Callback in den gemeinsamen Zustand unter Verwendung des Kontexts, der zu Beginn erfasst wurde.
Mannchmal ist dieser historische Kontext genau richtig. Eine Anfrage für Projekt 42 sollte auch dann weiterhin mit Projekt 42 in Verbindung bleiben, nachdem das aktive Projekt zu 84 geworden ist. Die Gefahr besteht darin, dass dieses Ergebnis eine Anzeige aktualisiert, die inzwischen auf Projekt 84 umgeschaltet wurde. Die Erinnerung an die ursprüngliche Anfrage ist es, die die Abschlusslogik erfüllt; der Fehler liegt darin, anzunehmen, dass eine abgeschlossene Antwort automatisch weiterhin gewünscht wird.
Deshalb gehören die Abschlusslogik und das asynchrone Eigentumskonzept zusammen. Ein Callback kann perfekt korrekte historische Werte enthalten und dennoch kein Recht haben, die Zielanzeige zu aktualisieren. Häufige Schutzmechanismen sind:
- eine Anfrage-ID oder Sequenznummer, die vor der Anwendung eines Ergebnisses verglichen wird
- ein
AbortController-Signal, das die Arbeit abbricht, wenn der Benutzer fortfährt - eine Überprüfung, ob die aktuelle Route oder Auswahl immer noch zur Anfrage passt
Einfaches Umschreiben des Callbacks, um das aktuellste Projekt zu lesen, kann die Situation verschlimmern: Die Antwort für Projekt 42 würde unter Projekt 84 gespeichert werden. Das Lesen des aktuellen Zustands ist keine universelle Lösung. Bewahren Sie die Identität der Anfrage bei und überprüfen Sie, ob das Ziel den Ergebnis immer noch haben möchte, bevor Sie es speichern.
Halten Sie zwei Kontexte getrennt. Der Operationskontext gehört zu den Daten, für die die Anfrage gestellt wurde. Der Interface-Kontext gehört zu dem, was der Benutzer gerade ansieht. Der Bildschirm sollte nur aktualisiert werden, wenn die beiden Kontexte weiterhin übereinstimmen. Ohne diese Trennung wirken Callbacks wie Zeitreisen – sie liefern gültige Informationen aus einem früheren Moment in ein bereits weitergegangenes Ansichtsfeld.
Auswahl zwischen Snapshot und Echtzeitzugriff
Die meisten dieser Fehler werden einfach zu handhaben, sobald das Team die gewünschte Beziehung benennt.
Eine Schnappschuss-Funktion bedeutet, dass die verzögert ausgeführte Aufgabe Daten verwendet, wie sie zum Zeitpunkt der Erstellung vorlagen. Dies gilt für Transaktions IDs, die ID des ausgewählten Datensatzes, die Werte des eingereichten Formulars, den Prüfungskontext sowie Befehle, deren ursprüngliche Absicht beibehalten werden muss. Implementieren lässt sich dies, indem Werte als Argumente übergeben werden, unveränderliche Datenpakete erstellt werden oder nur die für die Operation notwendigen Felder kopiert werden.
Livenzugriff bedeutet, dass die verzögert ausgeführte Aufgabe den neuesten Wert zum Zeitpunkt ihrer Ausführung verwendet. Dies gilt für den Verbindungsstatus, die aktuellste Konfiguration, bestimmte aktuelle UI-Zustände innerhalb von Ereignishandlern sowie veränderliche Koordinationswerte. Implementieren lässt sich dies über eine gemeinsame Bindung, einen Referenzwert, einen Zugriff auf einen Speicher oder eine andere Quelle, die ausdrücklich als „aktuell“ gekennzeichnet ist.
Im Allgemeinen ist auch keines sicherer. Fehler treten auf, wenn der Code das eine umsetzt, während der Entwickler vom anderen ausgeht.
Zwei Gewohnheiten machen die Wahl im Code sichtbar. Erstens sollten Schließfunktionen vermieden werden, die achtlos auf alles im Scope zugreifen; eine weite Schließfunktion versteckt ihre Abhängigkeiten, sodass ein Leser nicht erkennen kann, welche davon festgehalten und welche aktuell bleiben sollen. Zweitens sollten kleine Funktionen mit begrenzten Parametern bevorzugt werden, da dies die Anzahl der Variablen verringert, über deren zeitliche Semantik man nachdenken muss. Beides verbessert aus demselben Grund die Zuverlässigkeit von Asynchronprogrammierung: weniger implizite Beziehungen zur Zeit.
Eine kurze Überprülliste für jeden später ausgeführten Callback:
- Welche äußeren Variablen liest er?
- Soll er für jede von ihnen den Wert bei der Erstellung oder bei der Ausführung sehen?
- Gibt es darunter Objekte, deren Inhalt sich dazwischen ändern könnte?
Zusammenfassung
Closures in JavaScript sind deterministisch. Sie folgen dem lexikalischen Scope, behalten Bindungen bei und halten Umgebungen so lange am Leben, wie eine erreichbare Funktion sie benötigt. Was ihnen ein unheimliches Gefühl verleiht, ist die Behandlung einer Variablen, als hätte sie über die Zeit hinweg nur einen einzigen sinnvollen Wert.
Jedes der oben genannten Szenarien ist eine Variante derselben Frage: Zu welchem Zeitpunkt sollte dieser Callback gehören? Eine Schleife kann allen ihren Callbacks eine gemeinsame Bindung zuweisen. Ein Timer kann ein Objekt lesen, nachdem es bereits verändert wurde. Ein React-Callback kann weiterhin mit einer früheren Renderung verbunden bleiben. Ein Listener kann länger bestehen als der Zustand, für den er geschrieben wurde. Eine asynchrone Antwort kann die ursprüngliche Absicht in eine bereits weiterentwickelte Ansicht übertragen.
Erfahrene Entwickler stoßen immer noch auf solche Fehler, weil moderne Anwendungen die Ausführung von Aufgaben ständig hinauszögern. Zeitlimits, Promise-Ketten, DOM-Events, Abonnements, erneute Darstellungen, Job-Queues und HTTP-Antworten sorgen jeweils dafür, dass zwischen der Definition einer Funktion und ihrer Ausführung eine Lücke entsteht – je länger diese Lücke, desto mehr verändert sich die Umgebung. Die Lösung besteht nicht darin, sich eine weitere Definition von Closures einzuprägen. Stattdessen sollten Zeitpunkt und Verantwortlichkeit klar definiert werden: Wählen Sie bewusst zwischen Snapshot- oder Echtzeitzugriff, speichern Sie nur die historischen Werte, die für die Aufgabe benötigt werden, geben Sie dem aktuellen Zustand eine gezielte Quelle, verknüpfen Sie die Lebensdauer jedes Callbacks mit dem Element, das ihn steuert, und verhindern Sie, dass veraltete Aufgaben an Stellen schreiben, die sie nicht mehr kontrollieren. Wenn diese Entscheidungen im Code sichtbar sind, überraschen Closures Sie nicht mehr, denn sie sind endlich mit dem von Ihnen beabsichtigten Moment verbunden.