Comment CommonJS améliore-t-il vos groupes ?

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

Dans ce message, nous allons vous expliquer ce qu'est CommonJS et pourquoi il rend vos groupes JavaScript plus volumineux que nécessaire.

Résumé: Pour vous assurer que le bundler peut 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 ?

La norme CommonJS de 2009 définit des conventions pour les modules JavaScript. Il était à l'origine 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 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]);

Par la suite, un autre module pourra importer et utiliser tout ou partie de ces fonctions:

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

L'appel de index.js avec node affiche le numéro 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 devenu un format de module populaire pour les bibliothèques JavaScript côté client.

Quel est l'impact de CommonJS sur la taille finale de votre groupe ?

La taille de votre application JavaScript côté serveur n'est pas aussi importante que dans le navigateur. C'est pourquoi CommonJS n'a pas été conçu pour réduire la taille du bundle en production. Parallèlement, une analyse montre que la taille du bundle JavaScript reste la principale raison pour laquelle les applications de navigateur ralentissent.

Les outils de réduction et de bundle 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 essaient d'en supprimer autant que possible du code source que vous n'utilisez pas.

Par exemple, dans l'extrait de code ci-dessus, le 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 voulons utiliser les optimisations en mode production et utiliser index.js comme point d'entrée. Après avoir appelé webpack, si nous explorons la taille de la sortie, nous avons ce qui suit:

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

Notez que la taille du bundle est de 625 Ko. En examinant le résultat, 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, cela fait partie de la sortie, ce qui ajoute beaucoup de poids à nos éléments de production.

À présent, remplaçons le format du module par Modules ECMAScript, puis réessayez. Cette fois, utils.js ressemblerait à ceci:

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);

Enfin, index.js serait importé depuis utils.js à l'aide de la syntaxe du module ECMAScript:

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

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

En utilisant la même configuration webpack, nous pouvons créer notre application et ouvrir le fichier de sortie. Elle fait maintenant 40 octets avec la sortie suivante:

(()=>{"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 réducteur JavaScript utilisé par webpack) a intégré la fonction add dans console.log.

Vous vous demandez peut-être pourquoi l'utilisation de CommonJS génère-t-elle un bundle de sortie presque 16 000 fois plus grand ? Bien sûr, il s'agit d'un exemple de jouet. En réalité, la différence de taille n'est peut-être pas si importante, mais il y a de fortes chances que CommonJS ajoute un poids important à votre build de production.

De manière 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 réduction peuvent optimiser votre application, évitez de dépendre de modules CommonJS, et utilisez la syntaxe du 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 utilisez est un module CommonJS, la taille du bundle de votre application en sera affectée.

Pourquoi CommonJS élargit-il votre application ?

Pour répondre à cette question, nous examinerons le comportement de ModuleConcatenationPlugin dans webpack, puis nous parlerons de l'analyse statique. Ce plug-in concatène le champ d'application de tous vos modules en une seule fermeture et accélère l'exécution de votre code 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 créer le projet en utilisant 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 le résultat obtenu:

/******/ (() => { // 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 le résultat ci-dessus, toutes les fonctions se trouvent dans le même espace de noms. Pour éviter les conflits, webpack a renommé la fonction subtract de index.js en index_subtract.

Si un outil de réduction traite le code source ci-dessus, il:

  • Supprimez les fonctions inutilisées subtract et index_subtract.
  • Supprimer tous les commentaires et les espaces blancs redondants
  • Intégrer le corps de la fonction add à l'appel console.log

Les développeurs appellent souvent cette suppression des importations inutilisées comme "tree-shaking". Cette fonctionnalité n'était possible que parce que webpack a pu identifier de manière statique (au moment de la compilation) les symboles importés depuis utils.js et les symboles qu'il exporte.

Ce comportement est activé par défaut pour les modules ES, car ils peuvent être analysés de manière plus statique que CommonJS.

Examinons le même exemple, mais cette fois, modifiez utils.js pour utiliser 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 le résultat. Étant donné que l'intégration sur cette page est trop longue, 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 un "runtime" webpack, c'est-à-dire un code injecté chargé d'importer/exporter la fonctionnalité à 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 avons besoin de la fonction add de manière dynamique au moment de l'exécution à l'aide de __webpack_require__.

Cette étape est nécessaire, car CommonJS permet d'obtenir le nom de l'exportation à partir d'une expression arbitraire. Par exemple, le code ci-dessous est une construction absolument valide:

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

Le bundler n'a aucun moyen de savoir au moment de la compilation quel est le nom du symbole exporté, 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 façon, le réducteur ne peut pas comprendre exactement ce que index.js utilise à partir de ses dépendances, et ne peut donc pas l'utiliser dans des arbres. Nous observerons le même comportement pour les modules tiers. Si nous importons un module CommonJS depuis node_modules, votre chaîne d'outils de compilation ne pourra pas l'optimiser correctement.

Secousses d'arbre 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 un littéral de chaîne, contrairement à CommonJS, où il s'agit d'une expression.

Dans certains cas, si la bibliothèque que vous utilisez respecte des conventions spécifiques concernant l'utilisation de CommonJS, vous pouvez supprimer les exportations inutilisées au moment de la compilation à l'aide d'un plug-in webpack tiers. Bien que ce plug-in soit compatible avec le tree-shaking, il ne couvre pas toutes les différentes façons dont vos dépendances pourraient utiliser CommonJS. Cela signifie que vous n'obtenez pas les mêmes garanties qu'avec les modules ES. De plus, elle entraîne des frais supplémentaires dans le cadre de votre processus de compilation, en plus du comportement par défaut webpack.

Conclusion

Pour vous assurer que le bundler peut 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 vérifier que vous êtes sur la bonne voie:

  • Utiliser la méthode node-resolve de Rollup.js et définissez l'option modulesOnly pour spécifier que vous souhaitez dépendre uniquement des modules ECMAScript.
  • Utiliser le package is-esm pour vérifier qu’un package npm utilise les modules ECMAScript.
  • Si vous utilisez Angular, un avertissement s'affichera par défaut si vous dépendez de modules qui ne peuvent pas être soumis à des arborescences.