Réagissez: la levée de l'état tue votre application

Couverture


Avez-vous entendu parler de "lever l'état"? Je suppose que vous l'avez fait et c'est la raison exacte pour laquelle vous êtes ici. Comment pourrait-il être possible que l' un des 12 principaux concepts répertoriés dans la documentation officielle de React conduise à de mauvaises performances? Dans cet article, nous allons considérer une situation où c'est effectivement le cas.


Étape 1: soulevez-le


Je vous suggère de créer un jeu simple de tic-tac-toe. Pour le jeu, nous aurons besoin de:


  • Un état de jeu. Aucune véritable logique de jeu pour savoir si nous gagnons ou perdons. Juste un simple tableau bidimensionnel rempli de "x" ou de "0". non undefined "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)) 

  • Un conteneur parent pour héberger l'état de notre jeu.


     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> ) } 

  • Un composant enfant pour afficher l'état d'une seule cellule.


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


Démo en direct # 1


Jusqu'à présent, cela semble bien. Un champ parfaitement réactif avec lequel vous pouvez interagir à la vitesse de la lumière :) Augmentons la taille. Disons, à 100. Oui, il est temps de cliquer sur ce lien de démonstration et de changer la variable de size tout en haut. Toujours rapide pour vous? Essayez 200 ou utilisez la limitation du processeur intégrée à Chrome . Voyez-vous maintenant un décalage important entre le moment où vous cliquez sur une cellule et le moment où son contenu change?


Modifions à nouveau la size à 10 et ajoutons du profilage pour rechercher la cause.


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

Démo en direct # 2


Oui, c'est ça. Une console.log simple suffirait car elle s'exécute sur chaque rendu.


Alors que voyons-nous? Sur la base du nombre d'instructions "rendu cellule" (pour size = N, il devrait être N) dans notre console, il semble que le champ entier soit rendu à chaque fois qu'une seule cellule change.


La chose la plus évidente à faire est d'ajouter des clés comme le suggère la documentation de React .


 <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> 

Démo en direct # 3


Cependant, après avoir à nouveau augmenté la size , nous constatons que ce problème persiste. Si seulement nous pouvions voir pourquoi un composant est rendu ... Heureusement, nous pouvons avec l'aide de l'incroyable React DevTools . Il est capable d'enregistrer pourquoi les composants sont rendus. Vous devez cependant l'activer manuellement.


Réagissez aux paramètres DevTools


Une fois qu'il est activé, nous pouvons voir que toutes les cellules ont été rendues à nouveau parce que leurs accessoires ont changé, en particulier, l'accessoire setContent .


Rapport DevTools React # 1


Chaque cellule a deux accessoires: content et setContent . Si la cellule [0] [0] change, le contenu de la cellule [0] [1] ne change pas. D'un autre côté, setContent capture field , cellI et rowI dans sa fermeture. cellI et rowI restent les mêmes, mais le field change à chaque changement de cellule.


Refactorisons notre code et conservons setContent la même manière.


Pour conserver la référence à setContent nous devons nous débarrasser des fermetures. Nous pourrions éliminer la fermeture de cellI et rowI en faisant explicitement passer notre Cell cellI et rowI à setContent . En ce qui concerne le field , nous pourrions utiliser une fonctionnalité setState de setState - il accepte les rappels .


 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), ]), [], ) 

Ce qui fait que l' App ressemble à ceci


 <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> 

Cell doit maintenant passer cellI et rowI à setContent .


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

Démo en direct # 4


Jetons un coup d'œil au rapport DevTools.


Rapport ReaT DevTools # 2


Quoi?! Pourquoi diable dit-il "les accessoires des parents ont changé"? Donc, chaque fois que notre champ est mis à jour, l' App est restituée. Par conséquent, ses composants enfants sont restitués. Ok Le stackoverflow dit-il quelque chose d'utile sur l'optimisation des performances de React? Internet suggère d'utiliser shouldComponentUpdate ou ses proches: PureComponent et memo .


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

Démo en direct # 5


Ouais Désormais, une seule cellule est restituée une fois son contenu modifié. Mais attendez ... Y a-t-il eu une surprise? Nous avons suivi les meilleures pratiques et obtenu le résultat attendu.


Un rire diabolique était censé être ici. Comme je ne suis pas avec vous, je vous en prie, essayez le plus possible de l'imaginer. Allez-y et augmentez la size dans la démo en direct # 5 . Cette fois, vous devrez peut-être choisir un nombre un peu plus élevé. Cependant, le décalage est toujours là. Pourquoi ???


Jetons à nouveau un œil au rapport DebTools.


Rapport ReaT DevTools # 3


Il n'y a qu'un seul rendu de Cell et c'était assez rapide, mais il y a aussi un rendu d' App , qui a pris un certain temps. Le fait est qu'à chaque re-rendu de l' App chaque Cell doit comparer ses nouveaux accessoires avec ses accessoires précédents. Même s'il décide de ne pas rendre (ce qui est précisément notre cas), cette comparaison prend encore du temps. O (1), mais que O (1) se produit size * size fois!


Étape 2: déplacez-le vers le bas


Que pouvons-nous faire pour contourner ce problème? Si le rendu de l' App nous coûte trop cher, nous devons arrêter le rendu de l' App . Ce n'est pas possible si vous continuez à héberger notre état dans l' App aide de useState , car c'est exactement ce qui déclenche le nouveau rendu. Nous devons donc déplacer notre état vers le bas et laisser chaque Cell souscrire à l'état de son propre chef.


Créons une classe dédiée qui sera un conteneur pour notre état.


 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) 

Ensuite, notre App pourrait ressembler à ceci:


 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> ) } 

Et notre Cell peut afficher le contenu du field de son propre chef:


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

Démo en direct # 6


À ce stade, nous pouvons voir notre champ rendu. Cependant, si nous cliquons sur une cellule, rien ne se passe. Dans les journaux, nous pouvons voir "setCell" pour chaque clic, mais la cellule reste vide. La raison ici est que rien ne dit à la cellule de restituer. Notre état en dehors de React change, mais React ne le sait pas. Cela doit changer.


Comment déclencher un rendu par programme?


Avec les classes, nous avons forceUpdate . Cela signifie-t-il que nous devons réécrire notre code dans les classes? Pas vraiment. Ce que nous pouvons faire avec les composants fonctionnels est d'introduire un état factice, que nous changeons uniquement pour forcer notre composant à être restitué.


Voici comment nous pouvons créer un hook personnalisé pour forcer les rendus.


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

Pour déclencher un nouveau rendu lorsque notre champ est mis à jour, nous devons savoir quand il est mis à jour. Cela signifie que nous devons être en mesure de souscrire en quelque sorte aux mises à jour sur le terrain.


 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 } } 

Maintenant, nous pouvons nous abonner aux mises à jour sur le terrain.


 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> ) } 

Démo en direct # 7


Jouons avec la size avec cette implémentation. Essayez de l'augmenter aux valeurs qui semblaient léthargées auparavant. Et ... Il est temps d'ouvrir une bonne bouteille de champagne! Nous nous sommes procuré une application qui rend une cellule et une cellule uniquement lorsque l'état de cette cellule change!


Jetons un coup d'œil au rapport DevTools.


Rapport ReaT DevTools # 4


Comme nous pouvons le voir maintenant, seul Cell est rendu et c'est rapide et fou.


Et si dire que maintenant le code de notre Cell est une cause potentielle d'une fuite de mémoire? Comme vous pouvez le voir, dans useEffect nous souscrivons aux mises à jour des cellules, mais nous ne nous désinscrivons jamais. Cela signifie que même lorsque Cell est détruit, son abonnement perdure. Changeons cela.


Tout d'abord, nous devons enseigner à Field ce que signifie se désinscrire.


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

Nous pouvons maintenant appliquer unsubscribeCellUpdates à notre Cell .


 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> ) } 

Démo en direct # 8


Alors quelle est la leçon ici? Quand est-il judicieux de déplacer l'état dans l'arborescence des composants? Jamais! Eh bien, pas vraiment :) Tenez-vous en aux bonnes pratiques jusqu'à ce qu'elles échouent et ne faites aucune optimisation prématurée. Honnêtement, le cas que nous avons examiné ci-dessus est quelque peu spécifique, cependant, j'espère que vous vous en souviendrez si vous avez besoin d'afficher une très grande liste.


Étape bonus: refactoring dans le monde réel


Dans la démo en direct # 8, nous avons utilisé le field global, ce qui ne devrait pas être le cas dans une application réelle. Pour le résoudre, nous pourrions héberger un field dans notre App et le transmettre dans l'arborescence à l'aide de [context] ().


 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> ) } 

Maintenant, nous pouvons consommer le field du contexte dans notre Cell .


 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> ) } 

Démo en direct # 9


J'espère que vous avez trouvé quelque chose d'utile pour votre projet. N'hésitez pas à me faire part de vos retours! J'apprécie très certainement toute critique et question.

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


All Articles