Une approche répandue dans le développement d'applications React consiste à tout couvrir par la mémorisation. De nombreux développeurs appliquent généreusement cette technique d'optimisation, en encapsulant les composants dans la mémorisation pour éviter les nouveaux rendus inutiles. À première vue, cela semble être une stratégie infaillible pour améliorer les performances. Cependant, l'équipe du Social Discovery Group a découvert que lorsqu'elle est mal appliquée, la mémorisation peut en fait s'interrompre de manière inattendue, entraînant des problèmes de performances. Dans cet article, nous explorerons les endroits surprenants où la mémorisation peut faiblir et comment éviter ces pièges cachés dans vos applications React.
La mémorisation est une technique d'optimisation en programmation qui consiste à sauvegarder les résultats d'opérations coûteuses et à les réutiliser lorsque les mêmes entrées sont rencontrées à nouveau. L'essence de la mémorisation est d'éviter les calculs redondants pour les mêmes données d'entrée. Cette description est en effet vraie pour la mémorisation traditionnelle. Vous pouvez voir dans l'exemple de code que tous les calculs sont mis en cache.
const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }
La mémorisation offre plusieurs avantages, notamment l'amélioration des performances, les économies de ressources et la mise en cache des résultats. Cependant, la mémorisation de React fonctionne jusqu'à ce que de nouveaux accessoires arrivent, ce qui signifie que seul le résultat du dernier appel est enregistré.
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 }
La bibliothèque React nous fournit plusieurs outils de mémorisation. Il s'agit du HOC React.memo, des hooks useCallback, useMemo et useEvent, ainsi que de React.PureComponent et de la méthode de cycle de vie des composants de classe, ShouldComponentUpdate. Examinons les trois premiers outils de mémorisation et explorons leurs objectifs et leur utilisation dans React.
Réagir.mémo
Ce composant d'ordre supérieur (HOC) accepte un composant comme premier argument et une fonction de comparaison facultative comme second. La fonction de comparaison permet une comparaison manuelle des accessoires précédents et actuels. Lorsqu'aucune fonction de comparaison n'est fournie, React utilise par défaut une égalité superficielle. Il est crucial de comprendre que l’égalité superficielle n’effectue qu’une comparaison au niveau de la surface. Par conséquent, si les accessoires contiennent des types de référence avec des références non constantes, React déclenchera un nouveau rendu du composant.
const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })
React.useCallback
Le hook useCallback nous permet de conserver la référence à une fonction passée lors du rendu initial. Lors des rendus suivants, React comparera les valeurs dans le tableau de dépendances du hook, et si aucune des dépendances n'a changé, il renverra la même référence de fonction mise en cache que la dernière fois. En d'autres termes, useCallback met en cache la référence à la fonction entre les rendus jusqu'à ce que ses dépendances changent.
const callback = useCallback(() => { // do something }, [a, b, c])
React.useMemo
Le hook useMemo vous permet de mettre en cache le résultat d'un calcul entre les rendus. En règle générale, useMemo est utilisé pour mettre en cache des calculs coûteux, ainsi que pour stocker une référence à un objet lors de sa transmission à d'autres composants enveloppés dans le mémo HOC ou en tant que dépendance dans des hooks.
const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])
Dans les équipes de développement de React, la mémorisation complète est une pratique répandue. Cette approche implique généralement :
Cependant, les développeurs ne comprennent pas toujours les subtilités de cette stratégie et la facilité avec laquelle la mémorisation peut être compromise. Il n'est pas rare que les composants enveloppés dans le mémo HOC soient restitués de manière inattendue. Chez Social Discovery Group, nous avons entendu des collègues affirmer : « Tout mémoriser ne coûte pas beaucoup plus cher que ne pas mémoriser du tout. »
Nous avons remarqué que tout le monde ne saisit pas pleinement un aspect crucial de la mémorisation : elle fonctionne de manière optimale sans ajustements supplémentaires uniquement lorsque les primitives sont transmises au composant mémorisé.
Dans de tels cas, le composant sera restitué uniquement si les valeurs des accessoires ont réellement changé. Les primitives dans les accessoires sont bonnes.
Le deuxième point est lorsque nous transmettons des types de référence dans les accessoires. Il est important de se rappeler et de comprendre qu’il n’y a pas de magie dans React : c’est une bibliothèque JavaScript qui fonctionne selon les règles de JavaScript. Les types de référence dans les accessoires (fonctions, objets, tableaux) sont dangereux.
Par exemple:
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
Si vous créez un objet, un autre objet avec les mêmes propriétés et valeurs n'est pas égal au premier car ils ont des références différentes.
Si nous transmettons ce qui semble être le même objet lors d'un appel ultérieur du composant, mais qu'il s'agit en réalité d'un objet différent (puisque sa référence est différente), la comparaison superficielle utilisée par React reconnaîtra ces objets comme différents. Cela déclenchera un nouveau rendu du composant enveloppé dans un mémo, rompant ainsi la mémorisation de ce composant.
Pour garantir un fonctionnement sûr avec les composants mémorisés, il est important d'utiliser une combinaison de memo, useCallback et useMemo. De cette façon, tous les types de référence auront des références constantes.
Travail d'équipe : mémo, useCallback, useMemo
Tout ce qui est décrit ci-dessus semble logique et simple, mais examinons ensemble les erreurs les plus courantes pouvant être commises en travaillant avec cette approche. Certains d'entre eux peuvent être subtils, et d'autres peuvent être un peu exagérés, mais nous devons en être conscients et, surtout, les comprendre pour garantir que la logique que nous mettons en œuvre avec une mémorisation complète ne se brise pas.
Inline à la mémorisation
Commençons par une erreur classique, où à chaque rendu ultérieur du composant Parent, le composant mémorisé MemoComponent sera constamment restitué car la référence à l'objet passé dans params sera toujours nouvelle.
const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }
Pour résoudre ce problème, il suffit d’utiliser le hook useMemo mentionné précédemment. Désormais, la référence à notre objet sera toujours constante.
const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }
Alternativement, vous pouvez déplacer cela dans une constante en dehors du composant si le tableau contient toujours des données statiques.
const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }
Une situation similaire dans cet exemple consiste à transmettre une fonction sans mémorisation. Dans ce cas, comme dans l'exemple précédent, la mémorisation du MemoComponent sera interrompue en lui passant une fonction qui aura une nouvelle référence à chaque rendu du composant Parent. Par conséquent, MemoComponent s'affichera à nouveau comme s'il n'était pas mémorisé.
const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }
Ici, nous pouvons utiliser le hook useCallback pour conserver la référence à la fonction passée entre les rendus du composant Parent.
const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }
Note prise.
De plus, dans useCallback, rien ne vous empêche de transmettre une fonction qui renvoie une autre fonction. Cependant, il est important de se rappeler que dans cette approche, la fonction « someFunction » sera appelée à chaque rendu. Il est crucial d'éviter les calculs complexes à l'intérieur de `someFunction`.
function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }
La prochaine situation courante est celle de la propagation des accessoires. Imaginez que vous ayez une chaîne de composants. À quelle fréquence considérez-vous la distance que peut parcourir l'accessoire de données transmis par InitialComponent, potentiellement inutile pour certains composants de cette chaîne ? Dans cet exemple, cet accessoire interrompra la mémorisation dans le composant ChildMemo car, à chaque rendu de InitialComponent, sa valeur changera toujours. Dans un projet réel, où la chaîne de composants mémorisés peut être longue, toute mémorisation sera interrompue car des accessoires inutiles avec des valeurs en constante évolution leur seront transmis :
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()} /> ) }
Pour vous protéger, assurez-vous que seules les valeurs nécessaires sont transmises au composant mémorisé. Au lieu de:
const Component = (props) => { return <ChildMemo {...props} /> }
Utiliser (passer uniquement les accessoires nécessaires) :
const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )
Considérons l'exemple suivant. Une situation familière est celle où nous écrivons un composant qui accepte JSX comme enfant.
const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }
À première vue, cela semble inoffensif, mais en réalité, ce n’est pas le cas. Examinons de plus près le code dans lequel nous transmettons JSX en tant qu'enfant à un composant mémorisé. Cette syntaxe n'est rien d'autre que du sucre syntaxique pour passer ce « div » comme accessoire nommé « enfants ».
Les enfants ne sont pas différents de tout autre accessoire que nous transmettons à un composant. Dans notre cas, nous transmettons JSX, et JSX, à son tour, est du sucre syntaxique pour la méthode `createElement`, donc essentiellement, nous transmettons un objet régulier avec le type `div`. Et ici, la règle habituelle pour un composant mémorisé s'applique : si un objet non mémorisé est passé dans les accessoires, le composant sera restitué lorsque son parent sera rendu, car à chaque fois la référence à cet objet sera nouvelle.
La solution à un tel problème a été discutée quelques résumés plus tôt, dans le bloc concernant le passage d'un objet non mémorisé. Donc ici, le contenu transmis peut être mémorisé à l'aide de useMemo, et le transmettre en tant qu'enfant à ChildMemo n'interrompra pas la mémorisation de ce composant.
const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }
Prenons un exemple plus intéressant.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }
À première vue, cela semble inoffensif : nous avons deux composants, tous deux mémorisés. Cependant, dans cet exemple, ParentMemo se comportera comme s'il n'était pas enveloppé dans un mémo car ses enfants, ChildMemo, ne sont pas mémorisés. Le résultat du composant ChildMemo sera JSX, et JSX n'est qu'un sucre syntaxique pour React.createElement, qui renvoie un objet. Ainsi, après l'exécution de la méthode React.createElement, ParentMemo et ChildMemo deviendront des objets JavaScript normaux, et ces objets ne seront en aucun cas mémorisés.
ParentMémo | EnfantMémo |
---|---|
| |
En conséquence, nous transmettons un objet non mémorisé aux accessoires, interrompant ainsi la mémorisation du composant Parent.
const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }
Pour résoudre ce problème, il suffit de mémoriser l'enfant transmis, en garantissant que sa référence reste constante lors du rendu du composant App parent.
const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }
Un autre domaine dangereux et implicite est celui des hooks personnalisés. Les hooks personnalisés nous aident à extraire la logique de nos composants, rendant le code plus lisible et masquant la logique complexe. Cependant, ils nous cachent également si leurs données et fonctions ont des références constantes. Dans mon exemple, l'implémentation de la fonction submit est masquée dans le hook personnalisé useForm, et à chaque rendu du composant Parent, les hooks seront réexécutés.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };
Pouvons-nous comprendre à partir du code s'il est sûr de transmettre la méthode submit comme accessoire au composant mémorisé ComponentMemo ? Bien sûr que non. Et dans le pire des cas, l’implémentation du hook personnalisé pourrait ressembler à ceci :
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }
En passant la méthode submit dans le composant mémorisé, nous interromprons la mémorisation car la référence à la méthode submit sera nouvelle à chaque rendu du composant Parent. Pour résoudre ce problème, vous pouvez utiliser le hook useCallback. Mais le point principal que je voulais souligner est que vous ne devez pas utiliser aveuglément les données des hooks personnalisés pour les transmettre à des composants mémorisés si vous ne voulez pas interrompre la mémorisation que vous avez implémentée.
const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }
Comme toute approche, la mémorisation complète doit être utilisée de manière réfléchie et il faut s’efforcer d’éviter une mémorisation manifestement excessive. Considérons l'exemple suivant :
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} />))
Dans cet exemple, il est tentant d'envelopper la méthode handleChange dans useCallback car passer handleChange dans sa forme actuelle interrompra la mémorisation du composant Input puisque la référence à handleChange sera toujours nouvelle. Cependant, lorsque l'état change, le composant Input sera de toute façon restitué car une nouvelle valeur lui sera transmise dans la prop value. Ainsi, le fait que nous n'ayons pas enveloppé handleChange dans useCallback n'empêchera pas le composant Input d'être constamment restitué. Dans ce cas, utiliser useCallback serait excessif. Ensuite, je voudrais fournir quelques exemples de code réel vu lors des révisions de code.
const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])
Considérant à quel point l'opération consistant à ajouter deux nombres ou à concaténer des chaînes est simple et que nous obtenons des primitives en sortie dans ces exemples, il est évident que l'utilisation de useMemo ici n'a aucun sens. Exemples similaires ici.
const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])
Sur la base des dépendances dans useMemo, différents résultats peuvent être obtenus, mais encore une fois, ce sont des primitives et il n'y a pas de calculs complexes à l'intérieur. L'exécution de l'une de ces conditions sur chaque rendu du composant est moins chère que l'utilisation de useMemo.
Nous pouvons recommander au moins 4 de ces outils pour mesurer les performances de votre code d’application.
React.Profiler
Avec
Outils de développement React
Traqueur de rendu React
Un autre outil intéressant est
Livre d'histoires avec l'addon storybook-addon-performance.
De plus, dans Storybook, vous pouvez installer un plugin intéressant appelé
** Écrit par Sergey Levkovich, ingénieur logiciel principal chez Social Discovery Group