Niveau Niveau confirmé

JavaScript : organiser son code en modules

Articlejavascript

Publié par le , mis à jour le (91323 lectures)

javascript module espace de nommage

Cet article vous propose d'étudier différentes techniques permettant d'isoler votre code dans des modules « hermétiques », évitant ainsi les interactions involontaires avec le reste de votre code, ou avec le code que vous ne contrôlez pas.

JavaScript a été initialement conçu pour être un langage facile à prendre en main.

Néanmoins, avec l'augmentation importante des volumes de code utilisés sur Internet, on se heurte désormais comme dans la plupart des langages de programmation aux problèmes inhérents à la cohabitation de plusieurs bibliothèques.

L'objectif de cet article est de présenter quelques techniques permettant de rendre vos bibliothèques plus faciles à maintenir, plus lisibles et mieux structurées en les organisant sous forme de modules, minimisant ainsi les risques d'interaction involontaire avec l'extérieur.

Enoncé du problème

Voici tout d'abord un exemple assez simple, qui sera le fil conducteur de cet article :

function ajouterClasse(element, classe) {
  if (element.className) {
    element.className += " ";
  }

  element.className += classe;
}

function init() {
  ajouterClasse(document.body, nouvelleClasse);
}

var nouvelleClasse = "jsActif";

if (document.getElementById && document.createTextNode) {
  window.onload = init;
}

Cette bibliothèque ajoute automatiquement la classe jsActif à l'élément body lors du chargement de la page.

Le test (document.getElementById && document.createTextNode) permet de limiter l'exécution du code aux navigateurs modernes, qui supportent le DOM. Cela garantit un socle commun de fonctionnalités sur lequel nous pouvons nous appuyer par la suite.

Quant au test (element.className) dans la fonction ajouterClasse, il empêche l'ajout d'un espace au début de l'attribut class si celui-ci est vide (une fonction complète testerait aussi le cas où la classe à ajouter est déjà présente).

Cette bibliothèque est à première vue plutôt bien écrite :

  • Elle a un comportement propre sur les navigateurs obsolètes (en clair, elle ne fait rien) : on a mis en place une « dégradation gracieuse ».
  • Elle ne nécessite aucun ajout de code HTML : on a séparé le comportement et la structure.
  • Elle ne modifie pas directement les styles mais utilise une classe (que l'on pourra cibler avec les CSS) : on a séparé le comportement et la présentation.

Cependant, elle présente des défauts, que nous allons maintenant étudier.

Collisions de noms

Le premier problème est lié à l'utilisation systématique de variables (et fonctions) globales.

Imaginons qu'une page utilise cette bibliothèque, ainsi qu'une deuxième qui contienne elle aussi une fonction ajouterClasse. La seconde définition écrasera alors la première, ce qui aboutira sans doute à une erreur d'exécution que l'on peut difficilement anticiper et comprendre.

Le problème est le même pour les variables : si nouvelleClasse est définie dans une seconde bibliothèque, ce sera alors cette valeur qui sera utilisée comme paramètre de la fonction ajouterClasse.

La première solution qui vient à l'esprit est l'utilisation de noms plus complexes, éventuellement préfixés ou suffixés par le nom de la bibliothèque (bibliJsActif_ajouterClasse, ...).

Cependant, cette méthode a pour inconvénients d'être lourde, d'avoir un effet négatif sur la lisibilité du code, et de ne pas totalement résoudre le problème.

Ecrasement des événements

Dans une optique de séparation du comportement et de la structure, on a externalisé le gestionnaire d'événements onload dans le code JavaScript.

Cependant, cela pose un problème dans le cas de l'utilisation de plusieurs bibliothèques : quand on écrit « window.onload = ...; », on s'approprie le gestionnaire d'événements, au détriment de ceux qui l'avaient fait précédemment.

Dans le cadre de notre exemple, cela signifie que la fonction init ne s'exécutera jamais si onload a été écrasé par une autre bibliothèque.

Comment éviter les collisions de noms ?

JavaScript ne propose pas nativement de fonctionnalité d'espace de nommage, comme en C++, C# (namespace) ou Java (package).

Pour éviter les collisions de noms, il faut donc profiter de la souplesse de ce langage pour construire une solution ad hoc.

Pour information, les techniques que nous proposons ci-dessous s'appuient sur le patron de conception « singleton », qui permet de se contenter d'une variable globale, qui contiendra elle-même les membres de notre bibliothèque.

Un littéral objet

La solution la plus simple est d'utiliser un littéral objet, dont les membres seront les anciennes variables et fonctions globales de notre bibliothèque :

var bibliJsActif = {
  "ajouterClasse": function(element, classe) {
    if (element.className) {
      element.className += " ";
    }

    element.className += classe;
  },

  "init": function() {
    bibliJsActif.ajouterClasse(document.body, bibliJsActif.nouvelleClasse);
  },

  "nouvelleClasse": "jsActif"
};

if (document.getElementById && document.createTextNode) {
  window.onload = bibliJsActif.init;
}

Chaque couple séparé par le caractère « : » définit un membre de notre bibliothèque. Quand il s'agit d'une fonction, il faut utiliser la syntaxe des fonctions anonymes (function(...) {...}).

Pour accéder à ces membres, il suffit alors pour l'utilisateur d'indiquer que ce sont des propriétés de bibliJsActif (par exemple : bibliJsActif.nouvelleClasse).

Une fonction anonyme

Une seconde solution consiste à utiliser le fait que JavaScript permette de définir des fonctions à l'intérieur d'une autre fonction.

On peut alors créer une nouvelle portée pour notre bibliothèque en l'encapsulant dans une fonction anonyme que l'on exécute immédiatement. Les variables et fonctions ne sont dans ce cas-là plus globales mais réduites à la portée de la fonction (à condition bien sûr que les variables aient été déclarées avec le mot-clé var) :

(function() {
  function ajouterClasse(element, classe) {
    if (element.className) {
      element.className += " ";
    }

    element.className += classe;
  }

  function init() {
    ajouterClasse(document.body, nouvelleClasse);
  }

  var nouvelleClasse = "jsActif";

  if (document.getElementById && document.createTextNode) {
    window.onload = init;
  }
})();

Le résultat obtenu est différent de celui de la solution précédente puisque l'on n'a ici aucune variable globale. Notre bibliothèque est donc totalement inaccessible depuis l'extérieur, et l'on a réduit les risques de collisions de noms à néant.

Clarifions rapidement la syntaxe utilisée ici :

  • « function() {...} » permet de définir une fonction anonyme.
  • Puisque l'on souhaite que cette fonction soit exécutée immédiatement, on ajoute « () » à la fin : « function() {...}(); ».
  • La grammaire de JavaScript ne permet cependant pas cette écriture, il faut donc encadrer la fonction anonyme avec des parenthèses : « (function() {...})(); ».

Combinaison des deux premières solutions

La solution précédente permet une isolation totale du code, mais ce n'est pas souhaitable dans le cas où l'on souhaite exposer des fonctions et/ou des variables à l'extérieur, c'est-à-dire en général pour une bibliothèque de fonctions utilitaires ou pour une bibliothèque que l'utilisateur va pouvoir paramétrer.

Il est possible d'y remédier en modifiant la fonction anonyme pour qu'elle retourne un littéral objet similaire à celui de la première solution, mais qui ne contient pas forcément l'ensemble des membres de notre bibliothèque.

L'utilisation de cette méthode permet donc de simuler une véritable encapsulation, puisque notre bibliothèque a une partie privée et une partie publique :

var bibliJsActif = (function() {
  // Membres privés
  function init() {
    bibliJsActif.ajouterClasse(document.body, bibliJsActif.nouvelleClasse);
  }

  if (document.getElementById && document.createTextNode) {
    window.onload = init;
  }

  // Membres publics
  return {
    "ajouterClasse": function(element, classe) {
      if (element.className) {
        element.className += " ";
      }

      element.className += classe;
    },

    "nouvelleClasse": "jsActif"
  };
})();

Dans cet exemple, on a choisi d'inclure dans l'interface (c'est-à-dire la partie publique) la fonction ajouterClasse et la variable nouvelleClasse.

Pour accéder aux membres de l'interface de l'intérieur comme de l'extérieur de notre module, il faut utiliser la même syntaxe que pour la première solution : bibliJsActif.nouvelleClasse par exemple.

Bien que cet exemple ne l'illustre pas, la partie privée de notre bibliothèque est accessible depuis l'interface. Imaginons que l'on désire que seule init soit publique :

var bibliJsActif = (function() {
  // Membres privés
  function ajouterClasse(element, classe) {
    if (element.className) {
      element.className += " ";
    }

    element.className += classe;
  }

  var nouvelleClasse = "jsActif";

  // Membres publics
  return {
    "init": function() {
      ajouterClasse(document.body, nouvelleClasse);
    }
  };
})();

if (document.getElementById && document.createTextNode) {
  window.onload = bibliJsActif.init;
}

Comment éviter l'écrasement des événements ?

Notre exemple a montré qu'il ne suffit pas de se débarrasser des variables globales pour empêcher les interactions involontaires entre bibliothèques.

Nous allons donc étudier maintenant des méthodes nous permettant de faire en sorte que notre bibliothèque ne soit plus vulnérable à l'écrasement des événements.

Si vous désirez en savoir plus sur la gestion des événements, vous pouvez consulter l'article d'Alsacréations à ce sujet.

Les gestionnaires d'événements DOM-0

Comme nous l'avons vu précédemment, l'utilisation des gestionnaires d'événements classiques, ou DOM-0 (c'est-à-dire ceux qui passent par l'ajout d'une propriété à l'élement à observer : onload, onclick, ...), est problématique si l'on souhaite faire cohabiter plusieurs bibliothèques.

Etant donné que ces gestionnaires d'événements sont faciles à utiliser et implémentés de façon très consistante sur les différents navigateurs, de nombreuses solutions ont été imaginées pour résoudre ce problème tout en continuant à les utiliser.

La plus connue est sans doute celle de Simon Willison :

function addLoadEvent(func) {
  var oldonload = window.onload;
  if (typeof window.onload != "function") {
    window.onload = func;
  } else {
    window.onload = function() {
      if (oldonload) {
        oldonload();
      }
      func();
    };
  }
}

En utilisant cette fonction, on peut ajouter notre gestionnaire d'événements sans écraser ceux qui étaient déjà présents.

Cependant, cette solution, ainsi que toutes celles qui utilisent les gestionnaires d'événements DOM-0, n'est pas satisfaisante puisqu'elle suppose que toutes les bibliothèques auront assez de « civisme » pour ne pas écraser les autres gestionnaires d'événements (en utilisant le même genre de solutions), ce qui est plutôt optimiste.

Les gestionnaires d'événements DOM-2

Heureusement, le W3C a mis à notre disposition avec DOM-2 la méthode addEventListener, que les spécifications décrivent ainsi :

void addEventListener(
  in DOMString type, 
  in EventListener listener, 
  in boolean useCapture
);
  • type étant le type d'événement ("load", "click", ...),
  • listener la fonction à exécuter au déclenchement de l'événement,
  • et useCapture un booléen indiquant si l'on souhaite utiliser le mode capturant ou non.

Cette fonction permet d'associer plusieurs gestionnaires d'événéments du même type au même élément.

Malheureusement, elle n'est pas supportée par Internet Explorer, pas même dans sa version 7. On dispose d'une alternative propriétaire pour ce navigateur : attachEvent.

Ses deux paramètres sont les mêmes que les deux premiers de la méthode addEventListener, sauf que le type d'événement doit être préfixé par "on".

Quant au troisième paramètre, il n'est pas présent car Internet Explorer ne supporte pas le mode capturant. Il vaut donc mieux éviter d'utiliser ce mode si l'on souhaite écrire du code compatible avec ce navigateur (ce qui implique de systématiquement passer false comme paramètre useCapture pour addEventListener).

Pour ajouter un événement qui se déclenche au chargement de la page, il va donc falloir utiliser l'une ou l'autre de ces deux fonctions selon le navigateur auquel on a affaire :

function addLoadListener(func) {
  if (window.addEventListener) {
    window.addEventListener("load", func, false);
  } else if (document.addEventListener) {
    document.addEventListener("load", func, false);
  } else if (window.attachEvent) {
    window.attachEvent("onload", func);
  }
}

Le fait de tester l'existence de addEventListener sur window puis sur document permet de s'assurer que cela fonctionnera avec toutes les versions modernes d'Opera et de Mozilla.

Le seul navigateur relativement moderne qui ne supporte aucune de ces deux fonctions est Internet Explorer Mac, qui n'est aujourd'hui quasiment plus utilisé, et dont le support du DOM est connu pour être peu fiable.

Si vous souhaitez supporter ce navigateur, il vous faudra ajouter un bloc de code à votre fonction, en utilisant par exemple la solution de Simon Willison :

function addLoadListener(func) {
  if (window.addEventListener) {
    window.addEventListener("load", func, false);
  } else if (document.addEventListener) {
    document.addEventListener("load", func, false);
  } else if (window.attachEvent) {
    window.attachEvent("onload", func);
  } else if (typeof window.onload != "function") {
    window.onload = func;
  } else {
    var oldonload = window.onload;
    window.onload = function() {
      oldonload();
      func();
    };
  }
}

Il est important de conserver l'ordre des tests dans ces fonctions. On peut en effet espérer que, dans un futur plus ou moins proche, tous les navigateurs implémenteront la méthode addEventListener. On pourra alors sans crainte supprimer les tests suivants, ce qui aura pour seul effet d'empêcher l'exécution de notre script sur les navigateurs devenus obsolètes (à condition bien sûr d'avoir mis en place une « dégradation gracieuse »).

Nous ne traiterons pas ici les autres types d'événements que load, qui peuvent bien entendu poser eux aussi des problèmes d'écrasements.

Il existe également des méthodes permettant de déclencher un événement lorsque l'arbre DOM a fini de se charger (c'est-à-dire avant le chargement des images, entre autres), comme celle de Dean Edwards.

Un patron de module JavaScript

Nous avons étudié dans les deux parties précédentes comment éviter les collisions de noms puis l'écrasement des événements.

Récapitulons ce à quoi nous avons abouti en réécrivant notre exemple pour proposer un patron de module JavaScript. Nous n'avons pas besoin de mettre à disposition des variables ou des fonctions, c'est pourquoi nous utiliserons la solution de la fonction anonyme :

(function() {
  function ajouterClasse(element, classe) {
    if (element.className) {
      element.className += " ";
    }

    element.className += classe;
  }

  function init() {
    ajouterClasse(document.body, nouvelleClasse);
  }

  function addLoadListener(func) {
    if (window.addEventListener) {
      window.addEventListener("load", func, false);
    } else if (document.addEventListener) {
      document.addEventListener("load", func, false);
    } else if (window.attachEvent) {
      window.attachEvent("onload", func);
    }
  }

  var nouvelleClasse = "jsActif";

  if (document.getElementById && document.createTextNode) {
    addLoadListener(init);
  }
})();

Conclusion

Les méthodes présentées dans cet article demandent un surplus de code, qui peut être non négligeable pour de petites bibliothèques. Si vous utilisez peu de JavaScript, et que vous n'éprouvez pas le besoin de faire cohabiter plusieurs bibliothèques, ce n'est sans doute pas nécessaire.

Néanmoins, si votre bibliothèque commence à atteindre un volume important, ou si vous comptez la redistribuer, le fait de respecter ces règles permet d'en assurer la stabilité et la pérennité vis-à-vis de l'interaction avec les autres bibliothèques.

Il est à noter que l'on peut bien évidemment définir plusieurs modules dans un seul fichier pour compartimenter son code.