Écrire du JavaScript véloce et à faible empreinte mémoire

Par Addy Osmani

Qu’il s’agisse de V8, de Spider Monkey (Firefox), de Caracan (Opéra) ou de Chakra (IE), connaître le fonctionnement interne du moteur JavaScript vous permettra de mieux optimiser vos applications. Cela ne veut pas dire qu’il faut que vous optimisiez votre code pour un seul moteur ou navigateur : ne faites jamais cela !

Vous devriez plutôt vous poser ce type de questions :

  1. Que puis-je faire pour rendre mon code plus efficient ?
  2. Quelles optimisations communes sont faites par les moteurs JavaScript ?
  3. Quelles sont les limites de l’optimisation de ces moteurs ? Le ramasse-miette est-il bien capable de libérer la mémoire comme je m’y attend ?
JPEG - 126.8 ko

Les sites véloces, comme les voitures de course, nécessitent des outils spécialisés. Crédti de l’image : dHybridcars

Nombreux sont les pièges quand on souhaite produire du code rapide et économe en mémoire. Cet article propose donc d’explorer plusieurs techniques testées et approuvées pour écrire du code qui s’exécute efficacement.

Comment fonctionne JavaScript au sein de V8 ?

Bien qu’il soit possible d’écrire des applications volumineuses sans comprendre le fonctionnement des moteurs JavaScript, n’importe quel conducteur vous dira qu’il a déjà jeté un œil sous le capot au moins une fois. Étant donné que Chrome est mon navigateur préféré, je vais vous parler quelque peu de son moteur Javascript. Le cœur de V8 est composé de différentes pièces :

  1. Un compilateur de base qui parcourt votre JavaScript et génère du code machine avant qu’il soit exécuté, plutôt que d’exécuter du bytecode ou simplement de l’interpréter. Ce code natif n’est pour le moment pas très optimisé.
  2. V8 représente vos objets dans un modèle objet. Les objets sont représentés au sein de tableaux associatifs en JavaScript, mais dans V8, ils sont représentés avec les classes cachées (hidden classes) qui sont un système de typage interne pour optimiser la recherche de références.
  3. Le profileur à l’exécution surveille le système en fonctionnement et identifie les fonctions « chaudes » (le code qui est fréquemment exécuté[a]).
  4. Un compilateur optimisé recompile et optimise les fonctions chaudes identifiées par le profileur à l’exécution et procède à des optimisations comme l’inlining (le remplacement des appels de fonction par le corps de la fonction appelée).
  5. V8 supporte le retour sur optimisation[b] ce qui signifie que le compilateur peut révoquer le code optimisé s’il découvre que les optimisations s’avèrent contre-productives.
  6. V8 a également un ramasse-miette. Comprendre son fonctionnement peut être tout aussi important qu’optimiser son JavaScript.

Le ramasse-miette

Le ramasse-miette est un système de gestion de la mémoire. Il introduit la notion de collecteur qui tente de libérer la mémoire occupée par des objets qui ne sont plus utilisés. Dans un langage à ramasse-miette tel que JavaScript les objets qui sont toujours référencés par votre application ne sont pas effacés.

Déréférencer manuellement les objets s’avère inutile la plupart du temps. En déclarant simplement les variables à l’endroit où elles sont nécessaires (idéalement le plus localement possible ; à l’intérieur de la fonction où elles sont utilisées plutôt que dans un contexte parent), cela devrait fonctionner tout seul.

JPEG - 109.8 ko

Le ramasse-miette en train de libérer de la mémoire. Crédits de l’image : Valtteri Mäki.

Il est impossible de forcer l’intervention du ramasse-miette en Javascript. Ne pensez pas que c’est dommage, car le processus de libération de la mémoire est contrôlé par l’environnement d’exécution et il sait généralement mieux que vous quand un nettoyage s’impose.

Les mythes du déréférencement

Dans un bon nombre de discussions en ligne à propos de la libération de mémoire en JavaScript, le mot-clé delete a amené certains développeurs à penser qu’ils pouvaient forcer le déréférencement en l’utilisant, alors qu’il est seulement supposé retirer une clé d’un tableau de hachage. Évitez d’utiliser delete si vous le pouvez. Dans l’exemple ci-dessous, delete o.x fait plus de mal que de bien contrairement aux apparences. Il change la classe cachée de l’objet o et le transforme en objet générique plus lent.

var o = { x: 1 };
delete o.x; // true
o.x; // undefined

Cela dit, vous pouvez être certains de trouver delete dans la plupart des bibliothèques JavaScript populaires. Il a une utilité au sein du langage. Ce qu’il faut retenir, c’est qu’il modifie la structure des objets « chauds » à l’exécution. Les moteurs JavaScript repèrent ces objets chauds et tentent de les optimiser. C’est plus facile si la structure de ces objets évolue peu au cours de leur utilisation, or delete impose des changements.

Il y a également des erreurs d’interprétation sur le fonctionnement de null. Attribuer à la référence d’un objet la valeur null ne détruit pas l’objet associé. Cela détruit en réalité la référence à cet objet. Il vaut mieux utiliser o.x=null plutôt que le mot-clé delete, mais c’est rarement utile.

var o = { x: 1 };
o = null;
o; // null
o.x;  // TypeError

Si cette référence est la dernière pointant sur cet objet, alors il devient éligible pour être libéré de la mémoire. Mais si ce n’était pas la dernière référence à ce dernier, cet objet reste atteignable et ne sera donc pas détruit.

Une autre chose importante est que les variables globales ne sont pas détruites par le ramasse-miette durant toute la durée de vie de votre page. Que celles-ci soient affichées une fraction de seconde ou plusieurs heures, les variables affectées au contexte global d’exécution du JavaScript resteront en mémoire.

var monEspaceDeNomGlobal = {};

Les variables globales ne sont détruites que lorsque vous rafraîchissez la page, naviguez vers une autre page, fermez l’onglet ou votre navigateur. Les variables rattachées au contexte d’une fonction sont détruites quand elles tombent en dehors du contexte courant. Cela signifie qu’elles sont détruites quand les fonctions sont retirées de la pile d’exécution et qu’il n’y a plus de références pointant vers elles.

Quelques règles générales

Pour donner l’opportunité au ramasse-miette de libérer la mémoire occupée par vos variables le plus tôt possible, ne gardez pas de référence à vos objets plus longtemps que nécessaire. La plupart du temps, cela se fait automatiquement. Mais voici quelques règles à garder en tête :

  1. Comme dit plus haut, déclarez vos variables dans un contexte approprié plutôt que de les déréférencer manuellement. En d’autres termes, au lieu de créer une référence globale que vous devrez déréférencer plus tard, optez pour une variable locale qui sera détruite lorsque son contexte sera sorti de la pile. Vous aurez alors un code plus propre, et moins de choses à gérer.
  2. Assurez-vous de bien supprimer vos écouteurs d’événements des nœuds DOM dès lors qu’ils deviennent inutiles, en particulier juste avant de retirer les nœuds du document.
  3. Si vous cachez localement certaines données, assurez-vous de les nettoyer quand elles ne sont plus utiles ou de programmer leur suppression pour éviter que de trop grands volumes de données ne soient stockées en mémoire.

Les fonctions

Maintenant, intéressons-nous aux fonctions. Comme dit précédemment, le ramasse-miette fonctionne par récupération de pages mémoires qui ne sont plus référencées par l’application. Pour mieux illustrer notre propos, voyons quelques exemples.

function foo() {
        var bar = new GrosObjet();
        bar.appelQuelconque();
}

Quand le fil d’exécution sort de la fonction foo, l’objet vers lequel pointe bar est automatiquement éligible pour être libéré de la mémoire car il n’existe plus aucune référence à cet objet.

Comparons cet exemple au code suivant :

function foo() {
        var bar = new GrosObjet();
        bar.appelQuelconque();
        return bar;
}

//somewhere else
var b = foo();

Nous avons maintenant une référence à l’objet qui survit à l’appel de la fonction foo et qui persistera tant qu’aucun autre objet ne sera assigné à la variable b (ou tant que b sera dans le contexte d’exécution courant).

Les closures

Quand vous trouvez une fonction qui contient la déclaration d’une autre fonction et retourne une référence vers cette fonction, cette dernière aura accès au contexte de sa fonction parente même si sa fonction parente est retirée de la pile d’exécution. C’est ce qu’on appelle une closure ; c’est une expression qui a accès aux variables situées dans un contexte particulier. Par exemple :

function somme(x) {
   function faitLaSomme(y) {
       return x + y;
   };
   return faitLaSomme;
}

// Usage
var sommeA = somme(4);
var sommeB = sommeA(3);
console.log(sommeB); // Renvoie 7

L’objet de type function créé lors de l’appel à somme() ne peut être détruit puisqu’il est toujours référencé au sein du contexte global. Il est donc toujours référencé et reste tout ce qu’il y a de plus accessible via sommeA(n).

Intéressons-nous à un autre exemple. Pouvons-nous accéder à longueChaine ?

var a = function () {
   var longueChaine = new Array(1000000).join('x');
   return function () {
       return longueChaine;
   };
}();

Oui, bien sûr, via a(), donc elle n’a pas été détruite. Et dans l’exemple suivant ?

var a = function () {
   var petiteChaine = 'x';
   var longueChaine = new Array(1000000).join('x');
   return function (n) {
       return petiteChaine;
   };
}();

On ne peut plus y accéder et elle est éligible pour être détruite par le ramasse-miette.

Les timers

On trouve beaucoup de fuites de mémoires dans les boucles, mais setTimeout()/setInterval() ne sont pas mal non plus pour ça.

Considérons l’exemple suivant :

var monObjet = {
   callMeMaybe: function () {
       var maReference = this;
       var val = setTimeout(function () { 
           console.log('Tu perds ton temps!'); 
           maReference.callMeMaybe();
       }, 1000);
   }
};

Si nous appelons monObjet.callMeMaybe(); pour mettre en route le timer, nous pourrons voir toutes les secondes dans la console le message suivant : « Tu perds ton temps ! » Si nous tentons :

monObjet = null;

Le timer sera toujours actif. monObjet ne sera pas détruit puisque la closure transmise à setTimeout doit être conservée pour rester accessible. Par effet d’entraînement, monObjet est conservé puisqu’il encapsule maReference. Il en irait de même si nous avions passé cette closure à n’importe quelle autre fonction conservant une référence à cet objet.

C’est également important de garder à l’esprit que les références à l’intérieur des appels à setTimeout/setInterval, comme pour les fonctions, ne pourront être détruites qu’après la complète exécution de ces dernières.

Soyez à l’écoute des problèmes de performance

On ne le répétera jamais assez : n’optimisez votre code que lorsque vous constatez des problèmes de performance. On trouve des tas de micro-benchmarks démontrant que « N » est plus rapide que « M » dans V8, mais confronté à la réalité d’un vrai module de code ou d’une application concrète, l’impact de ces optimisations pourrait être bien moins important que ce que vous pensiez obtenir.

JPEG - 58.2 ko

En faire trop peut être aussi problématique que de ne rien faire du tout. Crédits image : Tim Sheerman-Chase

Imaginons que nous voulions créer un module qui :

  1. utilise une source de donnée locale contenant des éléments avec un identifiant numérique,
  2. affiche un tableau contenant ces données,
  3. assigne des gestionnaires d’événements qui commutent une classe quand un utilisateur clique sur une cellule.

Il y a plusieurs chose à prendre en compte dans ce problème, bien qu’il soit très simple à résoudre. Comment stockerons-nous les données ? Comment générer[e] le tableau de manière efficiente et l’insérer dans le DOM ? Comment pouvons-nous capturer les événements émis par le tableau de manière optimale ?

Une première méthode (naïve) serait de stocker chaque silo de données disponible dans des objets que nous regrouperions dans un tableau associatif. On pourrait utiliser jQuery pour parcourir ce tableau de données, générer le tableau et ensuite, l’insérer dans le DOM. Enfin, on pourrait utiliser les écouteurs d’événements pour programmer le comportement désiré au clic sur une cellule.

Ne faites SURTOUT PAS comme ça !

var moduleA = function () {
   return {

       data: dataArrayObject,

       init: function () {
           this.addTable();
           this.addEvents();
       },

       addTable: function () {

           for (var i = 0; i < rows; i++) {
               $tr = $('<tr></tr>');
               for (var j = 0; j < this.data.length; j++) {
                   $tr.append('<td>' + this.data[j]['id'] + '</td>');
               }
               $tr.appendTo($tbody);
           }

       },
       addEvents: function () {
           $('table td').on('click', function () {
               $(this).toggleClass('active');
           });
       }
   };
}();

C’est basique, mais ça fait ce qui a été demandé.

Dans ce cas, cependant, nous ne faisons qu’utiliser la propriété ID de type numérique qui aurait été plus avantageusement stockée dans un simple tableau. Point intéressant, utiliser documentFragment et les méthodes natives du DOM est plus optimal que d’utiliser jQuery de cette manière pour générer notre tableau. Enfin, bien sûr, écouter les événements au niveau du tableau aurait été plus performant que d’écouter les clics sur chaque cellule.

Notez que jQuery fait appel à DocumentFragment en interne, mais dans notre exemple le code appelle la fonction append() à chaque itération de la boucle, cloisonnement qui empêche une bonne optimisation. Cela ne devrait pas être si catastrophique mais n’oubliez pas de passer votre code au banc d’essai pour vous en assurer.

Dans notre cas, procéder à ces améliorations engendrera de bons gains en performance comme nous pouvons nous y attendre. Écouter les événements au niveau du tableau[g] apporte une amélioration intéressante, quant à l’utilisation de DocumentFragment, elle dope véritablement les performances.

var moduleD = function () {
   return {
       data: dataArray,

       init: function () {
           this.addTable();
           this.addEvents();
       },
       addTable: function () {
           var td, tr;
           var frag = document.createDocumentFragment();
           var frag2 = document.createDocumentFragment();

           for (var i = 0; i < rows; i++) {
               tr = document.createElement('tr');
               for (var j = 0; j < this.data.length; j++) {
                   td = document.createElement('td');
                   td.appendChild(document.createTextNode(this.data[j]));

                   frag2.appendChild(td);
               }
               tr.appendChild(frag2);
               frag.appendChild(tr);
           }
           tbody.appendChild(frag);
       },
       addEvents: function () {
           $('table').on('click', 'td', function () {
               $(this).toggleClass('active');
           });
       }
   };
}();

Nous pourrions également tenter d’autres approches pour améliorer les performances. Vous avez probablement lu quelque part qu’utiliser le prototypage est plus optimal que de créer un module (et nous l’avons confirmé au début de cet article), ou entendu que les moteurs de gabarits (templates) JavaScript sont très performants. Ils le sont parfois, mais utilisez-les pour clarifier votre code et précompilez vos gabarits. Testons et voyons si ces pratiques sont aussi intéressantes que ça.

var moduleG = function () {};

moduleG.prototype.data = dataArray;
moduleG.prototype.init = function () {
   this.addTable();
   this.addEvents();
};
moduleG.prototype.addTable = function () {
   var template = _.template($('#template').text());
   var html = template({'data' : this.data});
   $tbody.append(html);
};
moduleG.prototype.addEvents = function () {
  $('table').on('click', 'td', function () {
      $(this).toggleClass('active');
  });
};

var modG = new moduleG();

Comme on pouvait s’y attendre, les bénéfices en terme de performance sont négligeables ici. Les moteurs de template et le prototypage ne nous apportent rien de plus que ce que nous avions auparavant. Cela dit, ce n’est pas vraiment pour les performances que les développeurs modernes utilisent ces outils. C’est plutôt la clarté, le modèle d’héritage et la maintenabilité du code qui les motive.

Certains problèmes plus complexes concernent l’efficience du dessin avec canvas et la manipulation de pixels avec ou sans tableaux typés.

Jetez toujours un œil critique aux micro-benchmarks avant d’appliquer leurs conclusions à vos applications. Certains d’entre vous pourraient se souvenir de l’article Javascript templating shoot-off et la polémique qui s’en est suivie. Vous devez vous assurer que les résultats de vos tests ne sont pas influencés par des contraintes inexistantes dans une application concrète. Testez également ces optimisations dans leur contexte final.

Quelques astuces d’optimisation pour V8

Lister toutes les optimisations réalisables pour V8 s’éloignerait du sujet de cet article, mais voici cependant quelques astuces qui méritent d’être citées. En les gardant à l’esprit vous serez en mesure de limiter l’écriture de code non-performant.

  1. Certaines pratiques telles que l’utilisation de try...catch empêchent V8 de se lancer dans la moindre optimisation. Pour plus d’information sur les fonctions qui peuvent être optimisées et celles qui ne le peuvent pas, utilisez d8, un petit utilitaire en ligne de commande fourni avec V8, et l’option --trace-opt file.js.
  2. Si la vitesse d’exécution vous importe, essayez de garder vos fonctions monomorphes ; assurez-vous que les variables (tableaux, objets, paramètres de fonction) ne contiennent que des éléments du même type. Par exemple, évitez ce genre de code (NdT : en clair, faites comme si JavaScript était un langage typé) :
function ajoute(x, y) { 
  return x+y;
} 

ajoute(1, 2); 
ajoute('a','b'); 
ajoute(mon_objet, undefined);
  1. Ne réutilisez pas des éléments désinitialisés ou supprimés. Le résultat sera identique, mais votre code sera bien plus lent.
  2. N’écrivez pas de fonctions gigantesques car elles sont plus difficiles à optimiser.

Pour plus de trucs et astuces, je vous recommande de visualiser la vidéo Breaking the Javascript speed limit with V8 de Daniel Clifford au Google IO. Je vous recommande également Optimizing For V8 - A Series dans la foulée.

Objets vs Tableaux : que dois-je utiliser ?

  1. Si vous souhaitez stocker un paquet de nombres ou une liste d’objets de même nature, utilisez les tableaux.
  2. Si ce dont vous avez besoin sémantiquement est un objet avec un lot de propriétés (de types différents), utilisez un objet avec des propriétés. C’est très efficient en terme de mémoire et assez rapide d’accès.
  3. Qu’elles soient stockées dans des objets ou des tableaux, les clés numériques sont plus rapides d’accès lors de vos itérations que les propriétés des objets.
  4. Les propriétés des objets sont très complexes : elle peuvent être crées avec des accesseurs (getters/setters) et utiliser différents type d’énumérabilité et d’accès en écriture. Les éléments d’un tableau sont au contraire peu personnalisables ; ils existent ou pas, point final. Au niveau du moteur JavaScript, cela permet plus d’optimisations en terme d’organisation de la mémoire pour représenter sa structure. C’est d’autant plus bénéfique quand ces tableaux contiennent des nombres. Par exemple, si vous avez besoin de stocker des vecteurs, ne définissez pas une classe avec des propriétés x, y et z ; utilisez plutôt un tableau.

Il existe une différence majeure entre les objets et les tableaux en JavaScript, c’est la propriété magique length. Si vous gérez cette propriété vous-même, vos objets seront tout aussi rapides que des tableaux dans V8.

Quelques astuces pour l’utilisation des objets

  1. Créez vos objets via leur fonction constructeur. Cela permet d’assigner à chaque objet la même classe cachée et évite de changer cette dernière par la suite. Un autre point qui plaide pour cela est que c’est légèrement plus rapide que son homologue Object.create().
  2. Il n’y a aucune restriction sur le nombre de types d’objets différents au sein d’une application ou sur leur complexité (tout en restant raisonnable : les longues chaînes de prototype tendent à ralentir l’application, et les objets avec un nombre limité de propriété bénéficient d’une représentation spéciale leur conférant une rapidité accrue). Pour les objets « chauds », essayez de garder une chaîne de prototypes courte et peu de propriétés.

Clonage d’objets

Le clonage d’objets est un problème que rencontrent tous les développeurs d’applications. Bien qu’il soit possible de tester ses différentes implémentations pour observer comment elles se comportent avec le moteur V8, soyez prudents quand vous copiez quoi que ce soit. Copier des gros lots de données est généralement lent, ne le faites pas. Les boucles for...in sont particulièrement inadaptées pour cela car elles ont une spécification démoniaque et ne seront jamais rapides pour des objets arbitraires, quel que soit le moteur JavaScript.

Si vous avez absolument besoin de copier des objets dans une partie critique de votre code en terme de performance (et que vous ne pouvez pas contourner le problème), utilisez un tableau ou un “constructeur de copie” personnalisé qui copie chaque propriété individuellement. C’est probablement la manière la plus rapide de le faire.

function clone(original) {
 this.foo = original.foo;
 this.bar = original.bar;
}
var copie = new clone(original);

Les fonctions en cache grâce aux modules

Mettre vos fonctions en cache grâce au patron de conception « module » peut améliorer les performances. Regardez l’exemple ci-dessous, vous constaterez que l’une ou l’autre méthodes que vous avez l’habitude de voir est plus lente car elle oblige à copier les fonctions membres sans arrêt.

Vous noterez également que cette approche peut se révéler significativement meilleure que de s’appuyer sur la technique du prototype (et un test jsPerf le confirme).

PNG - 40 ko

Mesure des gains de performance des techniques modules et prototypes.

Voici un test comparatif entre les patrons de conception module et prototype.

 // Technique "Prototype"
 Klass1 = function () {}
 Klass1.prototype.foo = function () {
     log('foo');
 }
 Klass1.prototype.bar = function () {
     log('bar');
 }

 // Technique "Module"
 Klass2 = function () {
     var foo = function () {
         log('foo');
     },
     bar = function () {
         log('bar');
     };
     return {
         foo: foo,
         bar: bar
     }
 }


 // Technique "Module avec fonctions mises en cache"
 var FooFunction = function () {
     log('foo');
 };
 var BarFunction = function () {
     log('bar');
 };

 Klass3 = function () {
     return {
         foo: FooFunction,
         bar: BarFunction
     }
 }

 // Tests comparatifs

 // Prototype
 var i = 1000,
     objs = [];
 while (i--) {
     var o = new Klass1()
     objs.push(new Klass1());
     o.bar;
     o.foo;
 }

 // Module simple
 var i = 1000,
     objs = [];
 while (i--) {
     var o = Klass2()
     objs.push(Klass2());
     o.bar;
     o.foo;
 }

 // Module avec fonctions mises en cache
 var i = 1000,
     objs = [];
 while (i--) {
     var o = Klass3()
     objs.push(Klass3());
     o.bar;
     o.foo;
 }
// Le reste du code est sur jsPerf

Quelques astuces pour l’utilisation des tableaux

Maintenant, jetons un œil aux tableaux. En règle générale, ne supprimez pas d’éléments dans les tableaux. Cela oblige le moteur à basculer vers une représentation interne plus lente. Quand les clés d’un tableau deviennent éparpillées, le moteur est susceptible de le transformer en tableau associatif, qui est encore plus lent.

Représentation littérale des tableaux

L’initialisation littérale d’un tableau est préférable puisque cela donne un aperçu à la machine virtuelle de ce que vous souhaitez y stocker (taille, type de valeurs). Ils sont typiquement souhaitables pour des tableaux de taille petite à moyenne.

// Ici, le moteur JS voit que vous souhaitez un tableau contenant 4 nombres
var a = [1, 2, 3, 4];

// Ne faites pas ça :
a = []; // Ici, le moteur n'a aucune idée de ce qui ira dans le tableau
for(var i = 1; i <= 4; i++) {
    a.push(i);
}

Stocker un seul type de données versus données mixtes

Il n’est jamais bon de mélanger différents types de valeurs dans un même tableau (des nombres, chaînes, objets, booléens ou undefined, comme par exemple : var arr = [1, “1”, undefined, true, “true”]).

Test de performance de l’inférence de type

Comme vous pouvez le constater, le meilleur résultat est obtenu par le tableau d’entiers.

Tableaux à clés consécutives ou non

Quand vous utilisez des tableaux aux clés non consécutives, soyez bien conscients qu’accéder à leurs éléments est bien plus lent que si les clés se suivaient dans leur ordre naturel. La raison est que s’il manque trop d’éléments le moteur V8 choisira de ne pas leur allouer d’espace mémoire. À la place, il préférera les gérer au sein d’un tableau associatif, ce qui est bien plus lent en terme de temps d’accès.

Test comparatifs entre tableaux à clés éparses et clé consécutives

Ce test montre que la somme des éléments d’un tableau aux clés consécutives est plus rapide. Le fait qu’il contienne ou non des zéros ne fait aucune différence.

Tableaux « troués » contre tableaux « pleins »

Évitez de trouer vos tableaux (en supprimant des éléments ou en faisant a[x] = foo (dans le cas où x > a.length). Il suffit qu’un seul élément manque dans un tableau pour ralentir le code.

Test entre tableaux « troués » et tableaux « pleins »

Allouer l’espace du tableau à l’initialisation ou à la volée

Ne pré-allouez pas les tableaux dont la taille excéderait 64 000 éléments. Faites-les plutôt grossir à la volée. Avant d’interpréter les tests sur ce point, rappelez-vous bien que leurs résultats sont fortement dépendants du moteur JavaScript testé.

JPEG - 30.5 ko

Test des tableaux initialement vides par rapport aux tableaux pré-alloués.

Nitro (Safari) traite les tableaux pré-alloués plus favorablement. Cependant, c’est le contraire pour les autres moteurs (V8 et Spider Monkey) pour lesquels allouer l’espace à la volée est plus efficace.

Tester les tableaux pré-alloués

// Tableau vide
var arr = [];
for (var i = 0; i < 1000000; i++) {
   arr[i] = i;
}

// Tableau pré-dimensionné
var arr = new Array(1000000);
for (var i = 0; i < 1000000; i++) {
   arr[i] = i;
}

Optimiser vos applications

Dans le monde des applications Web, la performance est primordiale. Aucun utilisateur ne souhaite qu’un tableur prenne plusieurs secondes pour faire la somme des cellules d’une colonne, ou attendre plusieurs minutes que s’affiche le résumé de leurs messages. C’est pourquoi rendre votre code aussi véloce que possible est parfois critique.

JPEG - 146.7 ko

Crédits de l’image : Per Olof Forsberg

Bien qu’il soit utile de comprendre et d’améliorer les performances de vos applications, cela peut être difficile. Nous recommandons la démarche suivante pour venir à bout des points qui vous ralentissent :

  1. Mesurer : repérer les points les plus lents de votre application ( 45%)
  2. Comprendre : trouver ce qui provoque cette lenteur ( 45%)
  3. Corriger ! ( 10%)

Quelques-uns des outils suivants peuvent vous aider dans cette tâche.

Mesurer la performance

Il existe de nombreuses façons de tester les performances de vos bouts de code en JavaScript mais en règle générale on effectue une comparaison entre deux chronométrages. L’équipe de jsPerf a proposé un modèle qui a été ensuite repris par les suites de tests que sont SunSpider et Kraken.

var tempsTotal,
   depart = new Date,
   iterations = 1000;
while (iterations--) {
 // Placer ici le code a tester
}
// tempsTotal → Nombre de millisecondes necessaires 
// pour executer les instructions 1000 fois
tempsTotal = new Date - depart;

Dans cet exemple le code à tester est placé dans une boucle et exécuté un nombre déterminé de fois. Une fois terminé, la date de début d’exécution est soustraite à la date de fin déterminant le temps écoulé durant le test.

Néanmoins cette façon de faire des tests est très réductrice, surtout si vous voulez mesurer dans différents navigateurs et environnements. Le ramasse-miette lui-même peut avoir des effets sur vos résultats. Même en affinant la mesure en utilisant window.performance, vous pouvez être sûrs que des impondérables viendront fausser les mesures.

Que vous écriviez simplement des tests sur des parties de votre code, des suites de tests ou une librairie complète, il y a bien plus de facteurs à prendre en compte que vous ne pouvez l’imaginer. Pour un guide plus complet sur les tests de performance, je vous recommande fortement de lire Javascript Benchmarking de Mathias Bynens et John-David Dalton.

Le profilage

Les outils pour développeur de Chrome permettent de profiler aisément vos applications. Vous pouvez utiliser cette fonctionnalité pour identifier les fonctions les plus chronophages pour les optimiser en priorité. Cette démarche est importante car la moindre petite modification de votre code peut avoir des conséquences importantes en termes de performance.

JPEG - 40.8 ko

L’onglet “profilage” des outils pour développeur de Chrome.

Pour commencer le profilage, il faut obtenir une mesure de base des performances actuelles de votre application. Cette dernière peut être obtenue grâce à l’onglet “timeline” (frise chronologique). Cela nous permettra de mesurer le temps effectif d’exécution de notre application.

L’onglet “profiles” nous donnera lui une vue plus détaillée de ce qui se passe vraiment dans notre application. Le profilage processeur (JavaScript CPU profile) nous permet de savoir combien de temps processeur est utilisé par notre application, le profilage des sélecteurs CSS (CSS selector profile) nous renseigne sur le temps nécessaire pour calculer les sélecteurs CSS, et les captures de pile de mémoire Heap (Heap snapshots) nous permet de savoir combien nos objets consomment de mémoire.

L’utilisation de ces outils nous permet d’ajuster et de profiler à nouveaux notre code afin de vérifier si les changements réalisés ont bien un impact positif sur les performances.

JPEG - 79.7 ko

Pour une bonne introduction au profilage dans Chrome, lisez Javascript Profiling With The Chrome Developper Tools par Zack Grossbart.

Petit truc : dans l’idéal, il vaut mieux éviter que les extensions et applications installées dans Chrome n’influent sur vos résultats. Pour cela, exécutez Chrome avec l’option --user-data-dir <un_répertoire_vide>. Cela suffit la plupart du temps, mais si vous avez besoin d’aller plus loin les options de V8 vous seront d’une grande aide.

Eviter les fuites mémoire : trois techniques pour les déceler

Les équipes internes de Google, celle de Gmail par exemple, utilisent énormément les outils pour développeurs de Chrome pour traquer et éliminer les fuites mémoires.

JPEG - 39 ko

Statistiques d’utilisation mémoire dans les outils pour développeurs.

Les statistiques utilisées par nos équipes sont par exemple : l’utilisation mémoire privée, la taille de la pile Heap de JavaScript, le nombre de nœuds DOM, l’effacement des données, le nombre de gestionnaires d’évènements et le comportement du ramasse-miette. Les habitués des architectures dirigées par les événements seront peut-être contents d’apprendre que la plupart de nos problèmes venaient de l’utilisation de listen() non suivi d’unlisten() (Closure) et par l’absence de dispose() pour les objets créant des gestionnaires d’évènements.

Par chance, les outils pour développeurs peuvent nous aider à localiser ces problèmes et Loreena Lee a créé une présentation fantastique, qui détaille la technique des trois captures pour détecter les fuites mémoires, que je ne vous recommenderai jamais assez de lire.

La base de la technique est d’enregistrer un certain nombre d’action utilisateur dans votre application, forcer l’éxecution du ramasse-miette, vérifier si le nombre de noeuds DOM reste supérieur à la normale puis comparer trois captures successives de la pile Heap pour détecter s’il y a une fuite de mémoire.

Gestion de la mémoire dans les applications sans rechargement de page

La gestion de la mémoire est très importante au sein des applications qui ne sont jamais rechargées (avec AngularJS, Backbone, Ember..). En effet, une fuite de mémoire devient rapidement handicapantes. C’est un énorme piège au sein des applications pour appareils mobiles (qui sont limités en mémoire) comme les clients mails ou les applications de réseaux sociaux qui ont une durée d’utilisation très importante. Comme on dit : de grands pouvoirs impliquent de grandes responsabilités.

Il y a plusieurs façon de se prémunir de cela. Dans Backbone, assurez-vous de toujours déréférencer vos vues et références en utilisant dispose() (actuellement disponible dans Backbone (edge)).

Cette fonction, ajoutée tout récemment, supprime tout gestionnaire associé aux évènements de la vue ainsi qu’à toute collection ou écouteur de modèle pour lesquels la vue a été passée en troisième argument (en tant que contexte de callback). dispose() est également appelé par la méthode remove() de la vue, et s’occupe de nettoyer la majorité de l’utilisation mémoire lorsque la vue est supprimée de l’écran. D’autres librairies, comme Ember, suppriment les observateurs quand ils détectent que les éléments ont étés supprimés de la vue pour éviter les fuites mémoires.

Voici les conseils avisés de Derick Bailey :

Plutôt que d’essayer de comprendre comment fonctionnent les évènements en terme de références, suivez simplement les règles standard de gestion de la mémoire en JavaScript et vous serez tranquilles. Si vous chargez des données dans une collection Backbone pleine d’objets Utilisateurs et voulez que cette collection soit nettoyée par la suite afin de ne plus utiliser de mémoire, vous devez supprimer toute référence à cette collection et aux objets qu’elle contient. Une fois toutes les références supprimées, la mémoire sera libérée. Ce n’est rien d’autre que le comportement standard du ramasse-miette de JavaScript.

Dans son article, Derick couvre la plupart des erreurs communes de gestion de mémoire liées à l’utilisation de Backbone et comment les corriger.

Il y a également un bon tutoriel disponible pour déboguer les fuites mémoires dans Node écrit par Felix Geisendörfer. Particulièrement intéressant si votre application Node est utilisée dans le contexte d’un application sans rechargement de page.

Eviter les réagencements

Quand un navigateur doit recalculer la position et la forme des éléments d’un document afin de les redessiner, il effectue ce qu’on appelle un reflow (réagencement). C’est une opération qui bloque l’interface utilisateur, il est donc important de savoir comment limiter le temps qu’elle dure.

JPEG - 45.7 ko

Graphique montrant une succession de réagencements

Il vaut mieux regrouper les méthodes qui provoquent des réagencements ou un repaint (changement d’apparence d’un ou plusieurs éléments, sans changements de dimensions) et les éviter autant que possible. Si c’est nécessaire, effectuez de préférence vos modification en-dehors du DOM. Utilisez pour cela un DocumentFragment qui fonctionne à l’identique mais est bien plus léger. Voyez-le comme une façon d’extraire une portion de l’arbre du document ou de créer un nouveau « fragment » de document. Plutôt que de constamment ajouter des nœuds au document principal via le DOM, nous pouvons construire un fragment de document avec ce dont nous avons besoin et l’insérer en une seule fois dans le DOM, ce qui évite des reflows successifs et inutiles.

Par exemple, écrivons une fonction qui ajoute 20 éléments div à un autre. Le faire en ajoutant le nouveau div aussitôt qu’il est créé risque de déclencher 20 réagencements.

function ajouteDivs(element) {
 var div;
 for (var i = 0; i < 20; i ++) {
   div = document.createElement('div');
   div.innerHTML = 'Coucou!';
   element.appendChild(div);
 }
}

Pour contourner ce problème, nous pouvons utiliser un DocumentFragment auquel nous ajouterons chaque div. Quand nous intègrerons ce fragment au document principal, tous ses nœuds enfants seront ajoutés en une fois, ce qui ne provoque qu’un seul recalcul.

function ajouteDivs(element) {
 var div; 
 // Crée un fragment de document vide
 var fragment = document.createDocumentFragment();
 for (var i = 0; i < 20; i ++) {
   div = document.createElement('a');
   div.innerHTML = 'Coucou!';
   fragment.appendChild(div);
 }
 element.appendChild(fragment);
}

Vous pouvez en savoir plus sur ce thème avec les articles Make the Web Faster, JavaScript Memory Optimization et Finding Memory Leaks.

Détecter les fuites de mémoires dans JavaScript

Afin d’aider à la détection des fuites mémoires, deux de mes collègues Googlers (Marja Hölttä et Jochen Eisinger) ont développé un outil qui fonctionne avec les outils pour développeurs de Google Chrome (avec le protocole d’inspection à distance pour être précis). L’outil récupère plusieurs captures de la pile heap pour détecter les objets provoquant des fuites mémoire.

JPEG - 27.5 ko

Un outil détectant les fuites mémoire dans Javascript

Il existe un billet complet sur la façon d’utiliser l’outil et je vous encourage à le lire ou à jeter un œil sur la page du projet Leak Finder.

Si vous vous demandez pourquoi un tel outil n’est pas directement intégré aux outils pour développeurs, sachez qu’il y a deux raisons. La première, c’est qu’à l’origine l’outil a été développé pour nous aider à capturer des scénarios spécifiques dans la Closure Library, et la deuxième c’est qu’il correspond plus à un outil externe (ou peut-être un jour en tant qu’extension, si nous mettons en place une API de profilage de la pile Heap).

Les options de V8 pour déboguer les optimisations et le ramasse-miette

Chrome supporte le passage d’options directement à V8 via l’option js-flags pour obtenir plus d’informations sur les optimisations effectuées par le moteur. Par exemple, cette commande trace les optimisations de V8 :

/Applications/Google Chrome/Google Chrome" --js-flags="--trace-opt --trace-deopt

Si vous êtes sur Windows, exécutez :

chrome.exe --js-flags="--trace-opt --trace-deopt"

Voici les options de débogage disponibles pour développer avec Chrome :

  1. trace-opt : enregistre dans un fichier le nom des fonctions optimisées et montre à quels endroits V8 ne peut optimiser le code faute de pouvoir le cerner correctement.
  2. trace-deopt : enregistre dans un fichier les portions de code qui ont été déoptimisées pendant l’exécution.
  3. trace-gc : enregistre dans un fichier chaque appel au ramasse-miette.

Dans ces fichiers, V8 indique les fonctions optimisées avec une astérisque (*) et celles qui ne le sont pas avec un tilde (~).

Si vous souhaitez en apprendre plus au sujet des options de V8 et de la façon dont il fonctionne je vous recommande fortement de jeter un œil à l’excellent billet de Vyacheslav Egorov qui résume les meilleurs ressources disponibles actuellement sur le sujet.

Mesures temporelles précises : HRT et Navigation Time API

High Resolution Time (HRT) est une interface JavaScript délivrant une mesure du temps avec une précision de l’ordre du millionième de microseconde, non sujette aux variations de l’horloge système et aux ajustements de cette dernière par l’utilisateur. Voyez cela comme une façon plus précise de faire des tests de performance que la méthode vue précédemment avec new Date() et Date.now(). C’est très pratique quand vous écrivez des tests de performance.

JPEG - 28.3 ko

High Resolution Time (HRT) indique le temps avec une précision au millionième de milliseconde près.

HRT est disponible dans Chrome (version stable) via window.performance.webkitNow(), mais la version canary de Chrome propose déjà une version non-préfixée (window.performance.now()) Paul Irish a rédigé une présentation plus complète de window.performance sur HTML5Rocks.

Nous avons une mesure temporelle précise, mais quid de la mesure précise de performance sur le web ?

Eh bien, nous avons maintenant à notre disposition la Navigation Timing API. Cette API propose une façon simple d’obtenir des mesures précises et détaillées, récupérées durant le chargement de la page et sa présentation aux utilisateurs. Les mesures sont exposées via l’objet window.performance.timing que vous pouvez simplement obtenir par le biais de la console Javascript.

JPEG - 51.8 ko

Mesure de performances affichées dans la console JavaScript.

On peut aisément extraire diverses informations très pratiques des données ci-dessus. Par exemple, la latence du réseau via responseEnd - fetchStart, le temps nécessaire au chargement de la page à partir de sa réception via loadEventEnd - responseEnd et le temps nécessaire pour passer du clic à l’affichage de la nouvelle page via loadEventEnd - navigationStart.

Comme vous pouvez le voir ci-dessus, une propriété performance.memory est disponible. Elle donne des informations sur l’usage de la mémoire par JavaScript comme par exemple la taille de la pile Heap.

Pour plus d’informations sur la Navigation Timing API, lisez le super billet de Sam Dutton : Measuring Page Load Speed With Navigation Timing.

About:memory et about:tracing

about:tracing dans Google Chrome affiche les performances du navigateur lui-même, et enregistre l’activité de chaque thread, de chaque onglet et de chaque processus.

JPEG - 30.1 ko

about:tracing montre les performances du navigateur.

Ce qui est très pratique c’est que cet outile permet de capturer et profiler les mécanismes internes de Chrome de manière à pouvoir ajuster et optimiser votre JavaScript de manière appropriée.

Lili Thompson a écrit un excellent article destiné aux développeurs de jeux vidéos HTML5 sur la façon d’utiliser about:tracing pour le profilage de WebGL. Les autres développeurs y trouveront aussi des informations utiles.

Naviguer à l’intérieur de la page about:memory de Chrome est également d’une grande aide. Il montre l’utilisation exacte de chaque onglet ce qui peut aider à détecter facilement les applications causant des fuites de mémoire.

Conclusion

Comme nous l’avons vu, il y a nombre de subtilités à connaître dans le monde de la performance des moteurs JavaScript, et pas de solution miracle. Ce n’est qu’en confrontant vos optimisations à des tests grandeur nature que vous pourrez estimer les gains en performance les plus importants. De plus, savoir comment les moteurs JavaScript interprètent et optimisent votre code peut vous fournir des pistes pour le développement de vos applications.

Mesurez, comprenez, corrigez ! ...et recommencez.

JPEG - 155.6 ko

Crédits image : Sally Hunter

Soyez conscients de l’importance de la performance, mais sans aller jusqu’aux micros optimisations qui pourraient nuire à la simplicité de votre code. Par exemple, certains développeurs préfèrent utiliser .forEach et Object.keys plutôt que les boucles for et for...in, malgré leur lenteur, pour pouvoir garder la main sur le contexte. Sachez distinguer les optimisations qui sont absolument indispensables à votre application de celles dont elle peut se passer.

Aussi, soyez bien conscient que bien que les moteurs Javascript continuent à améliorer leurs performances, la vraie bataille pour les performances se situe au niveau du DOM. Les réagencements et les recalculs graphiques (reflows et repaints) doivent être évités à tout prix, souvenez-vous donc de ne toucher au DOM que quand c’est absolument nécessaire. Et allez-y mollo sur le réseau : les requêtes HTTP sont précieuses, surtout dans un contexte de mobilité, vous devriez exploiter le maximum du potentiel du cache HTTP pour réduire le poids de vos ressources.

Garder tout cela à l’esprit vous assure de tirer le meilleur parti des informations contenues dans ce billet. J’espère que vous l’avez trouvé utile !

L'article original a été relu par Jakob Kummerow, Michael Starzinger; Sindre Sorhus, Mathias Bynens, John-David Dalton et Paul Irish.

Fiche technique

À propos de l'auteur

Articles similaires

Voici la liste des dix articles les plus récents en rapport avec cet article :

Confirmé

JavaScript

© 2001-2016 Pompage Magazine et les auteurs originaux - Hébergé chez Nursit.com ! - RSS / ATOM - About this site