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

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
.

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

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.

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

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 {
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 = () => {
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.