Retour d'expérience : 5 idées pour améliorer les performances d'une application Web AngularJS

le 24/10/2013 par Michael Akbaraly
Tags: Software Engineering

Les technologies de développement Web ne cessent de s'améliorer et AngularJS en est une des plus intéressantes aujourd'hui.

Nous avons déjà parlé sur ce blog du développement d'applications de DataViz avec AngularJS et D3.js, ainsi que du référencement de ce type d'application Web.

Dans cet article, nous allons vous exposer 5 bonnes pratiques qui nous ont permis d'améliorer de manière très significative le ressenti utilisateur des performances Web d'une application donnée.

Le use case était un prototype d'application de gestion, développé avec AngularJS et quelques librairies tierces (HighCharts pour faire des graphes, x2js pour transformer du SOAP en JSON mais nous y reviendrons...).

L’application offrait les fonctionnalités demandées mais nous n’étions pas satisfaits des performances affichées. Nous nous sommes donc penchés sur ce problème, en privilégiant les améliorations les plus faciles à mettre en oeuvre et les plus visibles pour l’utilisateur.

Ce sont les 5 actions que nous avons mises en place que nous vous présentons dans cet article.

Les tests de performances ont été réalisés en se connectant à distance à l’application et en ayant le système de cache du navigateur activé.

  1. Attendre les réponses des webservices avant l’affichage de la page

L’ensemble des informations que l’on souhaite faire apparaître dans notre application provient d’une API mise à disposition par notre client, avec un temps de réponse assez long. Nous faisons alors face à un comportement récurrent, le résultat des requêtes n'apparaît qu’après l’affichage du corps statique (HTML) de notre page :

puis...

Chrome Inspector nous donne là encore plus d’informations :

Soit 1,25 secondes durant lesquelles l’utilisateur a le temps suffisant de se demander si sa requête a bien été prise en compte ou si l’application n’a pas bloqué.

L’API étant externe, il est difficile d’influer sur le temps de réponse des requêtes envoyées.

Il est malgré tout possible de rendre l’expérience de l’utilisateur plus agréable et intuitive, en acquérant les résultats avant l’affichage de l’ensemble de la page.

Cette action est à réaliser au routage de l’application, dans le fichier app.js.

Le code ci-dessus est un exemple de route disponible dans l’application.

En plus des propriétés templateUrl et controller, indiquant la vue et le contrôleur associés à notre route, s’ajoute la propriété resolve, permettant l’appel à toute fonction ou service dont on souhaite attendre la réponse avant le chargement de la page.

Ces résultats sont ensuite injectés dans le contrôleur, afin d’y être traités avant le rendu de la vue :

L’utilisateur a ainsi la satisfaction d’accéder à toute l’information en une fois.

Nous pouvons encore améliorer sa perception en affichant un spinner lors du chargement de la page, l’informant que sa requête a bien été prise en compte et qu’elle est en cours de traitement.

  1. Utiliser les promises et le service $q

Étude de cas :

Nous avons besoin d’une valeur qui n’est pas disponible par un appel simple à l’API, il faut en effet lancer une requête pour chaque client, stocker le résultat, puis lancer une nouvelle requête pour le client suivant, ajouter ce nouveau résultat au précédent, etc.

Les appels aux web services se faisant de manière asynchrone en Angular et chaque requête devant dans notre cas attendre le résultat de la précédente, une méthode triviale pour réaliser cette fonction est de sérialiser les appels à l’API comme suit:

L’application attend le résultat du premier appel pour déclencher le suivant et ainsi de suite.

Il y a donc une forte dépendance entre chaque requête, pouvant rendre non seulement le code difficilement lisible et maintenable (callback hell), mais empêche également l'exécution concurrente des différents appels :

Suivant l’idée développée dans le premier point de l’article, il serait donc intéressant de paralléliser ces appels.

Cela est possible en utilisant les promises et le service $q d’Angular,  rendant les appels au service totalement indépendants.

Chaque promise est stockée dans un tableau, qui est ensuite traité par la fonction $q.all(),  point de synchronisation, attendant le résultat de toutes les promises et renvoyant le résultat final.

Les promises sont exécutées ainsi en parallèle, avec à la clé un gain de temps non négligeable.

  1. Réduire le nombre de requêtes

Concaténer les fichiers avec Uglify et Grunt

Voici l’ensemble des scripts chargés à l’appel de toutes les pages de notre application :

L’inspection faite par l’outil Chrome Inspector, nous indique que près de 2 secondes sont nécessaires à l’appel de ces scripts à chaque chargement de notre application.

La timeline ci-dessus nous permet également de remarquer le comportement du navigateur, traitant les requêtes par paquet et en cascade car seulement capable de paralléliser un nombre limité d’appels.

Ces appels multiples ont donc un coût en terme de temps de chargement qu’il est nécessaire de réduire.

Cela est réalisable grâce à UglifyJs, outil permettant de concaténer et “minifier” les fichiers JavaScript afin d’accéder à l’ensemble des scripts en ne réalisant qu’une seule requête, rendant ainsi le chargement de la page plus rapide (disponible en plugin du builder JavaScript Grunt).

Nous passons ainsi de :<br><br>à:

et à une requête réalisée en 459 ms, soit une rapidité d'exécution bien supérieure à notre cas initial :

Toutefois, cette action “magique” n’est pas sans conséquence et nous comprenons mieux l’origine du nom de l’outil en parcourant le contenu du fichier généré :

Cette illisibilité rend ce fichier très difficile à débugger, aussi “l’uglification” des scripts est une action à ne réaliser qu’une fois, au déploiement de l’application en production.

A noter qu'on peut aussi se contenter de concaténer les fichiers sans les modifier, avec le plug-in grunt-contrib-concat de NodeJs.

  1. Utiliser le cache HTTP d’AngularJS

Angular propose un module de cache très simple à mettre en oeuvre, en initialisant la propriété ‘cache’ du service $http à ‘true’ :

$http.get(URL, { cache: true })

Ainsi, la première fois que le service $http enverra une requête à une URL, la réponse  sera stockée dans un cache nommé $http.

Ce cache est accessible grâce au service $cacheFactory:

Les requêtes basées sur la méthode GET bénéficient du principe d'idempotence REST qui permet d'exploiter naturellement un système de cache, ce n'est malheureusement pas le cas des méthodes POST dans les principales requêtes (SOAP) de l'application. Une API REST aurait donc été un grand avantage dans ce cas.

Cette option a ainsi amélioré certains aspects de l’application mais n’a pas réalisé l’optimisation principalement attendue : éviter les transactions client-API pour une même requête.

  1. N’en RESTez pas au SOAP

L’API utilisant le protocole SOAP, il a fallu trouver un mécanisme permettant de consommer de la meilleure manière les flux XML envoyés.

Après avoir pensé au début à parser manuellement le XML (solution vite abandonnée), nous avons imaginé ajouter une étape intermédiaire de transformation de la réponse en JSON afin de faciliter le traitement des données par Angular.

Après analyse du volume de données envoyé, il a été jugé raisonnable de gérer complètement les transactions avec l’API et la transformation en JSON dans l’application.

L’API étant hébergée sur un domaine différent de l’application, il a d’abord fallu contourner la restriction du same-origin-policy des navigateurs*,  empêchant l’application d’interroger les web services d’un domaine différent du sien en mettant en place un reverse-proxy sur notre serveur Apache.

La librairie x2js permet de gérer la conversion XML vers JSON directement en JavaScript et s’intègre efficacement à une application Angular.

Il est alors très simple de créer un service transformant tout fichier XML en JSON :

Le XML étant un format pouvant avoir de multiples formes et syntaxes, il reste nécessaire de vérifier la manière dont les données ont été transformées et d’ajouter un nettoyage des données personnalisé le cas échéant.

Au delà du temps de latence dû à l’appel à l’API, gérer la conversion du XML vers JSON est coûteux en terme de temps de réception du résultat (en jaune foncé) :

Il est possible de réduire le temps de réponse en mettant en place un proxy :

L’utilisation d’un proxy a pour avantages de :

- déléguer le travail d’interrogation de l’API et la transformation du XML en JSON au proxy

- rendre l’application RESTful et  permettre la mise en place d’un système de cache efficace

Ces améliorations permettent de réduire le temps de réponse, avec un temps de réception quasi instantané :

Toutefois cette solution n’a finalement pas été retenue pour notre application :  malgré un gain de performance sensible, la mise en place d’un proxy pose des questions de maintenance et d’hébergement qu’il a été jugé trop contraignant dans le cadre d’un prototype.

Conclusion

“Premature optimisation is the root of all evil” aussi la mise en place de ces actions n’est en rien dogmatique.

Elles ont néanmoins permis, pour une difficulté et un coût de réalisation raisonnable d’améliorer le temps de réponse de l’application et d’obtenir un gain indéniable et visible en terme de fluidité, rapidité et confort de navigation pour l’utilisateur.


* le Cross-Origin Resource Sharing est une solution alternative à la restriction de same-origin-policy  (réservée aux navigateurs les plus récents)