Es gibt einen Aspekt von JavaScript, der mich immer wieder zur Verzweiflung treibt: Closures. Ich arbeite viel mit React, und die Überschneidung besteht darin, dass sie manchmal die Ursache für veraltete Props und Zustände sein können. Wir werden uns damit beschäftigen, was das genau bedeutet, aber das Problem ist, dass die Daten, die wir zum Erstellen unserer UI verwenden, auf unerwartete Weise völlig falsch sein können, was, wissen Sie, schlecht ist.
Veraltete Props und Zustände
Kurz gesagt: Es ist, wenn Code, der asynchron ausgeführt wird, eine Referenz auf eine Prop oder einen Zustand hat, der nicht mehr *frisch* ist, und somit der Wert, den er zurückgibt, nicht der aktuellste ist.
Um es noch klarer zu machen, spielen wir mit dem gleichen Beispiel für veraltete Referenzen, das React in seiner Dokumentation hat.
function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
Nichts Besonderes hier. Wir haben eine Funktionskomponente namens Counter. Sie zählt, wie oft der Benutzer auf eine Schaltfläche geklickt hat, und zeigt eine Benachrichtigung an, die anzeigt, wie oft diese Schaltfläche geklickt wurde, wenn auf eine andere Schaltfläche geklickt wird. Probieren Sie das aus.
- Klicken Sie auf die Schaltfläche „Klick mich“. Sie werden sehen, wie der Klickzähler hochzählt.
- Klicken Sie nun auf die Schaltfläche „Alarm anzeigen“. Drei Sekunden sollten vergehen, und dann wird ein Alarm ausgelöst, der Ihnen mitteilt, wie oft Sie auf die Schaltfläche „Klick mich“ geklickt haben.
- Klicken Sie nun erneut auf die Schaltfläche „Alarm anzeigen“ und klicken Sie schnell auf die Schaltfläche „Klick mich“, bevor der Alarm in drei Sekunden ausgelöst wird.
Sehen Sie, was passiert? Die auf der Seite angezeigte Anzahl und die im Alarm angezeigte Anzahl stimmen nicht überein. Die Zahl im Alarm ist jedoch keine zufällige Zahl. Diese Zahl ist der Wert, den die Variable count zum Zeitpunkt der Definition der asynchronen Funktion innerhalb von setTimeout hatte, was der Zeitpunkt ist, an dem die Schaltfläche „Alarm anzeigen“ geklickt wird.
So funktionieren Closures nun mal. Wir werden uns in diesem Beitrag nicht auf die Einzelheiten eingehen, aber hier sind einige Dokumente, die sie detaillierter behandeln.
Konzentrieren wir uns darauf, wie wir diese veralteten Referenzen mit unseren Zuständen und Props vermeiden können.
React gibt in derselben Dokumentation, aus der das Beispiel entnommen wurde, einen Tipp, wie man mit veralteten Daten und Props umgeht.
Wenn Sie absichtlich den neuesten Zustand aus einem asynchronen Callback lesen möchten, könnten Sie ihn in einer
refspeichern, ihn mutieren und daraus lesen.
Indem wir den Wert *asynchron* in einer ref speichern, können wir veraltete Referenzen umgehen. Wenn Sie mehr über ref in Funktionskomponenten erfahren müssen, bietet die Dokumentation von React viele weitere Informationen.
Das wirft also die Frage auf: Wie können wir unsere Props oder Zustände in einer ref speichern?
Machen wir es zuerst auf die unsaubere Art.
Die unsaubere Art, Props und Zustände in einer Ref zu speichern
Wir können eine Ref einfach mit useRef() erstellen und count als Anfangswert verwenden. Dann setzen wir überall dort, wo der Zustand aktualisiert wird, die Eigenschaft ref.current auf den neuen Wert. Schließlich verwenden wir ref.current anstelle von count im asynchronen Teil unseres Codes.
function Counter() {
const [count, setCount] = useState(0);
const ref = useRef(count); // Make a ref and give it the count
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + ref.current); // Use ref instead of count
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button
onClick={() => {
setCount(count + 1);
ref.current = count + 1; // Update ref whenever the count changes
}}
>
Click me
</button>
<button
onClick={() => {
handleAlertClick();
}}
>
Show alert
</button>
</div>
);
}
Machen Sie dasselbe wie beim letzten Mal. Klicken Sie auf „Alarm anzeigen“ und dann auf „Klick mich“, bevor der Alarm in drei Sekunden ausgelöst wird.
Jetzt haben wir den neuesten Wert!
Das ist der Grund, warum es funktioniert. Wenn die asynchrone Callback-Funktion innerhalb von setTimeout definiert wird, speichert sie eine Referenz auf die von ihr verwendeten Variablen, in diesem Fall count. Auf diese Weise ändert React beim Aktualisieren des Zustands nicht nur den Wert, sondern die Variablenreferenz im Speicher ist auch völlig anders.
Das bedeutet, dass – selbst wenn der Wert des Zustands nicht primitiv ist – die Variable, mit der Sie in Ihrem asynchronen Callback arbeiten, nicht dieselbe im Speicher ist. Ein Objekt, das normalerweise seine Referenz über verschiedene Funktionen hinweg beibehält, hat jetzt einen anderen Wert.
Wie löst die Verwendung einer ref dieses Problem? Wenn wir uns die React-Dokumentation noch einmal ansehen, finden wir eine interessante, aber leicht zu übersehende Information:
[…]
useRefgibt Ihnen bei jedem Render dieselberef-Objekt zurück.
Es spielt keine Rolle, was wir tun. Während der gesamten Lebensdauer Ihrer Komponente gibt uns React dasselbe Ref-Objekt im Speicher zurück. Jeder Callback, egal wann er definiert oder ausgeführt wird, arbeitet mit demselben Objekt. Keine veraltete Referenz mehr.
Die sauberere Art, Props und Zustände in einer Ref zu speichern
Seien wir ehrlich... eine ref auf diese Weise zu verwenden, ist eine hässliche Lösung. Was passiert, wenn Ihr Zustand an tausend verschiedenen Stellen aktualisiert wird? Jetzt müssen Sie Ihren Code ändern und die ref manuell an all diesen Stellen aktualisieren. Das ist ein No-Go.
Wir werden dies skalierbarer machen, indem wir der ref automatisch den Wert des Zustands geben, wenn sich der Zustand ändert.
Beginnen wir damit, die manuelle Änderung der ref in der Schaltfläche „Klick mich“ zu entfernen.
Als Nächstes erstellen wir eine Funktion namens updateState, die aufgerufen wird, wenn wir den Zustand ändern müssen. Diese Funktion nimmt den neuen Zustand als Argument, setzt die Eigenschaft ref.current auf den neuen Zustand und aktualisiert gleichzeitig den Zustand mit demselben Wert.
Schließlich ersetzen wir die ursprüngliche setCount-Funktion, die React uns zur Verfügung stellt, durch die neue updateState-Funktion, wo der Zustand aktualisiert wird.
function Counter() {
const [count, setCount] = useState(0);
const ref = useRef(count);
// Keeps the state and ref equal
function updateState(newState) {
ref.current = newState;
setCount(newState);
}
function handleAlertClick() { ... }
return (
<div>
<p>You clicked {count} times</p>
<button
onClick={() => {
// Use the created function instead of the manual update
updateState(count + 1);
}}
>
Click me
</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
Verwendung eines benutzerdefinierten Hooks
Die sauberere Lösung funktioniert gut. Sie erledigt die Aufgabe genauso wie die unsaubere Lösung, ruft aber nur eine einzige Funktion auf, um den Zustand und die ref zu aktualisieren.
Aber wissen Sie was? Wir können es noch besser machen. Was ist, wenn wir weitere Zustände hinzufügen müssen? Was ist, wenn wir das auch in anderen Komponenten tun wollen? Nehmen wir den Zustand, die ref und die Funktion updateState und machen sie wirklich portabel. Benutzerdefinierte Hooks zur Rettung!
Außerhalb der Counter-Komponente definieren wir eine neue Funktion. Nennen wir sie useAsyncReference. (Sie kann eigentlich beliebig benannt werden, aber beachten Sie, dass es üblich ist, benutzerdefinierte Hooks mit dem Präfix „use“ zu benennen.) Unser neuer Hook hat vorerst einen einzigen Parameter. Wir nennen ihn value.
Unsere vorherige Lösung hatte dieselbe Information doppelt gespeichert: einmal im Zustand und einmal in der ref. Wir optimieren das, indem wir den Wert dieses Mal nur in der ref speichern. Mit anderen Worten, wir erstellen eine ref und geben ihr den Parameter value als Anfangswert.
Direkt nach der ref erstellen wir eine Funktion updateState, die den neuen Zustand nimmt und ihn der Eigenschaft ref.current zuweist.
Schließlich geben wir ein Array mit ref und der Funktion updateState zurück, sehr ähnlich zu dem, was React mit useState macht.
function useAsyncReference(value) {
const ref = useRef(value);
function updateState(newState) {
ref.current = newState;
}
return [ref, updateState];
}
function Counter() { ... }
Wir vergessen etwas! Wenn wir die Dokumentation zu useRef prüfen, erfahren wir, dass das Aktualisieren einer ref keinen Re-Render auslöst. Solange ref den aktualisierten Wert hat, sehen wir die Änderungen nicht auf dem Bildschirm. Wir müssen jedes Mal einen Re-Render erzwingen, wenn ref aktualisiert wird.
Was wir brauchen, ist ein Fake-State. Der Wert spielt keine Rolle. Er dient nur dazu, den Re-Render auszulösen. Wir können sogar den Zustand ignorieren und nur seine Update-Funktion behalten. Wir nennen diese Update-Funktion forceRender und geben ihr den Anfangswert false.
Nun erzwingen wir innerhalb von updateState den Re-Render, indem wir forceRender aufrufen und ihm einen anderen Zustand als den aktuellen übergeben, nachdem wir ref.current auf newState gesetzt haben.
function useAsyncReference(value) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
ref.current = newState;
forceRender(s => !s);
}
return [ref, updateState];
}
function Counter() { ... }
Nehmen Sie, welchen Wert er auch immer hat, und geben Sie das Gegenteil zurück. Der Zustand spielt keine wirkliche Rolle. Wir ändern ihn nur, damit React eine Zustandsänderung erkennt und die Komponente neu rendert.
Als Nächstes können wir die Count-Komponente bereinigen und die zuvor verwendete useState, ref und updateState-Funktion entfernen, dann den neuen Hook implementieren. Der erste Wert des zurückgegebenen Arrays ist der Zustand in Form einer ref. Wir werden ihn weiterhin count nennen, wobei der zweite Wert die Funktion zum Aktualisieren des Zustands/der ref ist. Wir werden ihn weiterhin setCount nennen.
Wir müssen auch die Referenzen auf den Zähler ändern, da diese nun alle count.current sein müssen. Und wir müssen setCount aufrufen, anstatt updateState aufzurufen.
function useAsyncReference(value) { ... }
function Counter() {
const [count, setCount] = useAsyncReference(0);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count.current);
}, 3000);
}
return (
<div>
<p>You clicked {count.current} times</p>
<button
onClick={() => {
setCount(count.current + 1);
}}
>
Click me
</button>
<button onClick={handleAlertClick}>Show alert</button>
</div>
);
}
Dies mit Props funktionsfähig machen
Wir haben eine wirklich portable Lösung für unser Problem. Aber wissen Sie was... es gibt noch etwas mehr zu tun. Insbesondere müssen wir die Lösung mit Props kompatibel machen.
Nehmen wir die Schaltfläche „Alarm anzeigen“ und die Funktion handleAlertClick in eine neue Komponente außerhalb der Counter-Komponente. Wir nennen sie Alert und sie erhält eine einzige Prop namens count. Diese neue Komponente zeigt den count-Prop-Wert, den wir ihr übergeben, nach einer Verzögerung von drei Sekunden in einem Alarm an.
function useAsyncReference(value) { ... }
function Alert({ count }) {
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
}
return <button onClick={handleAlertClick}>Show alert</button>;
}
function Counter() { ... }
In Counter ersetzen wir die Schaltfläche „Alarm anzeigen“ durch die Alert-Komponente. Wir übergeben count.current an die count-Prop.
function useAsyncReference(value) { ... }
function Alert({ count }) { ... }
function Counter() {
const [count, setCount] = useAsyncReference(0);
return (
<div>
<p>You clicked {count.current} times</p>
<button
onClick={() => {
setCount(count.current + 1);
}}
>
Click me
</button>
<Alert count={count.current} />
</div>
);
}
In Ordnung, Zeit, die Testschritte erneut durchzugehen. Sehen Sie? Auch wenn wir eine sichere Referenz auf den Zähler in Counter verwenden, ist die Referenz auf die count-Prop in der Alert-Komponente nicht asynchron sicher und unser benutzerdefinierter Hook ist noch nicht für die Verwendung mit Props geeignet.
Glücklicherweise ist die Lösung recht einfach.
Alles, was wir tun müssen, ist, einen zweiten Parameter zu unserem useAsyncReference-Hook namens isProp hinzuzufügen, mit false als Anfangswert. Kurz bevor wir das Array mit ref und updateState zurückgeben, richten wir eine Bedingung ein. Wenn isProp true ist, setzen wir die Eigenschaft ref.current auf value und geben nur ref zurück.
function useAsyncReference(value, isProp = false) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
ref.current = newState;
forceRender(s => !s);
}
if (isProp) {
ref.current = value;
return ref;
}
return [ref, updateState];
}
function Alert({ count }) { ... }
function Counter() { ... }
Aktualisieren wir nun Alert so, dass es den Hook verwendet. Denken Sie daran, true als zweiten Argument an useAsyncReference zu übergeben, da wir eine Prop und keine State übergeben.
function useAsyncReference(value) { ... }
function Alert({ count }) {
const asyncCount = useAsyncReference(count, true);
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + asyncCount.current);
}, 3000);
}
return <button onClick={handleAlertClick}>Show alert</button>;
}
function Counter() { ... }
Versuchen Sie es noch einmal. Jetzt funktioniert es perfekt, egal ob Sie Zustände oder Props verwenden.
Noch eine Sache…
Es gibt eine letzte Änderung, die ich vornehmen möchte. Die Dokumentation von Reacts useState besagt, dass React auf einen Re-Render verzichtet, wenn der neue Zustand mit dem vorherigen identisch ist. Unsere Lösung tut das nicht. Wenn wir den aktuellen Zustand erneut an die updateState-Funktion des Hooks übergeben, erzwingen wir unabhängig davon einen Re-Render. Ändern wir das.
Packen wir den Körper von updateState in eine if-Anweisung und führen wir ihn aus, wenn ref.current sich vom neuen Zustand unterscheidet. Der Vergleich muss mit Object.is() erfolgen, genau wie React es tut.
function useAsyncReference(value, isProp = false) {
const ref = useRef(value);
const [, forceRender] = useState(false);
function updateState(newState) {
if (!Object.is(ref.current, newState)) {
ref.current = newState;
forceRender(s => !s);
}
}
if (isProp) {
ref.current = value;
return ref;
}
return [ref, updateState];
}
function Alert({ count }) { ... }
function Counter() { ... }
Jetzt sind wir endlich fertig!
React kann manchmal wie eine Blackbox erscheinen, die voller kleiner Eigenheiten ist. Diese Eigenheiten können einschüchternd sein, wie die, die wir gerade behandelt haben. Aber wenn Sie geduldig sind und Herausforderungen mögen, werden Sie bald feststellen, dass es ein großartiges Framework und eine Freude ist, damit zu arbeiten.
Sie können auch
useEffectverwendenz.B.
Das Problem bei dieser Vorgehensweise ist, dass die Ref nach dem Render aktualisiert wird, wodurch Sie immer einen Schritt hinterherhinken.
Sie müssten den Zustand für den Render und die Ref für asynchrone Callbacks beibehalten.
Die einzige Verbesserung, die ich sehe, wäre, den Rückgabewert des Hooks so zu gestalten, dass er denselben Typ wie der initialisierte Wert hat. Anstatt
[ref, updateState]zurückzugeben, tun Sie[ref.current, updateState]. Es fühlt sich nicht so an, als ob eine Komponente, die diesen Hook verwendet, wissen müsste, dass es sich um eine Ref handelt, da dies keine Funktionalität dafür bietet.Nun, das Problem hier ist, dass Refs keinen speziellen Mechanismus haben, um zu verhindern, dass ein veralteter Wert in der Closure verbleibt. Es ist nur ein Objekt, also ist der "veraltete" Wert nur eine Referenz, die sich sowieso nicht ändert.
Wenn Sie
ref.currentaus dem Hook zurückgeben, machen Sie die gesamte "Objekt in Closure speichern"-Logik im Grunde ungültig, da Ihre Komponente nur den aktuellen Wert der Ref erhält, der in der Closure gespeichert ist (wie oft habe ich schon "Closure" gesagt?). Auf diese Weise würden Sie nur einen sehr ausgeklügeltensetState-Klon erstellen.Oder... Sie können var anstelle von const verwenden :)
Das würde auch funktionieren, oder?
Meine Idee ist, die
handleAlertClickzu abstrahieren, da sie nicht von unserer UI-Logik abhängt, und dann den Zugriff auf den Zustand über einen Getter freizugeben, anstatt über den aufgelösten Wert.Gute Arbeit, das ist die Art von Sache, die Menschen beunruhigen kann! Guter Ansatz!
React.useCallback kann in solchen Situationen ebenfalls nützlich sein.
Danke Pedro, das hat mir wirklich geholfen zu verstehen, wie man Refs benutzt!
Ich habe ein Diagramm erstellt, um zu versuchen, die Datenbewegung zu visualisieren, falls jemand interessiert ist. Es ist schwierig, alle Verbindungen darzustellen, daher habe ich versucht, die wichtigen zu priorisieren.
https://www.dropbox.com/s/z656mdqwe5rdyne/refs%20and%20stale%20props-01.pdf?dl=0
Wäre es korrekt, wenn ich denke, dass die Alert-Komponente ihre eigene Ref erstellt, diese Ref dann aber mit dem .current-Wert der Counter-Ref aktualisiert?
Ich verstehe immer noch nicht, warum
reffunktioniert. Diese Erklärung scheint im Widerspruch zu dem zu stehen, was die React-Doku sagt.Dieser Teil hat mich auch verwirrt! Ich habe es mehrmals lesen müssen, um zu verstehen, dass er, obwohl dieser Absatz mit "Hier ist, warum es funktioniert" beginnt, eigentlich darüber spricht, warum die alte Version *nicht* funktioniert.
Der Hinweis darauf ist, dass er sagt,
setTimeoutspeichere durch die Verwendung eine Referenz aufcount, aber das gilt für den früheren fehlerhaften Code, nicht für die behobene Version – diese verwendet nurref, nichtcount.Für die
isProp-Option, wenn man den gesamten ungenutzten Code entfernt, ist es dann nicht genau dasselbe wie die Verwendung vonuseRef?