Ein weit verbreiteter Ansatz bei der Entwicklung von React-Anwendungen besteht darin, alles mit Memoisierung abzudecken. Viele Entwickler wenden diese Optimierungstechnik großzügig an und kapseln Komponenten in Memoisierung ein, um unnötige Neudarstellungen zu vermeiden. Oberflächlich betrachtet scheint dies eine narrensichere Strategie zur Leistungssteigerung zu sein. Das Team der Social Discovery Group hat jedoch festgestellt, dass Memoisierung bei falscher Anwendung tatsächlich auf unerwartete Weise scheitern kann, was zu Leistungsproblemen führt. In diesem Artikel untersuchen wir die überraschenden Stellen, an denen Memoisierung ins Stocken geraten kann, und wie Sie diese versteckten Fallen in Ihren React-Anwendungen vermeiden können.
Memoisierung ist eine Optimierungstechnik in der Programmierung, bei der die Ergebnisse aufwändiger Operationen gespeichert und wiederverwendet werden, wenn dieselben Eingaben erneut vorkommen. Das Wesentliche bei der Memoisierung ist, redundante Berechnungen für dieselben Eingabedaten zu vermeiden. Diese Beschreibung trifft tatsächlich auf traditionelle Memoisierung zu. Sie können im Codebeispiel sehen, dass alle Berechnungen zwischengespeichert werden.
const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }
Memoisierung bietet mehrere Vorteile, darunter Leistungsverbesserungen, Ressourceneinsparungen und Ergebnis-Caching. Allerdings funktioniert die Memoisierung von React, bis neue Props eingehen, was bedeutet, dass nur das Ergebnis des letzten Aufrufs gespeichert wird.
const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }
Die React-Bibliothek stellt uns mehrere Tools zur Memoisierung zur Verfügung. Dies sind der HOC React.memo, die Hooks useCallback, useMemo und useEvent sowie React.PureComponent und die Lifecycle-Methode von Klassenkomponenten, shouldComponentUpdate. Sehen wir uns die ersten drei Memoisierungstools an und erkunden wir ihre Zwecke und Verwendung in React.
Reagieren.memo
Diese Higher-Order-Komponente (HOC) akzeptiert eine Komponente als erstes Argument und eine optionale Vergleichsfunktion als zweites. Die Vergleichsfunktion ermöglicht einen manuellen Vergleich vorheriger und aktueller Eigenschaften. Wenn keine Vergleichsfunktion bereitgestellt wird, verwendet React standardmäßig oberflächliche Gleichheit. Es ist wichtig zu verstehen, dass oberflächliche Gleichheit nur einen oberflächlichen Vergleich durchführt. Wenn die Eigenschaften Referenztypen mit nicht konstanten Referenzen enthalten, löst React daher ein erneutes Rendern der Komponente aus.
const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })
React.useCallback
Der useCallback-Hook ermöglicht es uns, den Verweis auf eine Funktion beizubehalten, die während des ersten Renderns übergeben wurde. Bei nachfolgenden Renderings vergleicht React die Werte im Abhängigkeitsarray des Hooks und gibt, wenn sich keine der Abhängigkeiten geändert hat, denselben zwischengespeicherten Funktionsverweis wie beim letzten Mal zurück. Mit anderen Worten: useCallback speichert den Verweis auf die Funktion zwischen Renderings zwischen, bis sich ihre Abhängigkeiten ändern.
const callback = useCallback(() => { // do something }, [a, b, c])
Reagieren.useMemo
Mit dem useMemo-Hook können Sie das Ergebnis einer Berechnung zwischen Renderings zwischenspeichern. Normalerweise wird useMemo verwendet, um aufwändige Berechnungen zwischenzuspeichern sowie einen Verweis auf ein Objekt zu speichern, wenn es an andere Komponenten übergeben wird, die im Memo-HOC verpackt sind, oder als Abhängigkeit in Hooks.
const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])
In React-Entwicklungsteams ist umfassendes Memoisieren eine weit verbreitete Praxis. Dieser Ansatz umfasst in der Regel:
Entwickler verstehen jedoch nicht immer die Feinheiten dieser Strategie und wie leicht die Memoisierung beeinträchtigt werden kann. Es kommt nicht selten vor, dass Komponenten, die im Memo-HOC verpackt sind, unerwartet erneut gerendert werden. Bei der Social Discovery Group haben wir Kollegen sagen hören: „Alles zu memoisieren ist nicht viel teurer, als es überhaupt nicht zu memoisieren.“
Uns ist aufgefallen, dass nicht jeder einen entscheidenden Aspekt der Memoisierung vollständig versteht: Sie funktioniert ohne zusätzliche Optimierungen nur dann optimal, wenn Primitive an die memoisierte Komponente übergeben werden.
In solchen Fällen wird die Komponente nur dann erneut gerendert, wenn sich die Prop-Werte tatsächlich geändert haben. Primitive in Props sind gut.
Der zweite Punkt ist, wenn wir Referenztypen in Props übergeben. Es ist wichtig, sich daran zu erinnern und zu verstehen, dass es in React keine Zauberei gibt – es ist eine JavaScript-Bibliothek, die nach den Regeln von JavaScript funktioniert. Referenztypen in Props (Funktionen, Objekte, Arrays) sind gefährlich.
Zum Beispiel:
const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false
Wenn Sie ein Objekt erstellen, ist ein anderes Objekt mit denselben Eigenschaften und Werten nicht gleich dem ersten, da sie unterschiedliche Referenzen haben.
Wenn wir bei einem nachfolgenden Aufruf der Komponente scheinbar dasselbe Objekt übergeben, es sich aber tatsächlich um ein anderes Objekt handelt (da seine Referenz anders ist), erkennt der oberflächliche Vergleich, den React verwendet, diese Objekte als unterschiedlich. Dies löst ein erneutes Rendern der in Memo eingebundenen Komponente aus und unterbricht somit die Memoisierung dieser Komponente.
Um einen sicheren Betrieb mit memoisierten Komponenten zu gewährleisten, ist es wichtig, eine Kombination aus Memo, UseCallback und UseMemo zu verwenden. Auf diese Weise verfügen alle Referenztypen über konstante Referenzen.
Teamarbeit: Memo, useCallback, useMemo
Alles, was oben beschrieben wurde, klingt logisch und einfach, aber schauen wir uns gemeinsam die häufigsten Fehler an, die bei der Arbeit mit diesem Ansatz gemacht werden können. Einige davon sind vielleicht subtil, andere vielleicht etwas weit hergeholt, aber wir müssen uns ihrer bewusst sein und sie vor allem verstehen, um sicherzustellen, dass die Logik, die wir mit der vollständigen Memoisierung implementieren, nicht zusammenbricht.
Inlining zur Memoisierung
Beginnen wir mit einem klassischen Fehler: Bei jedem nachfolgenden Rendern der übergeordneten Komponente wird die gespeicherte Komponente MemoComponent ständig neu gerendert, da der Verweis auf das in den Parametern übergebene Objekt immer neu ist.
const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }
Um dieses Problem zu lösen, genügt es, den zuvor erwähnten useMemo-Hook zu verwenden. Jetzt bleibt die Referenz auf unser Objekt immer konstant.
const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }
Alternativ können Sie dies in eine Konstante außerhalb der Komponente verschieben, wenn das Array immer statische Daten enthält.
const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }
Eine ähnliche Situation in diesem Beispiel ist die Übergabe einer Funktion ohne Memoisierung. In diesem Fall wird, wie im vorherigen Beispiel, die Memoisierung der MemoComponent unterbrochen, indem ihr eine Funktion übergeben wird, die bei jedem Rendern der übergeordneten Komponente eine neue Referenz hat. Folglich wird die MemoComponent neu gerendert, als wäre sie nicht gespeichert.
const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }
Hier können wir den useCallback-Hook verwenden, um den Verweis auf die übergebene Funktion zwischen den Renderings der übergeordneten Komponente beizubehalten.
const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }
Notiz erfasst.
Außerdem hindert Sie bei useCallback nichts daran, eine Funktion zu übergeben, die eine andere Funktion zurückgibt. Es ist jedoch wichtig, daran zu denken, dass bei diesem Ansatz die Funktion `someFunction` bei jedem Rendern aufgerufen wird. Es ist wichtig, komplexe Berechnungen innerhalb von `someFunction` zu vermeiden.
function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }
Die nächste häufige Situation ist die Ausbreitung von Props. Stellen Sie sich vor, Sie haben eine Kette von Komponenten. Wie oft denken Sie darüber nach, wie weit sich die von InitialComponent weitergegebene Datenprops bewegen können, die für einige Komponenten in dieser Kette möglicherweise unnötig sind? In diesem Beispiel unterbricht diese Prop die Memoisierung in der ChildMemo-Komponente, da sich ihr Wert bei jedem Rendern der InitialComponent immer ändert. In einem realen Projekt, in dem die Kette der memoisierten Komponenten lang sein kann, wird die gesamte Memoisierung unterbrochen, da unnötige Props mit ständig wechselnden Werten an sie weitergegeben werden:
const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }
Um sich abzusichern, stellen Sie sicher, dass nur die erforderlichen Werte an die gespeicherte Komponente übergeben werden. Stattdessen:
const Component = (props) => { return <ChildMemo {...props} /> }
Verwenden Sie (geben Sie nur die erforderlichen Requisiten weiter):
const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )
Betrachten wir das folgende Beispiel. Eine bekannte Situation ist, wenn wir eine Komponente schreiben, die JSX als untergeordnete Elemente akzeptiert.
const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }
Auf den ersten Blick scheint es harmlos, aber in Wirklichkeit ist es das nicht. Schauen wir uns den Code genauer an, in dem wir JSX als untergeordnete Elemente an eine gespeicherte Komponente übergeben. Diese Syntax ist nichts anderes als syntaktischer Zucker für die Übergabe dieses „div“ als Prop mit dem Namen „children“.
Kinder unterscheiden sich nicht von anderen Props, die wir an eine Komponente übergeben. In unserem Fall übergeben wir JSX, und JSX wiederum ist syntaktischer Zucker für die Methode „createElement“, also übergeben wir im Wesentlichen ein reguläres Objekt mit dem Typ „div“. Und hier gilt die übliche Regel für eine gespeicherte Komponente: Wenn ein nicht gespeichertes Objekt in den Props übergeben wird, wird die Komponente neu gerendert, wenn ihr übergeordnetes Element gerendert wird, da der Verweis auf dieses Objekt jedes Mal neu ist.
Die Lösung für ein solches Problem wurde einige Abstracts zuvor im Abschnitt über die Übergabe eines nicht gespeicherten Objekts besprochen. Hier kann der übergebene Inhalt also mit useMemo gespeichert werden, und die Übergabe als untergeordnetes Element an ChildMemo unterbricht die Speicherung dieser Komponente nicht.
const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }
Betrachten wir ein interessanteres Beispiel.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }
Auf den ersten Blick scheint es harmlos: Wir haben zwei Komponenten, die beide gespeichert sind. In diesem Beispiel verhält sich ParentMemo jedoch so, als wäre es nicht in Memos eingeschlossen, da seine untergeordneten Elemente, ChildMemo, nicht gespeichert sind. Das Ergebnis der ChildMemo-Komponente ist JSX, und JSX ist nur syntaktischer Zucker für React.createElement, das ein Objekt zurückgibt. Nachdem die Methode React.createElement ausgeführt wurde, werden ParentMemo und ChildMemo zu regulären JavaScript-Objekten, und diese Objekte werden in keiner Weise gespeichert.
ElternMemo | KinderMemo |
---|---|
| |
Als Ergebnis übergeben wir ein nicht gespeichertes Objekt an die Requisiten und unterbrechen so die Speicherung der übergeordneten Komponente.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }
Um dieses Problem zu beheben, reicht es aus, das übergebene untergeordnete Element zu speichern und sicherzustellen, dass seine Referenz während des Renderns der übergeordneten App-Komponente konstant bleibt.
const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }
Ein weiterer gefährlicher und impliziter Bereich sind benutzerdefinierte Hooks. Benutzerdefinierte Hooks helfen uns, Logik aus unseren Komponenten zu extrahieren, wodurch der Code lesbarer wird und komplexe Logik verborgen wird. Sie verbergen jedoch auch vor uns, ob ihre Daten und Funktionen konstante Referenzen haben. In meinem Beispiel ist die Implementierung der Submit-Funktion im benutzerdefinierten Hook useForm verborgen, und bei jedem Rendern der übergeordneten Komponente werden die Hooks erneut ausgeführt.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };
Können wir aus dem Code erkennen, ob es sicher ist, die Submit-Methode als Prop an die memoisierte Komponente ComponentMemo zu übergeben? Natürlich nicht. Und im schlimmsten Fall könnte die Implementierung des benutzerdefinierten Hooks folgendermaßen aussehen:
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }
Indem wir die Submit-Methode an die memoisierte Komponente übergeben, unterbrechen wir die Memoisierung, da der Verweis auf die Submit-Methode bei jedem Rendern der übergeordneten Komponente neu ist. Um dieses Problem zu lösen, können Sie den useCallback-Hook verwenden. Aber der Hauptpunkt, den ich betonen wollte, ist, dass Sie nicht blind Daten aus benutzerdefinierten Hooks verwenden sollten, um sie an memoisierte Komponenten zu übergeben, wenn Sie die von Ihnen implementierte Memoisierung nicht unterbrechen möchten.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }
Wie bei jedem Ansatz sollte die vollständige Memoisierung mit Bedacht eingesetzt werden, und man sollte versuchen, offensichtlich übermäßige Memoisierung zu vermeiden. Betrachten wir das folgende Beispiel:
export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))
In diesem Beispiel ist es verlockend, die Methode handleChange in useCallback einzuschließen, da die Übergabe von handleChange in seiner aktuellen Form die Memoisierung der Eingabekomponente unterbricht, da der Verweis auf handleChange immer neu sein wird. Wenn sich der Status jedoch ändert, wird die Eingabekomponente trotzdem neu gerendert, da ihr in der Eigenschaft value ein neuer Wert übergeben wird. Die Tatsache, dass wir handleChange nicht in useCallback eingekapselt haben, verhindert also nicht, dass die Eingabekomponente ständig neu gerendert wird. In diesem Fall wäre die Verwendung von useCallback übertrieben. Als Nächstes möchte ich einige Beispiele für echten Code liefern, der bei Codeüberprüfungen zu sehen ist.
const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])
Wenn man bedenkt, wie einfach die Addition zweier Zahlen oder die Verkettung von Zeichenfolgen ist und dass wir in diesen Beispielen Primitive als Ausgabe erhalten, ist es offensichtlich, dass die Verwendung von useMemo hier keinen Sinn ergibt. Ähnliche Beispiele hier.
const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])
Basierend auf den Abhängigkeiten in useMemo können unterschiedliche Ergebnisse erzielt werden, aber auch hier handelt es sich um Primitive und es sind keine komplexen Berechnungen darin enthalten. Die Ausführung einer dieser Bedingungen bei jedem Rendern der Komponente ist günstiger als die Verwendung von useMemo.
Wir können mindestens 4 dieser Tools zur Messung der Leistung Ihres Anwendungscodes empfehlen.
React.Profiler
Mit
React-Entwicklertools
React Render Tracker
Ein weiteres interessantes Werkzeug ist
Storybook mit dem Add-on „Storybook-Addon-Performance“.
Außerdem können Sie in Storybook ein interessantes Plugin namens installieren
** Geschrieben von Sergey Levkovich, Senior Software Engineer bei der Social Discovery Group