mardi 28 mai 2013

AngularJS et performances

La plupart des applications AngularJS ne posent pas de problèmes particuliers de performances. Mais dans certains cas, où une page peut comporter un grand nombre de bindings, il va falloir y prêter attention pour éviter les ralentissements.

Les développeurs d’AngularJS ont toujours indiqué que le framework est capable de supporter sans problème 2000 bindings dans une page web, sur une machine un peu ancienne. C’est un ordre de grandeur, un ordinateur récent avec un navigateur web rapide pourra en supporter beaucoup plus sans soucis. Par contre sur un smartphone un peu poussif, il faudra être prudent pour éviter de se retrouver avec une application pénible à utiliser.

Ça peut paraître beaucoup, 2000 bindings, et dans la majorité des pages d’une application on sera très loin de cette limite théorique, mais avec un gros tableau on peut l’atteindre facilement. Dans le cas d’un tableau comportant 10 colonnes, avec un binding par colonne, il suffit d’afficher 200 lignes pour arriver aux 2000 bindings. Dans tous les cas où l’on va afficher tout un ensemble de données pour chaque élément d’une collection potentiellement grande, que ce soit sous la forme d’un tableau, d’une liste ou de toute autre façon, il va falloir faire attention.


Fonctionnement du dirty checking et des watches

Pour rafraîchir la vue dynamique créée à partir du template HTML, AngularJS utilise des watches. La méthode $watch du scope associe un traitement à exécuter lorsque la valeur d’une expression est modifiée.

A chaque événement, AngularJS parcourt tous les watches, et évalue toutes les expressions, pour déclencher si nécessaire les traitements correspondants. Quand dans le template on met une expression entre double accolades pour afficher un texte provenant du modèle de données, ou qu’on le fait avec un attribut ng-bind, la directive crée un watch pour cette expression, avec du code qui rafraîchit le contenu texte de l’élément HTML si la valeur de l’expression est modifiée.

Et bien sûr, si on le fait à l’intérieur d’un ng-repeat, ça va créer autant de watches que d’occurrences dans la répétition.

Ne pas surveiller n’importe quoi

L’expression d’un watch doit être très rapide à évaluer. Ça peut être un accès à une propriété d’un objet, ou le résultat d’une fonction simple, ou n’importe quel calcul très rapide.

Il ne faut jamais faire de watch sur une expression coûteuse à évaluer, car AngularJS va l’évaluer un grand nombre de fois, et ça aurait forcément un impact très négatif sur les performances de l’application. C’est vrai aussi pour les watches implicites, ceux créés par les directives, ce qui implique qu’il ne faut jamais utiliser une expression coûteuse à évaluer dans une directive qui place un watch.

L’extension Chrome Batarang permet de visualiser les watches d’une application AngularJS, et dispose d’un onglet “Performance” permettant de connaître l’impact en temps d’exécution de chaque expression utilisée dans un watch. C’est la première chose à aller voir en cas de problème de lenteur dans une application.

Mettre en cache dans le scope

On peut améliorer les performances facilement en mettant en cache les résultats des évaluations complexes. Plutôt que de faire évaluer systématiquement une expression coûteuse, on va stocker son résultat dans le scope, et le recalculer seulement lorsque c’est nécessaire. Pour cela, on place dans le contrôleur un watch explicite, avec la méthode $watch du scope, qui fait le calcul complexe et stocke son résultat dans le scope.

C’est ce que j’ai fait dans mon outil de présentation de slides. Le code source des slides est au format Markdown, il est parsé et converti en HTML. Mais ce code source est rarement modifié. S’il y avait des bindings avec comme expression la fonction qui convertit le source Markdown en HTML, la conversion serait faite plusieurs fois à chaque événement tel que l’appui sur une touche, et les performances seraient désastreuses. Au contraire, un simple watch explicite sur le contenu du code source va déclencher la conversion seulement lorsqu’il est modifié :

$rootScope.$watch('source.markdown', function () {
    $rootScope.slides = Presentation.parseSource($rootScope.source.markdown);
});


Le résultat de la conversion est stocké dans le scope, et les bindings portent sur ce résultat, sans nécessiter de faire la conversion à chaque évaluation d’un binding.

Paginer côté client

Parfois ce n’est pas suffisant, aucun watch n’est très coûteux en soi, mais c’est leur grand nombre qui pose problème. C’est le cas quand on fait une répétition sur une grosse collection.

La solution la plus simple est alors de paginer. Si on limite les données affichées dans la page en faisant la répétition (ngRepeat) sur seulement une portion de la collection, alors les directives utilisées dans la partie répétée ne vont plus placer des watches pour tous les éléments de la collection, mais uniquement pour ceux qui sont pris en compte dans la répétition. Quand on pagine côté client, les performances d’AngularJS vont être liées au nombre d’éléments affichés simultanément, et non plus au nombre d’éléments total de la collection.

Problème du scroll infini

Si on préfère une ergonomie en scroll infini à de la pagination, en limitant le nombre d’éléments sur lesquels on fait la répétition au départ, on va aussi améliorer les performances. Mais quand on va scroller, beaucoup scroller, ce nombre va augmenter de plus en plus et peut finir par poser problème. Or il n’est pas facile d’enlever les watches des éléments qui ne sont plus visibles parce qu’on a scrollé, tout en conservant un fonctionnement normal de l’ascenseur.

Mais il y a une autre façon d’optimiser les performances, c’est de générer moins de watches, lorsque c’est possible. Souvent, les données des éléments de la collections ne seront jamais modifiées, et du coup il est inutile d’avoir des watches qui vont surveiller en les recalculant sans arrêt des expressions dont la valeur ne changera jamais.

Imaginons qu’on veuille faire une page web comme la timeline de Twitter, qui fonctionne avec du scroll infini ; enfin pas si infini que ça d’ailleurs, mais on peut quand même scroller longtemps avant d’arriver au bout de la collection de tweets. Les données des tweets n’évoluent pas, et si on a récupéré des centaines de tweets, c’est du gâchis d’avoir des watches qui surveillent le texte des tweets ou le nom de leur auteur, ou encore l’adresse de son image de profil...

Binding sans watch

Dans un tel cas, il faudrait pouvoir faire des bindings résolus une seule fois, qui ne placent pas de watch dans le scope, et n’ont donc pas de gros impact sur les performances. Ce n’est pas possible avec les directives fournies par AngularJS, mais on peut le faire en écrivant ses propres directives.

Ou alors on peut aussi, et c’est encore plus simple, utiliser une directive déjà écrite comme celle-ci : “Bindonce”. Elle permet de faire un bind sans watch sur des données qui ne sont pas susceptibles d’évoluer. On peut même l’utiliser avec des données récupérées en asynchrone, auquel cas il y a quand même un watch temporaire, mais un seul watch sur l’objet complet, et qui est supprimé lorsque les données sont arrivées. De toute façon à l’intérieur d’une répétition, la portion du template ne sera répétée qu’une fois que les éléments de la collection auront été chargées, donc le watch temporaire est inutile.

Directive spécifique

Il est aussi possible de réduire le nombre de watches en créant une directive spécifique à ce qu’on veut afficher, qui va construire la portion de template répétée en manipulant le DOM, et sans placer toute une série de watches inutiles.

Dans l’exemple des tweets, on peut faire une directive qui affiche complètement un tweet. Mais il ne faut pas qu’elle utilise un template avec les directives standards d’AngularJS qui impliquent la pose de watches. Elle doit récupérer les données du tweet dans le scope et construire directement dans le DOM, et une fois pour toute, la portion de HTML à afficher.

C’est faisable, mais ça demande plus de travail que d’utiliser une directive générique comme celle citée précédemment (Bindonce). Et ce n’est pas forcément beaucoup plus efficace en terme de performances.

Pas d’optimisations prématurées

Dans tous les cas, il y a une règle générale concernant les optimisations, qui n’a rien de spécifique à AngularJS : il ne faut optimiser que lorsqu’il y a un problème de performances. Dans la plupart des cas tout se passe très bien, et il n’est pas souhaitable de complexifier l’application inutilement. S’il n’y a pas trop de bindings dans la page, le gain de performances serait sans doute trop faible pour que l’utilisateur s’en rende compte, et pour justifier des optimisations superflues.


4 commentaires:

  1. Merci pour ces précisions très utiles.

    RépondreSupprimer
  2. Ce commentaire a été supprimé par l'auteur.

    RépondreSupprimer
  3. Merci pour cet article très interessant.

    Dans le cadre d'un projet pro, j'ai du faire face aux limites d'angular, surtout lorsqu'on charge les composants avec des change, blur, et autres directives...

    J'ai réalisé un petit workaround qui permet d'utiliser des champs "simples", avec un minimum de données angular (ng-model) en conservant le même niveau d'UI : un nouveau champs est dynamiquement créé et utilise le scope pour interpreté les données à executer. Bien sur, tout n'est pas rose et il faut adapter son code mais grace a cela, la page devient utilisable et se charge relativement vite.
    En couplant cette technique et le module "bindonce", on respire enfin.

    En espérant que cela pourra aider quelques personnes.
    Le code n'est pas exemplt de bug ou de regression sur certains navigateurs. Je suis preneur de te modification constructive :)

    http://jsfiddle.net/U3pVM/1614/

    RépondreSupprimer
  4. en référence au dernier article dans le quelle vous préconisez d’optimiser le code seulement lorsque on touche a l'impatience de l'utilisateur. Ne penser vous pas qu'il faudrait aussi faire attention a la consommation d’énergie? Je pence notamment aux contrainte de batterie des smart phone actuel.

    RépondreSupprimer