Écrivez du code JavaScript robuste

GNU/Linux Magazine n° 211 | janvier 2018 | Grégory Jarrige
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Je vous propose d’étudier un panel de mauvaises pratiques de développement JavaScript… et d’étudier par la même occasion différentes manières de rectifier le tir. Tous les exemples que je vais présenter ici sont tirés de cas réels que j’ai anonymisés.  Les solutions curatives que je vais présenter sont compatibles avec un large panel de navigateurs, sans nécessiter la mise en œuvre d’outillages complexes.

On entend beaucoup parler actuellement de Typescript, et certains n’hésitent pas à affirmer que c’est le nec plus ultra pour développer du code JavaScript (JS) sûr, robuste, maintenable, etc.

L’une des caractéristiques de Typescript est de fournir un typage strict des données (« strong typing »), là où JavaScript fonctionne sur le principe d’un typage faible (« less typing »). Cette question du typage est à mon avis un faux problème, ou plutôt ce n’est qu’une toute petite partie du problème. Car c’est rarement sur des questions de typage que les applications posent le plus de problèmes. Après tout, les développeurs ne sont pas des imbéciles, et la plupart font généralement attention à ce qu’ils écrivent. C’est sur des questions relatives à la manipulation du DOM [1], et aussi à la gestion des événements, que les développeurs JS les moins aguerris se font généralement avoir. Dans cet article, je me focaliserai surtout sur les problèmes de manipulation du DOM.

Je vous propose donc un tour d’horizon de problèmes rencontrés sur le terrain. Nous verrons que pour chacun de ces problèmes, il existe des solutions. Vous êtes prêts ? Alors c’est parti...

1. Rechercher 2 fois (ou plus) un élément du DOM

Le DOM du navigateur est un drôle d’animal qui nécessite un peu de pratique pour être apprivoisé.

Le cas que je vous présente ici, c’est sans doute celui que j’ai rencontré le plus souvent dans des tutos d’initiation à JavaScript et à Jquery. C’est un cas d’école que je qualifierais de « viral » tant il a pu faire de dégâts en transmettant aux développeurs JS débutants l’une des plus mauvaises pratiques JS qui soit.

Pour comprendre de quoi il s’agit, voici un mini-formulaire HTML :

<div>

    <label>Nom:<input id="nom" type="text"/></label>

    <br>

    Bonjour <span id="view_nom"></span>

</div>

Vous remarquerez que je ne m’embarrasse pas ici avec une balise <form>, on n’en pas besoin dans ce cas précis.

Voici maintenant le code JS associé, dans une version particulièrement médiocre :

function initNom(evt) {

    var target_value = document.getElementById('nom').value ;

    document.getElementById('view_nom').innerHTML = target_value;

}

document.getElementById('nom').addEventListener('keyup', initNom, false);

Bon, mais me direz-vous, il a quoi de problématique ce code ?

Si vous observez bien ce qui se passe, vous constatez qu’on met en place un écouteur d’événement de type 'keyup' sur un élément du DOM qui a pour identifiant 'nom'. Cet écouteur d’événement fait appel à la fonction initNom(). Du coup, dès que l’utilisateur saisit un caractère dans le champ de saisie ayant pour identifiant nom, la fonction initNom() est lancée. Jusque-là pas de souci, on aurait pu d’ailleurs faire appel à une fonction anonyme, mais cela n’aurait rien changé à l’affaire.

Le problème se situe dans la fonction initNom(), car cette fonction qui est en mesure de recevoir en paramètre un objet JavaScript (que j’ai appelé ici evt), n’exploite pas du tout cet objet. C’est dommage, car cet objet contient beaucoup d’informations intéressantes, comme nous le verrons dans un instant. Au lieu d’exploiter ces infos, le développeur relance une nouvelle recherche dans le DOM pour aller récupérer la valeur du champ de saisie lié à l’élément sur lequel l’écouteur avait été placé précédemment.

En résumé, on a lancé 2 fois la même recherche dans le DOM. Or les recherches dans le DOM sont coûteuses en termes de performances... moins on en fait et mieux on se porte. Je vous disais il y a un instant que le paramètre de la fonction initNom() était en mesure de nous fournir beaucoup d’informations, en réalité, il contient toutes les informations dont nous avons besoin pour manipuler l’élément à l’origine de l’événement. Pour  le vérifier, le plus simple est de placer un appel à la fonction JS console.dir() au tout début de la fonction initNom(), comme ceci :

function initNom(evt) {

    console.dir(evt.target); // mise en place d'un mouchard sur 'evt.target'

    var target_value = document.getElementById('nom').value ;

    document.getElementById('view_nom').innerHTML = target_value;

}

document.getElementById('nom').addEventListener('keyup', initNom, false);

Vous noterez que j’ai passé à mon mouchard l’objet evt.target, car cela me permet d’obtenir le bon niveau de granularité. Vous pourrez tester la fonction sur l’objet evt directement, vous verrez beaucoup d’informations, mais aucune de véritablement utile, sauf evt.target. Parmi les informations qui vont nous intéresser dans evt.target, on trouve :

id : nom

nodeName : INPUT

nodeType : 1

parentNode : div

tagName : INPUT

type : text

value : "a"

Je vous ai mis un petit échantillon, en réalité il y a beaucoup plus d’infos que cela, mais on a là quelques informations essentielles. On voit en effet que, à partir de l’objet evt.target, on est en mesure de récupérer son identifiant, son type, et surtout la valeur saisie par l’utilisateur (qui est dans l’exemple un simple « a »).

Vous comprenez sans doute pourquoi le fait de lancer une seconde recherche dans le DOM est inutile.

Je vous propose ci-dessous une version améliorée, plus souple, car permettant de gérer plusieurs éléments au travers de la même fonction. Du coup, j’ai donné à cette fonction un nom plus générique : initView().

Voici le nouveau code HTML, avec cette fois-ci deux champs de saisie, nom et prenom :

<div>

 <label>Nom:<input id="nom" type="text"/></label>

 <br>

 <label>Prénom:<input id="prenom" type="text"/></label>

 <br>

 Bonjour <span id="view_nom"></span> <span id="view_prenom"></span>

</div>

Et voici le code JS associé :

function initView(evt) {

//console.dir(evt.target);

var target_id = evt.target.id ;

var target_value = evt.target.value ;

var view_target = document.getElementById('view_'+target_id);

if (view_target) {

        view_target.innerHTML = target_value;

} else {

        console.log('élément ayant pour id "view_'+target_id+'" non trouvé / DOM');

}

}

document.getElementById('nom').addEventListener('keyup', initView, false);

document.getElementById('prenom').addEventListener('keyup', initView, false);

Vous voyez qu’une seule fonction peut être associée à plusieurs écouteurs d’événements. On ne le fait pas dans cet exemple, mais on pourrait se servir d’infos comme evt.target.nodeName, pour adapter le fonctionnement de la fonction à différents types de balises HTML.

2. Ne pas tester la présence d’un élément dans le DOM

Dans le dernier exemple de code de la section précédente, vous avez probablement remarqué que j’ai placé un test me permettant de vérifier l’existence de l’élément recherché dans le DOM. Une mauvaise pratique aurait consisté à écrire ceci :

document.getElementById('view_'+target_id).innerHTML = target_value;

Supposez en effet que l’élément recherché n’existe pas dans le DOM, le code ci-dessus aurait déclenché un plantage, mettant votre application « par terre ». Le code ci-dessous est certes plus verbeux, mais il vous protège contre tout risque de plantage. Et le message d’erreur envoyé via la fonction console.log() permettra d’identifier plus rapidement les cas d’anomalie, s’il y en a :

...

var view_target = document.getElementById('view_'+target_id);

if (view_target) {

        view_target.innerHTML = target_value;

} else {

        console.log('élément ayant pour id "view_'+target_id+'" non trouvé / DOM');

}

...

3. Ne pas surveiller les logs

L’un de mes premiers réflexes quand j’interviens sur une application (ou un module applicatif) que je ne connais pas, c’est de tester différentes parties de l’application, et d’observer le contenu des logs JS, via la console du navigateur. C’est très instructif, et très révélateur de l’état de santé d’une application.

Une application robuste, bien écrite, ne devrait générer quasiment aucun message dans les logs. Les messages d’avertissement et d’erreur peuvent fournir aux pirates des renseignements précieux leur permettant d’identifier des failles de sécurité.

Il y a bien sûr les messages laissés par les développeurs, pour lesquels on a souvent du mal à savoir s’il s’agit d’un code lié à un oubli du développeur, ou si le message a été laissé volontairement pour alerter les développeurs sur un possible dysfonctionnement (comme dans mon test du dernier exemple).

La gestion des messages d’erreur, et notamment des messages d’erreur mis en place par les développeurs eux-mêmes, devrait en toute logique être normalisée. Or, quand je demande à certaines équipes où se trouvent les « normes et standards » du projet, ou si des règles ont été définies pour le monitoring des erreurs, je me heurte très souvent à un silence gêné, voire à un mur d’incompréhension.

Bref, passons... et jetons un coup d’œil au cas suivant, vous allez voir, il est gratiné celui-là.

4. Je cherche 2 fois dans le DOM, et j’aggrave mon cas

Allez, j’ai donné beaucoup d’explications dans les sections précédentes, alors là je vous livre le problème « brut de fonderie » :

if(document.getElementsByClassName('productFormDetails')[0] != undefined) {

                                

    document.getElementsByClassName('productFormDetails')[0]

        .addEventListener(

            'submit',

            function (evt) {

                evt.preventDefault();

               // legacy code here

            },

            false

);

}

Le développeur avait sans doute une bonne intention, mais c’est quand même dommage de faire deux fois la même recherche dans le DOM :

  • une fois pour vérifier la présence d’un élément ;
  • la seconde fois pour ajouter un écouteur d’événement sur le même élément.

Il eut été plus judicieux d’écrire quelque chose dans ce genre-là :

var checkItem = document.getElementsByClassName('productFormDetails');

if(checkItem.length > 0) {

    checkItem[0].addEventListener('submit',

       function (evt) {

           evt.preventDefault();

           // legacy code here

       },

       false

);

}

Bon, on peut toujours discuter de la pertinence fonctionnelle du schmilblik, car quand on commence à utiliser la fonction JS getElementsByClassName() c’est qu’il y a peut-être mieux à faire. Mais je ne veux pas mélanger les sujets, alors je n’insiste pas davantage sur ce cas.

Passons maintenant à un autre cas que je trouve assez rigolo.

5. Je cherche 2 fois dans le DOM… autre exemple

Je vous avoue que j’ai un peu hésité à vous présenter celui-là, mais puisqu’il s’agit d’un article sur les mauvaises pratiques, autant être exhaustif. Et puis il va me donner l’occasion d’introduire une solution correctrice très pratique. Donc dans la famille « je double les recherches dans le DOM », voici le petit-fils :

var mydata = 1000;

if (mydata > 0) {

    document.getElementById('cart').getElementsByTagName('span')[0].innerHTML =

        '(' + mydata + ')';

    document.getElementById('cart').getElementsByTagName('span')[0].className =

        document.getElementById('cart').getElementsByTagName('span')[0]

            .className.replace('outScreen', '');

}

Oui je sais, ça pique les yeux un machin comme ça.

C’est sans doute plus facile à comprendre si on l’associe à un peu de code HTML :

<div id="cart">

  <span class="outScreen inScreen">test1</span>

  <span>test2</span>

</div>

Ce code est sans doute confus, mais surtout il est très fragile, compte tenu que l’on tente d’adresser et de manipuler des éléments du DOM, sans s’assurer au préalable qu’ils sont bien présents.

Il y a forcément mieux à faire, et c’est un cas où l’API QuerySelector peut faire des merveilles :

var mydata = 1000;

if (mydata > 0) {

var tmpItemCart = `document.querySelector('#cart>span')`;

    if (tmpItemCart) {

        tmpItemCart.innerHTML = '(' + mydata + ')';

        tmpItemCart.className = tmpItemCart.className.replace('outScreen', '');

    }

}

Cette API QuerySelector est apparue avec la norme ECMAScript 5 (ES5). Si vous consultez le site caniuse.com [2], vous constaterez qu’elle est supportée par un très large panel de navigateurs (à l’exception des ancêtres de la famille Internet Explorer antérieurs à la version 11 et qui ne sont même plus mentionnés parcaniuse.com).

6. QuerySelectorAll, un formidable butineur du DOM

Puisque nous avons abordé l’API QuerySelector, poursuivons dans cette direction, en étudiant maintenant sa petite cousine, l’API QuerySelectorAll.

Un petit peu de code HTML pour démarrer l’explication :

<ul>

  <li data-product-code="x1">test1</li>

  <li>test2</li>

  <li data-product-code="x2">test3</li>

  <li>test4</li>

  <li data-product-code="x3">test5</li>

  <li>test6</li>

</ul>

Nous verrons dans un instant comment analyser ce code HTML avec l’API QuerySelectorAll, mais je vous propose de prendre un instant pour parler des attributs data-*. Car ils constituent à mes yeux, l’un des atouts majeurs du HTML5. Si vous ne connaissez pas le principe, sachez que vous pouvez créer autant d’attributs data-* que vous voulez. Par exemple : data-product-code, data-id, data-tarif, data-stock, etc.

Donc en résumé, ces attributs librement personnalisables permettent de stocker discrètement, au sein du code HTML, tout un tas de données « métier », que l’on avait tendance auparavant à planquer dans l’attribut class (parce qu’on ne savait pas où les mettre et qu’on en avait besoin malgré tout).

On peut utiliser ces attributs sur les balises <td> ou <tr> d’un tableau HTML, sur les balises <ul>, <li>, sur des balises <input>… en fait partout où on en a besoin, et ça c’est vraiment cool.

Si on a besoin de sélectionner des éléments HTML contenant un attribut data-*, la meilleure solution c’est de passer par les API querySelector (pour une sélection unitaire) et querySelectorAll (pour obtenir une liste).

Dans l’exemple suivant, on sélectionne tous les nœuds du DOM contenant l’attribut data-product-code :

document.querySelectorAll('[data-product-code]') ;

Je vais vous montrer maintenant un exemple de code qui m’a fait bondir, le jour où je suis tombé dessus :

for (var i = 0;

     i < `document.querySelectorAll('[data-product-code]')`.length ;

     i++)

{

    `document.querySelectorAll('[data-product-code]')`[i].style.color = 'red';

}

Mais que se passe-t-il dans cette boucle for ?

Si l’on tient compte du fait que notre exemple de code HTML contenait trois balises <li> dotées d’un attribut data-product-code, eh bien la boucle ci-dessus effectue 6 recherches identiques dans le DOM... donc 5 recherches inutiles.

Je vous laisse imaginer ce qu’un tel code peut donner si on l’applique sur un nombre d’éléments HTML plus important.

Le code ci-dessus aurait été un peu moins toxique si la condition d’évaluation avait été écrite différemment, comme ceci :

for (var i = 0, imax = `document.querySelectorAll('[data-product-code]')`.length;

     i < imax ;

     i++)

{

    `document.querySelectorAll('[data-product-code]')`[i].style.color = 'red';

}

… on aurait ainsi éliminé 2 recherches inutiles dans le DOM, mais ça en fait encore 3 de trop.

Mais comment un développeur a-t-il pu écrire un code pareil ? Eh bien, je suppose que le développeur concerné ne connaissait pas bien le système des nodeList. Car la méthode querySelectorAll() qui utilise l’API du même nom, produit en sortie un objet particulier qu’on appelle une NodeList (liste de nœuds). La fonction querySelectorAll() n’est pas la seule capable de produire une nodeList. Parmi les fonctions capables de produire une nodeList, on trouve :

  • document.getElementsByTagName() ;
  • document.getElementsByClassName() ;
  • document.getElementsByName().

Il y a quand même une différence subtile entre les trois méthodes document.getElementsByXXX(), et la méthode document.querySelectorAll() :

  • Les trois premières méthodes renvoient une nodeList « live », qui reflètera toujours l'état exact du document sur lequel la sélection a été effectuée. Cela signifie que si un nœud faisant partie de la sélection est modifié ou supprimé, cette modification sera visible immédiatement dans la nodeList renvoyée par les méthodes getElementsByXXX() (nul besoin de la rafraîchir). Je devrais parler de HTMLCollection pour désigner ce type de nodeList, mais je trouve ce terme moins parlant que nodeList « live » ;
  • À l'inverse, la méthode querySelectorAll() renvoie une nodeList statique, qui est un « cliché » de l'état du DOM à un instant donné. Toute modification ou suppression effectuée sur un nœud faisant partie de la sélection n'affecte en aucune manière la nodeList renvoyée par querySelectorAll().

Mais je digresse, et on n’a toujours pas corrigé le code qui pique les yeux. Voici un exemple qui permet de corriger efficacement notre boucle défaillante :

var selection = document.querySelectorAll('[data-product-code]');

for (var i = 0, len = selection.length ; i < len ; i++) {

    selection[i].style.color = 'blue';

}  

Pour ne rien vous cacher, j’adore les API querySelector et querySelectorAll, ce sont de formidables outils, qui s’appuient strictement sur les sélecteurs de la norme CSS3. On avait vu dans la section précédente que l’on pouvait écrire ceci :

document.querySelector('#cart>span');

Mais on peut aussi écrire des choses comme cela :

document.querySelector("[role='main']")

document.querySelector('input[type=range]')

  

document.querySelectorAll('span[data-type="date"]');

  

document.querySelectorAll("input[type='checkbox']:not(:checked)")

document.querySelectorAll("tr:nth-child(2n+1)")

Vous comprenez sans doute mieux maintenant pourquoi j’adore ces API.

Si vous avez envie d’approfondir le sujet, un petit tour du côté de W3Schools s’impose [3][4], ou du côté de MDN [5].

7. Notion de portée

Tous les développeurs JS ont un jour ou l’autre fait l’erreur de déclarer une variable sans utiliser le mot-clé var, comme dans l’exemple suivant :

toto = 123;

Moi aussi je l’ai faite cette erreur ! C’est facile de se faire avoir, un moment d’inattention, un petit coup de fatigue, et « boum ! », c’est la boulette.

L’incidence qu’a cette boulette ? Eh bien, la variable toto se trouve automatiquement affectée à l’objet Window, qui est l’objet de référence sur lequel repose toute votre application. Du coup, votre variable est une variable globale. Si vous souhaitiez que la variable toto ne soit  visible qu’à l’intérieur d’une fonction particulière, c’est raté. Et si différentes parties d’une application utilisent cette variable pour des usages différents, les ennuis commencent...

Heureusement pour nous, la norme ECMAScript 5 (ES5) a introduit le mode « strict ». Ce mode permet d'influer sur la manière dont JavaScript parse et exécute le code. L'objectif est de réduire au maximum les erreurs de syntaxe, et donc d'améliorer la robustesse générale du code.

Pour mettre un script en mode strict, il faut utiliser la syntaxe suivante :

"use strict";

Cela ressemble vaguement à une chaîne de caractères qui ne serait pas affectée à une variable, mais en réalité l’interpréteur JS traite cette ligne comme une commande lui permettant de basculer en mode strict.

Cette commande est valide aussi bien globalement (à l'extérieur de toute fonction) que localement (à l'intérieur des fonctions). Cependant, il est vivement recommandé de ne l'utiliser qu'à l'intérieur des fonctions, donc sur une portée locale (en anglais local scope). Car si vous utilisez du code JavaScript de diverses origines (par exemple : un framework, quelques plugins, quelques fonctions « maison », etc.), le risque est grand que certaines portions de code ne soient pas compatibles avec le mode strict. Cela pourrait entraîner des erreurs bloquant l'exécution du code JavaScript dans son ensemble.

8. Notion de modularité

Le mode strict présenté en section précédente est très bien, mais il est souhaitable d’adopter une approche modulaire et de cloisonner autant que possible les différents modules de vos applications.

Une approche que j’aime bien utiliser consiste à déclarer chaque module sous la forme d’une IIFE (Immediately Invoked Function Expression) [6]. À l’intérieur de cette IIFE, on va déclarer le « code métier » propre à chaque module, en définissant des méthodes et propriétés qui seront privées, et en déclarant en fin de parcours la liste des seules propriétés et méthodes qui peuvent vues par le monde extérieur.

Voici un exemple avec un embryon de calculette, qui ne sait faire que des additions et des soustractions :

var myCalc = (function () {

    "use strict";  

    

    // Déclaration des méthodes et propriétés privées

    var CONST_TEST = 'hello world';

    function _PrivateFunctionPlus (param1, param2){

        if (!_PrivateFunctionCheck(param1)) {

            console.log('paramètre 1 erroné');

            return false;

        }

        if (!_PrivateFunctionCheck(param2)) {

            console.log('paramètre 2 erroné');

            return false;

        }

        return param1 + param2;

    }

    function _PrivateFunctionMoins (param1, param2){

        if (!_PrivateFunctionCheck(param1)) {

            console.log('paramètre 1 erroné');

            return false;

        }

        if (!_PrivateFunctionCheck(param2)) {

            console.log('paramètre 2 erroné');

            return false;

        }

        return param1 - param2;

    }

    function _PrivateFunctionCheck(param) {

        console.log(CONST_TEST);

        if (typeof param != 'number') {

            return false ;

        } else {

            return true;

        }

    }

    // Déclaration des méthodes et propriétés publiques

    return {

        author: 'xxx',

        version: '1.0.0',

        calcPlus : _PrivateFunctionPlus,

        calcMoins : _PrivateFunctionMoins,

    };

})();

Un peu de code de test :

console.log(myCalc.calcPlus(5, 10)); // => 15

console.log(myCalc.calcMoins(5, 10)); // => -5

console.log(myCalc.calcMoins(5, 'a')); // => paramètre 2 erroné

Quelques explications s’imposent :

  • vous noterez que le nom des méthodes internes est très différent du nom des méthodes tel que le monde extérieur les voit ;
  • la méthode _PrivateFunctionCheck() n’est pas exposée à l’extérieur, elle est réservée au seul usage des deux autres méthodes qui, elles, sont publiques.

On n’est bien évidemment pas obligé de préfixer les méthodes avec un underscore, c’est une proposition que vous êtes libre de ne pas retenir. L’important c’est que vous adoptiez un style qui vous corresponde, et de le faire accepter par l’ensemble de l’équipe.

L’IIFE n’est pas une technique particulièrement intuitive, il est important de bien l’expliquer aux développeurs et de leur donner le temps de se l’approprier en les encourageant à la tester sur des cas simples et adaptés à leurs besoins. Sinon vous risquez de constater des dérives, comme par exemples du code « métier » placé dans la partie publique, selon le principe suivant :

...

    // Déclaration des méthodes et propriétés publiques

    return {

        author: 'xxx',

        version: '1.0.0',

        init: _init,

        calcPlus : function() {

                     // code métier placé au mauvais endroit

                   },

        calcMoins : function() {  

                     // encore du code métier placé au mauvais endroit

                   }

    };

...

Résultat des courses, la partie « privée » du composant ne sert plus à grand-chose, et le code métier n’est pas protégé contre des modifications accidentelles, voire malveillantes.

Conclusion

Dans certaines sociétés où la pénurie de compétences JavaScript se fait cruellement sentir, des développeurs back-end (PHP ou Java) sont amenés à travailler sur des développements front-end, sans avoir le niveau de formation adéquat.

Une mauvaise compréhension des mécanismes du DOM, associée souvent à une mauvaise compréhension de la gestion des événements au sein du navigateur et cela donne un cocktail explosif, avec des applications fragiles et difficilement maintenables.

Heureusement, nous avons vu que la plupart des problèmes peuvent être résolus assez simplement, dès lors que l’on sait les identifier.  Si vous êtes confrontés à quelques-uns des problèmes que j’ai listés dans cet article, les solutions que j’ai proposées – et que j’ai moi-même pratiquées à plusieurs reprises - devraient vous aider à remettre de l’huile dans les rouages.

Références

[1] DOM : https://fr.wikipedia.org/wiki/Document_Object_Model

[2] Site caniuse.com pour querySelector : https://caniuse.com/#search=queryselector

[3] querySelector() sur W3Schools : https://www.w3schools.com/jsref/met_document_queryselector.asp

[4] querySelectorAll() sur W3Schools : https://www.w3schools.com/jsref/met_document_queryselectorall.asp

[5] querySelector() sur MDN : https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector

[6] IIFE : https://en.wikipedia.org/wiki/Immediately-invoked_function_expression