Reagieren: Wenn Sie den Status erhöhen, wird Ihre App zerstört

Abdecken


Haben Sie von "Lifting State Up" gehört? Ich denke du hast und das ist der genaue Grund warum du hier bist. Wie könnte es möglich sein, dass eines der 12 Hauptkonzepte, die in der offiziellen Dokumentation von React aufgeführt sind, zu einer schlechten Leistung führt? In diesem Artikel werden wir eine Situation betrachten, in der dies tatsächlich der Fall ist.


Schritt 1: Heben Sie es an


Ich schlage vor, dass Sie ein einfaches Tic-Tac-Toe-Spiel erstellen. Für das Spiel brauchen wir:


  • Ein Spielzustand. Keine echte Spiellogik, um herauszufinden, ob wir gewinnen oder verlieren. Nur ein einfaches zweidimensionales Array, gefüllt mit undefined "x" oder "0".


     const size = 10 // Two-dimensional array (size * size) filled with `undefined`. Represents an empty field. const initialField = new Array(size).fill(new Array(size).fill(undefined)) 

  • Ein übergeordneter Container, in dem der Status unseres Spiels gehostet wird.


     const App = () => { const [field, setField] = useState(initialField) return ( <div> {field.map((row, rowI) => ( <div> {row.map((cell, cellI) => ( <Cell content={cell} setContent={ // Update a single cell of a two-dimensional array // and return a new two dimensional array (newContent) => setField([ // Copy rows before our target row ...field.slice(0, rowI), [ // Copy cells before our target cell ...field[rowI].slice(0, cellI), newContent, // Copy cells after our target cell ...field[rowI].slice(cellI + 1), ], // Copy rows after our target row ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> ) } 

  • Eine untergeordnete Komponente zum Anzeigen des Status einer einzelnen Zelle.


     const randomContent = () => (Math.random() > 0.5 ? 'x' : '0') const Cell = ({ content, setContent }) => ( <div onClick={() => setContent(randomContent())}>{content}</div> ) 


Live-Demo Nr. 1


Bisher sieht es gut aus. Ein perfekt reaktives Feld, mit dem Sie mit Lichtgeschwindigkeit interagieren können :) Erhöhen wir die Größe. Sagen wir zu 100. Ja, es ist Zeit, auf diesen Demo-Link zu klicken und die Größenvariable ganz oben zu ändern. Immer noch schnell für dich? Versuchen Sie es mit 200 oder verwenden Sie die in Chrome integrierte CPU-Drosselung . Sehen Sie jetzt eine signifikante Verzögerung zwischen dem Klicken auf eine Zelle und dem Ändern des Inhalts?


Lassen Sie uns die size wieder auf 10 ändern und ein Profil hinzufügen, um die Ursache zu untersuchen.


 const Cell = ({ content, setContent }) => { console.log('cell rendered') return <div onClick={() => setContent(randomContent())}>{content}</div> } 

Live-Demo Nr. 2


Ja, das ist es. Ein einfaches console.log würde ausreichen, da es bei jedem Rendern ausgeführt wird.


Was sehen wir also? Basierend auf der Anzahl der Anweisungen "Zelle gerendert" (für size = N sollte es N sein) in unserer Konsole scheint es, als würde das gesamte Feld jedes Mal neu gerendert, wenn sich eine einzelne Zelle ändert.


Am naheliegendsten ist es, einige Schlüssel hinzuzufügen, wie in der React-Dokumentation vorgeschlagen .


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} setContent={(newContent) => setField([ ...field.slice(0, rowI), [ ...field[rowI].slice(0, cellI), newContent, ...field[rowI].slice(cellI + 1), ], ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> 

Live-Demo Nr. 3


Nach erneuter Vergrößerung sehen wir jedoch, dass dieses Problem immer noch besteht. Wenn wir nur sehen könnten, warum eine Komponente gerendert wird ... Zum Glück können wir mit Hilfe von erstaunlichen React DevTools . Es kann aufzeichnen, warum Komponenten gerendert werden. Sie müssen es jedoch manuell aktivieren.


Reagieren Sie auf DevTools-Einstellungen


Sobald es aktiviert ist, können wir sehen, dass alle Zellen neu gerendert wurden, weil ihre Requisiten geändert wurden, insbesondere setContent prop.


Reagieren Sie auf den DevTools-Bericht Nr. 1


Jede Zelle hat zwei Requisiten: content und setContent . Wenn sich Zelle [0] [0] ändert, ändert sich der Inhalt von Zelle [0] [1] nicht. Auf der anderen Seite erfasst setContent field , cellI und rowI in seinem Abschluss. cellI und rowI bleiben gleich, aber das field ändert sich mit jeder Änderung einer Zelle.


Lassen Sie uns unseren Code umgestalten und setContent .


Um den Verweis auf setContent gleich zu halten, setContent wir die Verschlüsse setContent . Wir könnten rowI Schließen von cellI und rowI beseitigen, indem unsere Cell cellI und rowI explizit an cellI setContent . In setState auf das field könnten wir eine nette Funktion von setState - es akzeptiert Rückrufe .


 const [field, setField] = useState(initialField) // `useCallback` keeps reference to `setCell` the same. const setCell = useCallback( (rowI, cellI, newContent) => setField((oldField) => [ ...oldField.slice(0, rowI), [ ...oldField[rowI].slice(0, cellI), newContent, ...oldField[rowI].slice(cellI + 1), ], ...oldField.slice(rowI + 1), ]), [], ) 

Dadurch sieht die App so aus


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} rowI={rowI} cellI={cellI} setContent={setCell} /> ))} </div> ))} </div> 

Jetzt muss Cell cellI und rowI an den setContent .


 const Cell = ({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) } 

Live-Demo Nr. 4


Werfen wir einen Blick auf den DevTools-Bericht.


Reagieren Sie auf den DevTools-Bericht Nr. 2


Was ?! Warum zum Teufel heißt es "Eltern Requisiten geändert"? Die Sache ist also, dass jedes Mal, wenn unser Feld aktualisiert wird, die App neu gerendert wird. Daher werden die untergeordneten Komponenten neu gerendert. Ok Sagt Stackoverflow etwas Nützliches über die Optimierung der Reaktionsleistung aus? Das Internet schlägt vor, shouldComponentUpdate oder seine nahen Verwandten zu verwenden: PureComponent und memo .


 const Cell = memo(({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) }) 

Live-Demo Nr. 5


Ja Jetzt wird nur eine Zelle neu gerendert, sobald sich ihr Inhalt ändert. Aber warte ... Gab es eine Überraschung? Wir haben Best Practices befolgt und das erwartete Ergebnis erzielt.


Ein böses Lachen sollte hier sein. Da ich nicht bei dir bin, versuche bitte, es dir so gut wie möglich vorzustellen. Erhöhen Sie die size in der Live-Demo Nr. 5 . Diesmal müssen Sie möglicherweise mit einer etwas größeren Anzahl gehen. Die Verzögerung ist jedoch immer noch da. Warum ???


Schauen wir uns noch einmal den DebTools-Bericht an.


Reagieren Sie auf den DevTools-Bericht Nr. 3


Es gibt nur ein Rendern von Cell und es war ziemlich schnell, aber es gibt auch ein Rendern von App , was einige Zeit in App hat. Die Sache ist, dass bei jedem erneuten Rendern der App jede Cell ihre neuen Requisiten mit ihren vorherigen Requisiten vergleichen muss. Selbst wenn es sich entscheidet, nicht zu rendern (was genau unser Fall ist), braucht dieser Vergleich immer noch Zeit. O (1), aber das O (1) kommt size * size mal vor!


Schritt 2: Bewegen Sie es nach unten


Was können wir tun, um das zu umgehen? Wenn das Rendern der App uns zu viel kostet, müssen wir das Rendern der App beenden. Es ist nicht möglich, unseren Status in der App mit useState , da genau dies das erneute Rendern auslöst. Wir müssen also unseren Status nach unten verschieben und jede Cell den Status für sich abonnieren lassen.


Lassen Sie uns eine dedizierte Klasse erstellen, die ein Container für unseren Staat sein wird.


 class Field { constructor(fieldSize) { this.size = fieldSize // Copy-paste from `initialState` this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) } cellContent(rowI, cellI) { return this.data[rowI][cellI] } // Copy-paste from old `setCell` setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] } map(cb) { return this.data.map(cb) } } const field = new Field(size) 

Dann könnte unsere App so aussehen:


 const App = () => { return ( <div> {// As you can see we still need to iterate over our state to get indexes. field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> ) } 

Und unsere Cell kann den Inhalt aus dem field selbst anzeigen:


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Live-Demo Nr. 6


An diesem Punkt können wir sehen, wie unser Feld gerendert wird. Wenn wir jedoch auf eine Zelle klicken, passiert nichts. In den Protokollen sehen wir "setCell" für jeden Klick, aber die Zelle bleibt leer. Der Grund hierfür ist, dass nichts die Zelle zum erneuten Rendern auffordert. Unser Zustand außerhalb von React ändert sich, aber React weiß nichts davon. Das muss sich ändern.


Wie können wir ein Rendering programmgesteuert auslösen?


Mit Klassen haben wir forceUpdate . Bedeutet das, dass wir unseren Code in Klassen umschreiben müssen? Nicht wirklich. Was wir mit funktionalen Komponenten tun können, ist die Einführung eines Dummy-Status, den wir nur ändern, um unsere Komponente zum erneuten Rendern zu zwingen.


Hier erfahren Sie, wie Sie einen benutzerdefinierten Hook erstellen können, um das erneute Rendern zu erzwingen.


 const useForceRender = () => { const [, setDummy] = useState(0) const forceRender = useCallback(() => setDummy((oldVal) => oldVal + 1), []) return forceRender } 

Um ein erneutes Rendern auszulösen, wenn unser Feld aktualisiert wird, müssen wir wissen, wann es aktualisiert wird. Das bedeutet, dass wir in der Lage sein müssen, Feldaktualisierungen irgendwie zu abonnieren.


 class Field { constructor(fieldSize) { this.size = fieldSize this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) this.subscribers = {} } _cellSubscriberId(rowI, cellI) { return `row${rowI}cell${cellI}` } cellContent(rowI, cellI) { return this.data[rowI][cellI] } setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] const cellSubscriber = this.subscribers[this._cellSubscriberId(rowI, cellI)] if (cellSubscriber) { cellSubscriber() } } map(cb) { return this.data.map(cb) } // Note that we subscribe not to updates of the whole filed, but to updates of one cell only subscribeCellUpdates(rowI, cellI, onSetCellCallback) { this.subscribers[this._cellSubscriberId(rowI, cellI)] = onSetCellCallback } } 

Jetzt können wir Feldaktualisierungen abonnieren.


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => field.subscribeCellUpdates(rowI, cellI, forceRender), [ forceRender, ]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Live-Demo Nr. 7


Lassen Sie uns mit dieser Implementierung mit der size spielen. Versuchen Sie, es auf die Werte zu erhöhen, die sich zuvor verzögert anfühlten. Und ... es ist Zeit, eine gute Flasche Champagner zu öffnen! Wir haben uns eine App besorgt, die nur dann eine Zelle und eine Zelle rendert, wenn sich der Status dieser Zelle ändert!


Werfen wir einen Blick auf den DevTools-Bericht.


Reagieren Sie auf den DevTools-Bericht Nr. 4


Wie wir jetzt sehen können, wird nur Cell gerendert und es ist schnell verrückt.


Was ist, wenn Sie sagen, dass der Code unserer Cell jetzt eine mögliche Ursache für einen Speicherverlust ist? Wie Sie sehen können, useEffect wir in useEffect , aber wir useEffect uns nie ab. Dies bedeutet, dass das Abonnement auch dann Cell , wenn Cell zerstört wird. Lassen Sie uns das ändern.


Zuerst müssen wir Field beibringen, was es bedeutet, sich abzumelden.


 class Field { // ... unsubscribeCellUpdates(rowI, cellI) { delete this.subscribers[this._cellSubscriberId(rowI, cellI)] } } 

Jetzt können wir unsubscribeCellUpdates auf unsere Cell anwenden.


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Live-Demo # 8


Was ist die Lektion hier? Wann ist es sinnvoll, den Status im Komponentenbaum nach unten zu verschieben? Niemals! Nun, nicht wirklich :) Halten Sie sich an die Best Practices, bis sie fehlschlagen, und führen Sie keine vorzeitigen Optimierungen durch. Ehrlich gesagt ist der Fall, den wir oben betrachtet haben, etwas spezifisch, aber ich hoffe, Sie werden sich daran erinnern, wenn Sie jemals eine wirklich große Liste anzeigen müssen.


Bonusschritt: Reales Refactoring


In der Live-Demo Nr. 8 haben wir ein globales field , was in einer realen App nicht der Fall sein sollte. Um dies zu lösen, können wir das field in unserer App hosten und es mit [context] () an den Baum weitergeben.


 const AppContext = createContext() const App = () => { // Note how we used a factory to initialize our state here. // Field creation could be quite expensive for big fields. // So we don't want to create it each time we render and block the event loop. const [field] = useState(() => new Field(size)) return ( <AppContext.Provider value={field}> <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> </AppContext.Provider> ) } 

Jetzt können wir field aus dem Kontext in unserer Cell konsumieren.


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() const field = useContext(AppContext) useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Live-Demo Nr. 9


Hoffentlich haben Sie etwas Nützliches für Ihr Projekt gefunden. Zögern Sie nicht, mir Ihr Feedback mitzuteilen! Ich freue mich über Kritik und Fragen.

Source: https://habr.com/ru/post/de471300/


All Articles