Tutoriel Vidéo JavaScript Les modules

Télécharger la vidéo

Dans cette vidéo je vous propose de découvrir et comprendre les différents systèmes de module en JavaScript.

Pourquoi on a besoin d'un système de modules ?

Lorsque l'on a commencé à écrire du JavaScript un simple fichier suffisait, mais on en a très rapidement eu besoin d'éviter les répétitions. L'approche la plus simple pour avoir du code réutilisable consiste à injecter une variable dans le namespace global (window dans le cas du navigateur).

var variable1 = 4

function maSuperFonction () {
  // Le code de la fonction
}

window.maSuperFonction = maSuperFonction

Afin d'éviter que le code de notre librairie déborde sur le code du reste de l'application on utilisera une IIFE (Immediately invoked function expression). L'utilisation d'une fonction permet de limiter la portée des variables (surtout à l'époque où seul le mot clef var existait).

;(function () {
  var variable1 = 4

  function maSuperFonction () {
    // Le code de la fonction
  }

  window.maSuperFonction = maSuperFonction
})()

Cette IIFE permet de s'assurer que les variables ne débordent pas, mais on peut aussi l'utiliser avec des paramètres pour définir les "dépendances" de notre code.

;(function (maFunc) {
  console.log('Voila le résultat : ' + maFunc())
})(window.maSuperFonction)

Cette approche était très utilisée avec jQuery par exemple.

;(function ($) {
  $('.demo').click(function () {
    $(this).slideToggle()
  })
})(jQuery)

Les limites de cette approche

Malheureusement cette approche n'est pas extensible et commence à poser des problèmes lorsque l'on a plusieurs dépendances (les fichiers doivent être inclus dans un ordre précis). Il a donc fallu créer un système capable de résoudre les dépendances et d'organiser l'ordre d'éxécution automatiquement.

Les solutions

CommonJS

L'objectif de CommonJS était de trouver un format de définition qui fonctionne avec le langage JavaScript (sans forcément se contraindre aux limitations des navigateurs). Un module s'écrit de manière classique (sans IIFE) et recevra un objet module qui contiendra une propriété exports qui permettra d'exporter ce que l'on souhaite.

let count = 0
let step = 1

function increment () {
  count += step
  return count
}

function decrement () {
  count -= step
  return count
}

module.exports = {
  increment,
  decrement
}

Il est ensuite possible d'utiliser ce module à l'aide de la fonction require() à laquelle on passera le chemin de notre module.

const incr = require('./incrementer.js')

const count = document.querySelector('#count')

document.querySelector('#increment').addEventListener('click', function () {
  count.innerHTML = incr.increment()
})

Le système se chargera alors d'inclure automatiquement le fichier et de renvoyer dans le retour ce qui a été exporté. Si il y a des sous-dépendances elles seront automatiquement résolue.

Cette approche a été adoptée par NodeJS et fonctionne très bien côté serveur. En revanche, il n'est pas possible de l'utiliser directement côté navigateur et il faudra un outil pour convertir le code CommonJS en code compatible avec les navigateurs. Un exemple simple d'implémentation consiste à créer un objet pour représenter les différents modules et d'englober les différents modules dans une fonction.

modules['./app.js'] = function (require, module) {
  // Le code de app.js
  const incr = require('./incrementer.js')
  const count = document.querySelector('#count')

  document.querySelector('#increment').addEventListener('click', function () {
    count.innerHTML = incr.increment()
  })
}

Cette transformation peut se faire au travers de différents outils comme Webpack ou ParcelJS.

AMD

La direction prise par CommonJS ne satisfaisait pas tout le monde et une groupe de personne a décidé de développer un autre système de définition de module, qui fonctionnerait directement sur les navigateurs et qui supporterait le chargement asynchrone.

define(['jquery'], function ($) {
  return function (selector) {
    $(selector).click(function () {
      $(this)
        .next()
        .slideToggle()
    })
  }
})

Un module peut être nommé ou représenter le chemin du fichier JavaScript.

define(['./cart', './inventory'], function (cart, inventory) {
  return {
    color: 'blue',
    size: 'large',
    addToCart: function () {
      inventory.decrement(this)
      cart.add(this)
    }
  }
})

L'avantage de cette approche est qu'elle peut être utilisée directement sur les navigateur en définissant cette fonction define (vous pouvez trouver plus d'information sur les raisons derrière ce système de module sur la page de RequireJS).

UMD

Maintenant on se retrouve donc avec plusieurs approches pour définir un module et publier une librairie dans ces conditions était devenu problématique.

C'est pour remédier à ce problème qu'UMD (Universal Module Definition) est né. Il permet de définir un module qui fonctionnera avec les 3 systèmes (CommonJS, AMD et window)

;(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined'
    ? factory(exports, require('react'))
    : typeof define === 'function' && define.amd
    ? define(['exports', 'react'], factory)
    : ((global = global || self), factory((global.ReactDOM = {}), global.React))
})(this, function (exports, React) {
  //  Du code
  exports.createPortal = createPortal
  exports.findDOMNode = findDOMNode
  exports.render = render
  exports.version = ReactVersion
})

Pour arriver à ces fins UMD va ajouter une série de conditions pour découvrir quel type de module est utilisé et injectera un paramètre exports à la fonction qui permettra d'exporter ce qui doit être accessible.

ES205, un standard (ESM)

Les solutions vu précédemments sont des paliatifs à l'absence de système de module natif en JavaScript. Cependant avec l'évolution des standards le JavaScript s'est vu doté d'un tel système lors de l'arrivé de l'EcmaScript 2015.
Un script peut être chargé dans une page HTML comme un module.

<script src="app.js" type="module">

A l'intérieur de ce fichier il est ensuite possible d'utiliser les mots clef import et export.

let count = 0
let step = 1

export function increment () {
  count += step
  return count
}

export function decrement () {
  count -= step
  return count
}

Vous pouvez en lire plus sur les différents formats d'export sur la documentation MDN.

Il est ensuite possible d'importer un ou plusieurs éléments à l'aide du mot clef import.

import { increment } from './incrementer.js'

const count = document.querySelector('#count')

document.querySelector('#increment').addEventListener('click', function () {
  count.innerHTML = increment()
})

Lorsque le navigateur chargera ce script il parsera le fichier pour découvrir les dépendances (ici ìncrementer.js). Il les chargera et les parsera pour découvrir des sous-dépendances si nécessaire (et ainsi de suite). Ce n'est qu'une fois la totalité de l'arbre de dépendance découverte que le code pourra être éxécuté.

Malheureusement, même si les navigateurs modernes supportent cette syntaxe cette cascade de résolution peut poser des problème de performances (surtout si il y a beaucoup de fichier). Aussi, on aura tendance à utiliser des bundlers pour résoudre les dépendances et générer un fichier plus simple à charger pour le navigateur. Ces outils ont aussi l'avantage d'être capable de faire du tree-shaking pour n'importer que le code dont on a besoin. Par exemple :

import { increment } from './incrementer.js'

// Ce code sera transformé comme ceci
let count = 0
let step = 1

function increment () {
  count += step
  return count
}

La fonction decrement sera automatiquement supprimée car elle n'est pas utilisé dans le code final (le tree shaking n'est pas possible avec CommonJS).

ES2020, import dynamique

Une nouvelle évolution du système de module natif pointe le bout de son nez et va permettre l'import asynchrone et dynamique.

const count = document.querySelector('#count')

document
  .querySelector('#increment')
  .addEventListener('click', async function () {
    count.innerHTML = await import('./incrementer.js').then(
      ({ default: incr }) => {
        return incr.increment()
      }
    )
  })

Ce système est intéréssant car il va permettre de charger certaines parties de notre script dans un second temps (très pratique pour des modules imposants qui ne sont présent seulement sur certaines pages).

Quel syntaxe dois-je utiliser ?

Avec tous ces systèmes de modules on peut être perdu et même si il est intéréssant de connaitre ces différentes approches, on n'aura tendance à écire des modules en utilisant le format ES2015 / ES2020.

Côté serveur les choses sont un peu plus complexe à cause notamment de NodeJS qui propose un support encore approximatif des modules EcmaScript (ESM)

As of Node.js 14 there is no longer warning when using ESM in Node.js. However, the ESM implementation in Node.js remains experimental. [...] Users should be cautious when using the feature in production environments.

Du coup une grande partie de l'écosystème (npm) utilise, et continue d'utiliser la syntaxe CommonJS. Les bundler vont alors jouer le rôle de passerelle et permettre l'inclusion de module CommonJS depuis une syntaxe ESM.

import React from 'react'
import ReactDOM from 'react-dom'

ReactDom
  .render
  // ...
  ()

Dans ce cas là les module.exports seront interprétés comme un export default.

Maintenant il n'y a plus qu'à attendre que l'écosystème s'adapte maintenant que les modules existent dans le langage pour ne plus avoir à se poser autant de question.