Maintenir ou restaurer de bonnes performances sur une application React / Redux

React est connu pour offrir de bonnes performances aux applications Web. Pourtant, comme avec toute technologie, il est important de suivre de bonnes pratiques pour maintenir de bonnes performances. Un signe qu’il est temps de reconsidérer les pratiques suivies sur l’application est quand les animations deviennent saccadées (e.g. stepper, onglets), qu’il y a une latence lorsqu’on saisit du texte dans un champ (e.g. validation au fil de la saisie)… Ayant constaté que certains problèmes, récurrents, apparaissent dans de nombreux projets, nous présentons dans cet article plusieurs points importants à garder en tête au cours des développements pour y arriver.

Cet article suppose que vous avez déjà manipulé quelques notions de React : composants, propriétés, state, Higher Order Components, et Redux : sélecteurs, reducers, actions. Nous utiliserons reselect pour écrire les sélecteurs, qui offre un cache simple mais efficace.

Comment détecter les causes de problèmes de performance

L’amélioration des performances en React tourne généralement autour de la minimisation du nombre de rendus de chaque composant. Si un état de l’application change, certains composants ont besoin d’être rendus, d’autres non s’ils ne sont pas affectés. Pourtant, il arrive que des composants n’ayant pas besoin de rendu le sont, voire même plusieurs fois, inutilement.

Une méthode pour diagnostiquer les problèmes de rendu est d’ajouter des logs dans le rendu de chaque composant sensible (pages, sous-composants logiques tels que stepper, tab, carte, formulaire…), derrière un flag.

// app config
export const debugPerformance = false;

// Home.js

const mapStateToProps = createStructuredSelector({
  name: nameSelector,
});

class Home extends Component {
  render() {
    if (debugPerformance) {
      console.log('render Home');
    }
    const { name } = this.props;
    return <p>Hello {name}!</p>;
  }
}

export default connect(mapStateToProps)(Home);

On peut alors repérer quels composants sont rendus de trop nombreuses fois, ce qui donne un point d’entrée pour l’investigation. Les rendus sont provoqués par un changement d’un state ou d’une prop. Repérez quelle prop/state a changé. C’est alors une affaire de debug (points d’arrêt, logs…) pour identifier la source de ces changements.

Pour vérifier si un sélecteur émet une valeur de trop nombreuses fois, la même technique fonctionne :

export const nameSelector = createSelector(
  getUserState,
  userState => {
    if (debugPerformance) {
      console.log('nameSelector');
    }
    return userState.name;
  },
);

N’activez évidemment le flag debugPerformance que lorsque vous souhaitez analyser les performances. Les logs sont bien trop verbeux pour les autres développements.

En suivant ce diagnostic, nous pourrions arriver à certaines des causes et résolutions ci-dessous. Le flag est maintenu désactivé au cours des développements, et peut être réactivé de temps en temps (sans commit) pour vérifier si les composants ne sont pas rendus plus de fois que nécessaire, et le cas échéant reprendre le diagnostic.

Redux : utiliser immutablejs

Ce point est un pré-requis pour que les optimisations de cet article puissent s’appliquer. Les sélecteurs et les composants détermineront si un rendu est nécessaire en comparant leurs inputs (autres sélecteurs, props…) par référence. Immutablejs garantit que si la référence n’a pas changé, la valeur non plus, y compris dans les sous-objets. Vérifier si une réévaluation du composant/sélecteur est nécessaire est alors très peu coûteux.

Exemple de tutoriel

Ici, ça pourrait donner :

// reducer initial state
const initialState = fromJS({ name: "John" });

// selector
export const nameSelector = createSelector(
  getUserState,
  userState => {
    if (debugPerformance) {
      console.log('nameSelector');
    }
    return userState.get('name');
  },
);

Gestion du cache avec les sélecteurs

Passons maintenant aux moyens d’améliorer les performances. Nous commencerons par Redux car, sur une application classique en React / Redux, il y a beaucoup à gagner de ce côté. (Pour la lisibilité des exemples, j’omets les logs précédemment ajoutés.)

Syntaxe à respecter, instance à conserver

Le code d’abord, l’explication après :

// Selector
export const nameSelector = createSelector(
  getUserState,
  userState => userState.get('name'),
);

// Injection in component
const mapStateToProps = createStructuredSelector({
  name: nameSelector,
});

Ce qu’il ne faut PAS faire (bien que ce soit tentant pour passer des paramètres) :

// Selector
export const nameSelector = () => createSelector(
  getUserState,
  userState => userState.get('name'),
);

// Injection in component
const mapStateToProps = createStructuredSelector({
  name: nameSelector(),
});

Des sélecteurs mal écrits peuvent coûter particulièrement chers en performance, sans causer aucune erreur, donc sans qu’on le remarque. La fonctionnalité première des sélecteurs est d’accéder aux informations du store. La seconde de le faire efficacement, notamment via un cache. Chaque instance de sélecteur possède un cache interne. Créer une nouvelle instance réinitialise le cache, et réévalue le sélecteur. Le contre-exemple ci-dessus est en fait une factory de sélecteur. Nous allons voir ça plus bas.

Gestion des paramètres

Si vous passez d’une factory à une instance de composant, vous vous demanderez certainement comment passer un paramètre à un sélecteur. Voici deux moyens d’arriver à ses fins.

Paramètre statique, nombre fini de valeurs, connues à l’avance

Supposons que nous avons 4 étapes dans un formulaire, représentées par une énumération :

enum Step {
  step1 = 'step1', step2 = 'step2', step3 = 'step3', step4 = 'step4',
}

Nous voulons un sélecteur qui renvoie une valeur différente du store en fonction d’un paramètre : l’identifiant de l’étape. Reformulons un peu ce besoin : nous voulons une instance de sélecteur pour chaque étape, chacune renvoyant l’info correspondant à cette étape. Cela peut se faire via une factory :

// Factory
const stepNameSelectorFactory = <T extends Step>(step: T) => createSelector(
  (state: AppStore) => state.get(NAME_REDUCER).get(step as any),
  stepState => stepState.get('name'),
);

// Selectors, using a dictionary for generic usage:
export const stepNameSelector = {};
for (const step of Object.values(Step)) {
  stepNameSelector[step] = stepNameSelectorFactory(step);
}

// Injection in component
const mapStateToProps = createStructuredSelector({
  name: stepNameSelector[Step.step1],
});

// Or, if specific is fine:
export const step1NameSelector = stepNameSelectorFactory(Step.step1);
export const step2NameSelector = stepNameSelectorFactory(Step.step2);
export const step3NameSelector = stepNameSelectorFactory(Step.step3);

// Injection in component
const mapStateToProps = createStructuredSelector({
  name: step1NameSelector,
});

Ici, nous avons utilisé l’énumération comme liste de valeurs du paramètre sur laquelle boucler. Cette approche est valide pour d’autres listes de valeur, du moment que les valeurs sont finies et connues à l’avance. Notons que la différence par rapport au contre-exemple précédent est la création d’instances une fois pour toutes. Ce sont elles qui seront injectées dans les composants, plutôt qu’un appel systématique à la factory.

Paramètres dynamiques ou valeurs infinies : état de l’application

Souvent, les paramètres dynamiques seront un état de l’application, ou un état dérivé. L’information (ou sa source) doit alors se situer dans le store, et être accessible via un autre sélecteur.

Dans l’exemple suivant, la récupération du dossier se fait à partir d’un paramètre, l’ID de l’utilisateur trouvé dans le store.

// cases selector
const casesSelector = createSelector(
  getCaseState,
  caseState => caseState.get('cases'),
);

// user ID selector
const userIDSelector = createSelector(
  getUserState,
  userState => userState.get('id'),
);

export const userCaseSelector = createSelector(
  casesSelector,
  userIDSelector,
  (cases, id) => cases.find(case => case.get('userId') === id),
);

Paramètres dynamiques ou valeurs infinies : props d’un composant

Les sélecteurs supportent la notion de props d’un composant. Mais attention : une nouvelle instance de sélecteur doit être créée pour chaque composant ! Sinon, le cache sera inefficace. Pour ce faire, je vous renvoie à l’article de leur documentation : Sharing Selectors with Props Across Multiple Component Instances, qui présente toutes les explications et échantillons de code nécessaires pour appliquer cette méthode.

L’idée est que chaque composant ait une instance de sélecteur via une factory. Cette technique est notamment utile quand les paramètres sont dynamiques et correspondent à des props de composants.

Je ne suis dans aucun de ces scénarii

Pour conserver un sélecteur efficace, c’est-à-dire avec un bon cache, il vous faudra certainement adapter votre structure de code pour rentrer dans l’un des trois cas précédents. Par exemple créer un état de l’application représentant l’info requise, ou encore réarranger vos composants pour que le paramètre dynamique soit une prop. Vous risquez sinon de perdre l’efficacité du cache.

Redux : injection des props dans les composants « intelligents » qui en ont besoin

Continuons dans notre lancée de réduire au maximum les rendus des composants. Une approche courante, en redux, pour structurer ses composants et les connexions à redux, est de séparer en deux types de composants : les composants intelligents (gérant la logique de la vue : gestion des évènement, gestion d’onglets/stepper, gestion de la navigation…) et les composants de présentation (qui sont des feuilles dans l’arbre des composants de notre application, typiquement des composants génériques, les blocs composant la vue).

Lorsque l’application grandit, on peut facilement se retrouver avec de nombreux composants logiques intermédiaires qui prennent de nombreuses propriétés qui sont simplement passées à leurs enfants. Mais si l’une de ces propriétés change, toute l’arborescence est susceptible d’être re-rendue, ce qui est très coûteux et peut causer des ralentissements.

Il est alors utile de reconsidérer quels composants doivent être connectés à Redux. Il n’y a aucune obligation que seul le composant parent soit connecté. Les composants logiques enfants peuvent également être connectés, notamment si cela permet d’économiser des rendus des composants parents et frères. Seuls les composants de présentation purs peuvent être épargnés.

Le principe général à appliquer est que chaque composant ne doit recevoir que les props dont il a vraiment besoin pour son rendu, lui et non ses enfants (composants logiques). Ainsi, les composants non concernés ne seront pas re-rendus.

Exemple d’arborescence de composants :

  • Page d’accueil <- connexion à redux pour obtenir un titre à afficher
    • Carte d’accroche <- connexion à redux pour obtenir un lien
    • Stepper contenant un formulaire à chaque étape
    • Step n <- connexion à redux pour obtenir les données du formulaire de l’étape, un loader…

React : rendu des composants

Enfin, côté composants React, pour aller jusqu’au bout de l’optimisation, il faut s’assurer que chaque composant n’est re-rendu que si l’un des éléments de son state ou ses props change. Il y a deux approches pour ça.

Composants avec une classe

class Home extends PureComponent {
  render() {
    const { name } = this.props;
    return <p>Hello {name}!</p>;
  }
}

export default Home;

Composants fonctionnels

function Home(props) {
  const { name } = props;
  return <p>Hello {name}!</p>;
}

export default memo(Home);

Ces deux approches sont équivalentes et s’assurent que les rendus ne sont pas refaits tant que les props ne changent pas de référence.

React : props callback

Ajoutons à ça que bon nombre de propriétés sont des fonctions de callback. Les fonctions anonymes créées dans le render() du composant sont dangereuses car, au-delà du coût de création de la fonction, elles ont une nouvelle référence à chaque création. On perd à nouveau tout l’intérêt des optimisations précédentes, et les composants enfants feront le rendu à chaque fois, peu importe les optimisations qu’on y a faites.

Callback sans argument

Pour utiliser une méthode d’instance et en conserver la référence (tout en gardant l’accès au reste de l’instance via this), le plus simple est d’écrire le composant sous forme de classe.

class Home extends PureComponent {
  clickMe = () => console.log(‘I was clicked.’);

  render() {
    return <button onClick={clickMe}>Please click me</button>;
  }
}

export default Home;

La méthode étant définie au niveau du composant, la référence reste toujours la même.

Callback avec argument

function Home(props) {
  const { name } = props;
  const memoizedCallback = useCallback(
    () => console.log(‘I was clicked by’, name),
    [name],
  );
  return <button onClick={memoizedCallback}>Please click me</button>;
}

export default memo(Home);

Cette syntaxe utilisant l’API Hook de React 16 garantit que la référence est conservée tant que les paramètres ne changent pas. Utilisant l’API Hook, elle ne fonctionne que sur les composants fonctionnels.

Notez qu’on pourrait également utiliser cette approche avec un tableau vide (2è argument de useCallback) pour un cache permanent, pour le cas de méthode sans paramètre à utiliser en conservant la référence (comme fait précédemment avec un composant de type classe). Ou, simplement, utiliser d’autres fonctions en dehors de la fonction du composant.

Avec ces deux optimisations, les références des fonctions sont conservées, et les composants enfants ne seront rendus qu’en cas de changement qui impose un rendu.

React : mise en cache sur une fonction particulière

En bonus des points précédents, nous pouvons également mettre un cache sur des fonctions particulières.

import memoizeOne from 'memoize-one';

export const formatUser = memoizeOne(user => {
  const { id, name } = user;
  return {
    id: id.toLowerCase(),
    name,
  };
});

// Usage:
const formattedUser = formatUser(rawUser);

Gardez en tête que la taille du cache est d’un élément : seule la dernière valeur est renvoyée. Si l’un des arguments change, la fonction est ré-évaluée.

Conclusion

Toutes les optimisations précédentes ont des synergies. Ensemble, elles garantissent que les sélecteurs et le rendu des composants ne sont ré-exécutés que quand l’application en a vraiment besoin.

Vous avez repéré d’autres causes courantes impactant les performances ? N’hésitez pas à les partager en commentaire de cet article !

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*