Comment CommonJS rend vos bundles plus volumineux

Découvrez l'impact des modules CommonJS sur le tree-shaking de votre application.

Dans cet article, nous allons découvrir ce qu'est CommonJS et pourquoi il rend vos bundles JavaScript plus volumineux que nécessaire.

Résumé: Pour que le bundleur puisse optimiser votre application, évitez de dépendre de modules CommonJS et utilisez la syntaxe de module ECMAScript dans l'ensemble de votre application.

Qu'est-ce que CommonJS ?

CommonJS est une norme de 2009 qui a établi des conventions pour les modules JavaScript. Il était initialement destiné à être utilisé en dehors du navigateur Web, principalement pour les applications côté serveur.

Avec CommonJS, vous pouvez définir des modules, en exporter des fonctionnalités et les importer dans d'autres modules. Par exemple, l'extrait de code ci-dessous définit un module qui exporte cinq fonctions: add, subtract, multiply, divide et max:

// utils.js
const { maxBy } = require('lodash-es');
const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Plus tard, un autre module peut importer et utiliser certaines ou toutes ces fonctions:

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

L'appel de index.js avec node génère le nombre 3 dans la console.

En raison de l'absence de système de modules standardisé dans le navigateur au début des années 2010, CommonJS est également devenu un format de module populaire pour les bibliothèques côté client JavaScript.

Quel est l'impact de CommonJS sur la taille finale du bundle ?

La taille de votre application JavaScript côté serveur n'est pas aussi critique que dans le navigateur. C'est pourquoi CommonJS n'a pas été conçu dans le but de réduire la taille du bundle de production. En parallèle, une analyse montre que la taille du groupe JavaScript reste la première raison de la lenteur des applications de navigateur.

Les outils de regroupement et de minification JavaScript, tels que webpack et terser, effectuent différentes optimisations pour réduire la taille de votre application. En analysant votre application au moment de la compilation, ils tentent de supprimer autant que possible le code source que vous n'utilisez pas.

Par exemple, dans l'extrait de code ci-dessus, votre bundle final ne doit inclure que la fonction add, car il s'agit du seul symbole de utils.js que vous importez dans index.js.

Créons l'application à l'aide de la configuration webpack suivante:

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

Ici, nous spécifions que nous souhaitons utiliser les optimisations du mode de production et utiliser index.js comme point d'entrée. Après avoir appelé webpack, si nous examinons la taille de l'sortie, nous obtenons quelque chose comme ceci:

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

Notez que le groupe est de 625 ko. Si nous examinons la sortie, nous trouverons toutes les fonctions de utils.js, ainsi que de nombreux modules de lodash. Bien que nous n'utilisions pas lodash dans index.js, il fait partie de la sortie, ce qui ajoute beaucoup de poids à nos éléments de production.

Modifions maintenant le format de module sur Modules ECMAScript, puis réessayons. Cette fois, utils.js se présente comme suit:

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

import { maxBy } from 'lodash-es';

export const max = arr => maxBy(arr);

index.js importe depuis utils.js à l'aide de la syntaxe de module ECMAScript:

import { add } from './utils.js';

console.log(add(1, 2));

À l'aide de la même configuration webpack, nous pouvons compiler notre application et ouvrir le fichier de sortie. Il est maintenant de 40 octets, avec le résultat suivant:

(()=>{"use strict";console.log(1+2)})();

Notez que le bundle final ne contient aucune des fonctions de utils.js que nous n'utilisons pas, et qu'il n'y a aucune trace de lodash. De plus, terser (le minificateur JavaScript utilisé par webpack) a intégré la fonction add dans console.log.

Vous vous demandez peut-être pourquoi l'utilisation de CommonJS fait que le bundle de sortie est presque 16 000 fois plus volumineux. Bien sûr, il s'agit d'un exemple fictif. En réalité, la différence de taille peut ne pas être si importante, mais il est probable que CommonJS ajoute un poids important à votre build de production.

En règle générale, les modules CommonJS sont plus difficiles à optimiser, car ils sont beaucoup plus dynamiques que les modules ES. Pour vous assurer que votre bundler et votre outil de minification peuvent optimiser votre application, évitez de dépendre de modules CommonJS et utilisez la syntaxe de module ECMAScript dans l'ensemble de votre application.

Notez que même si vous utilisez des modules ECMAScript dans index.js, si le module que vous consommez est un module CommonJS, la taille du bundle de votre application en sera affectée.

Pourquoi CommonJS augmente-t-il la taille de votre application ?

Pour répondre à cette question, nous allons examiner le comportement de ModuleConcatenationPlugin dans webpack, puis nous allons discuter de l'analysabilité statique. Ce plug-in concatène le champ d'application de tous vos modules en une seule fermeture et permet à votre code d'avoir un temps d'exécution plus rapide dans le navigateur. Voyons un exemple :

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

console.log(add(1, 2));

Ci-dessus, nous avons un module ECMAScript, que nous importons dans index.js. Nous définissons également une fonction subtract. Nous pouvons compiler le projet à l'aide de la même configuration webpack que ci-dessus, mais cette fois, nous allons désactiver la minimisation:

const path = require('path');

module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    minimize: false
  },
  mode: 'production',
};

Examinons la sortie générée:

/******/ (() => { // webpackBootstrap
/******/    "use strict";

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

/******/ })();

Dans la sortie ci-dessus, toutes les fonctions se trouvent dans le même espace de noms. Pour éviter les collisions, webpack a renommé la fonction subtract de index.js en index_subtract.

Si un outil de minification traite le code source ci-dessus, il:

  • Suppression des fonctions inutilisées subtract et index_subtract
  • Supprimez tous les commentaires et les espaces blancs redondants.
  • Insérer le corps de la fonction add dans l'appel console.log

Les développeurs appellent souvent cette suppression des importations inutilisées "tree-shaking". Le tree-shaking n'a été possible que parce que webpack a pu comprendre de manière statique (au moment de la compilation) les symboles que nous importons à partir de utils.js et les symboles qu'il exporte.

Ce comportement est activé par défaut pour les modules ES, car ils sont plus analysables de manière statique que CommonJS.

Examinons exactement le même exemple, mais cette fois, modifiez utils.js pour qu'il utilise CommonJS au lieu des modules ES:

// utils.js
const { maxBy } = require('lodash-es');

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: arr => maxBy(arr)
};

Object.keys(fns).forEach(fnName => module.exports[fnName] = fns[fnName]);

Cette petite modification modifiera considérablement la sortie. Comme il est trop long pour être intégré à cette page, je n'en ai partagé qu'une petite partie:

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

Notez que le bundle final contient du code injecté webpack "d'exécution" qui est responsable de l'importation/l'exportation des fonctionnalités à partir des modules groupés. Cette fois, au lieu de placer tous les symboles de utils.js et index.js sous le même espace de noms, nous exigeons de manière dynamique, au moment de l'exécution, la fonction add à l'aide de __webpack_require__.

Cela est nécessaire, car avec CommonJS, nous pouvons obtenir le nom d'exportation à partir d'une expression arbitraire. Par exemple, le code ci-dessous est une construction absolument valide:

module.exports[localStorage.getItem(Math.random())] = () => {  };

Le bundleur ne peut pas connaître le nom du symbole exporté au moment de la compilation, car cela nécessite des informations qui ne sont disponibles qu'au moment de l'exécution, dans le contexte du navigateur de l'utilisateur.

De cette manière, le minificateur ne peut pas comprendre exactement ce que index.js utilise à partir de ses dépendances. Il ne peut donc pas l'éliminer par tree-shaking. Nous observerons exactement le même comportement pour les modules tiers. Si nous importons un module CommonJS à partir de node_modules, votre chaîne d'outils de compilation ne pourra pas l'optimiser correctement.

Tree-shaking avec CommonJS

Il est beaucoup plus difficile d'analyser les modules CommonJS, car ils sont dynamiques par définition. Par exemple, l'emplacement d'importation dans les modules ES est toujours une chaîne littérale, contrairement à CommonJS, où il s'agit d'une expression.

Dans certains cas, si la bibliothèque que vous utilisez suit des conventions spécifiques sur la façon dont elle utilise CommonJS, vous pouvez supprimer les exportations inutilisées au moment de la compilation à l'aide d'un plugin webpack tiers. Bien que ce plug-in prenne en charge le tree-shaking, il ne couvre pas toutes les façons dont vos dépendances peuvent utiliser CommonJS. Cela signifie que vous n'obtenez pas les mêmes garanties qu'avec les modules ES. De plus, cela ajoute un coût supplémentaire au processus de compilation en plus du comportement par défaut de webpack.

Conclusion

Pour que le bundleur puisse optimiser votre application, évitez de dépendre de modules CommonJS et utilisez la syntaxe de module ECMAScript dans l'ensemble de votre application.

Voici quelques conseils pratiques pour vous assurer que vous êtes sur la bonne voie:

  • Utilisez le plug-in node-resolve de Rollup.js et définissez l'indicateur modulesOnly pour spécifier que vous ne souhaitez dépendre que des modules ECMAScript.
  • Utilisez le package is-esm pour vérifier qu'un package npm utilise des modules ECMAScript.
  • Si vous utilisez Angular, vous recevrez par défaut un avertissement si vous dépendez de modules non éligibles au tree-shaking.