Rollup, l’outil pour les bundler tous !

Rollup est un bundler JavaScript. C’est à dire qu’il lit votre code et bundle l’ensemble des modules importés (via import ou require) en un fichier unique. Il est capable d’exporer ce module dans les principaux formats de modules (CJS, ESM, AMD, IIFE) correspondant à tous les cas d’usage. En outre, via quelques plugins bien utiles, il se chargera aussi de transpiler et de minifier le code. Que demande le peuple ?

Je vais commencer par une petite digression pour vous répondre tout de suite quant au besoin du peuple. Le peuple, celui qui n’a pas forcement une connexion fibre, qui a une ADSL poussive ou une 3G un peu essouflée, sans toutefois naviguer avec IE6. Ce que ce peuple veut, c’est un code léger à charger et rapide à parser.

Malheureusement, nous avons pris pour habitude d’écrire notre code en ES2015+ et de tout transpiler en ES5 sans trop réfléchir (source d’obésité avérée). Parce que dans nos stats d’analytics on a quand même 2,6% de personnes qui naviguent encore sous IE (donc 80% sous IE11 d’ailleurs !).

Au delà de Rollup, cet article sera donc axé sur la performance et the one best way pour livrer notre cher JS.

On risque de pas mal parler de version d’ECMAScript, j’utilise de manière interchangeable ES2015+ et ES6+.

Installer les dépendances

Comme dit précédemment, Rollup est un outil qui vient lire le code et qui intègre le code des modules requis dès qu’il rencontre une instruction import. Cette petite merveille de technologie en profite pour optimiser l’import avec du Tree Shaking [en]. C’est à dire qu’il n’importe que le code qui sera utilisé.

Pas de mention de require ? C’est normal, Rollup est un outil qui fonctionne avec les modules ECMAScript, pas avec les modules CommonJS. Néanmoins, n’ayez pas d’inquiétude, les plugins sont au cœur de Rollup et il y en a justement un qui nous permet d’importer les modules CJS. On commence donc par installer Rollup et ses plugins.

# j'installe ici tous les plugins que nous allons utiliser. Dans certains cas, tous ne seront pas utiles
# vous installerez au besoin selon vos projets
npm install --save dev rollup rollup-plugin-commonjs rollup-plugin-node-resolve rollup-plugin-babel rollup-plugin-terser babel-cli babel-plugin-external-helpers

Nous avons donc installé Rollup et les plugins que nous utilisons couramment. Dans l’ordre :

  • Rollup, nous l’avons assez présenté,
  • rollup-plugin-commonjs, comme son nom l’indique, il permet d’importer les modules CJS,
  • rollup-plugin-node-resolve permet de résoudre l’adresse du module depuis les node_modules (par défaut Rollup ne sait résoudre que les modules locaux,
  • rollup-plugin-babel, ici évidemment, il s’agit de transpiler le code avec Babel,
  • rollup-plugin-terser, ce plugin n’est rien d’autre qu’un uglyfier, il prend en charge ES6+, ce que ne fait pas UglifyJS,
  • babel-cli, c’est le cœur de Babel et il est utilisé par rollup-plugin-babel,
  • babel-plugin-external-helpers, il s’agit d’un plugin pour Babel utile à son usage avec Rollup, nous y reviendrons.

Maintenant que nos dépendances sont bien installées, nous allons configurer tout cela. Nous allons tirer parti des scripts npm. Si vous n’en maitrisez pas l’usage, je vous conseille une lecture rapide de mon article sur npm en tant que task runner. J’y utilise d’ailleurs Browserify, un utilitaire similaire à Rollup mais qui ne fonctionne qu’avec les modules CJS.

Configurer Rollup

Avant d’attaquer la configuration à proprement parler, il faut savoir ce que l’on veut faire. Selon les cas, des configurations tout à fait différentes s’appliquent…

Module npm

Je commence par les modules à publier (ou pas) sur npm pour trois raisons :

  • il n’y a pas besoin de plugin dans ce cas,
  • comme nous importons majoritairement des plugins npm, c’est bien de voir comment ils sont conçus,
  • enfin, les pratiques actuelles sont sources de problème pour l’écosytème.

La pratique actuelle est de pré-compiler en ES5 les modules (pour le navigateur, pas pour nodejs) publiés sur npm afin qu’il n’y ait nul besoin de les re-compiler lors de la publication de l’app finale.

C’est très moche car pour le développeur d’une app qui ne voudrait cibler que les navigateurs modernes, impossible de transpiler le code ES5 en ES2015+. Et le code ES5 est plus lourd.

L’inverse est en revanche possible. Si le code est en ES6+, pour celui qui veut supporter des navigateurs plus anciens, il utilise Babel pour compiler son code ainsi que les dépendances.

En outre, cette pratique habitue les développeurs à penser que les bibliothèques sur npm sont compatibles sans compilation pour tous les navigateurs. Ce qui n’est pas vrai et va l’être de moins en moins. Sauf mention contraire, c’est au dévloppeur final de choisir la compilation et la minification.

Maintenant que tout est dit, la bonne pratique consiste à utiliser Rollup afin de transformer les modules ESM en modules CJS pour les outils comme Browserify (ou Webpack 1) ne supportant pas l’ESM. Il y a dans le package.json deux champs pour spécifier les modules : main et module.

On utilisera main qui est le champ par défaut, c’est celui qu’utilise Node ainsi que les bundlers CJS. Tandis que les bundlers ESM aware utiliseront le script référencé dans module s’il existe. Attaquons la config, rollup.config.js :

export default {
    input: 'src/index.js',
    output: {
        format: 'cjs',
        file: 'lib/index.js'
    }
};

Le fichier de configuration est un module au format ESM 😎. L’input est le fichier source au format ESM, l’output est au format CJS. On a donc les deux formats de modules que l’on voudra publier dans notre package.json. Jetons donc maintenant un œil à ce dernier.

{
  "name": "smart-img-resize",
  "version": "2.1.1",
  "main": "lib/index.js",
  "module": "src/index.js",
  "files": [
    "src",
    "lib"
  ],
  "dependencies": {
    "smartcrop": "^1.1.1"
  },
  "devDependencies": {
    "rollup": "^0.62.0"
  },
  "scripts": {
    "build": "rollup -c",
    "prepare": "npm run build"
  }
}

Rien de plus simple n’est-ce pas ? Si vous ne spécifiez pas le chemin du fichier de config, par défaut, Rollup ira chercher rollup.config.js à la racine. On précise ici prepare afin que Rollup s’exécute avant la publication du paquet sur npm et lors d’une installation en local.

Dernier détail à gérer lors de la publication de modules à partager, on ne bundle pas les dépendances. Le module bundler du développeur de l’application final s’en chargera, il n’y a qu’à lire le package.json de chaque module importé. Imaginez que chaque module vienne avec ses propres dépendances, il y aurait rapidement du code redondant !

Pour dire à Rollup que ce n’est pas grave s’il ne résoud pas les imports, on devra préciser de manière exhaustive les modules qui ne devront pas être résolus (sinon, il y aura simplement un warning mais l’ouput sera le même).

export default {
    input: 'src/index.js',
    output: {
        format: 'cjs',
        file: 'lib/index.js'
    },
    external: ['smartcrop']
};

Et voilà, rien de bien compliqué ! Maintenant, passons de l’autre côté du tunnel pour bundler une application finale.

Application web

Qu’il s’agisse d’une webapp, d’une PWA ou d’un site, cela ne change rien, vous avez des fichiers JavaScript qu’il va falloir préparer pour le navigateur du client.

Première question, que veut-on faire ? Voici le listing des pré-requis :

  • Cette fois, on va vouloir bundler nos dépendances directement en un seul fichier afin de ne pas avoir 10 000 requêtes,
  • On va aussi être responsable de compiler notre code et ses dépendances en fonction de nos navigateurs cibles,
  • Enfin, on va minifier l’ensemble pour transférer le moins d’octets possible.

Si nous sommes d’accord sur les tâches à effectuer, comme nous avons vu plus haut les plugins dont nous avions besoin, passons à la configuration de notre rollup.config.js.

// import de nos plugins
import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import { terser } from 'rollup-plugin-terser';

export default {
    input: 'js/main.js', // notre fichier source au format ESM
    output: {
        format: 'iife',
        file: 'js/main.iife.min.js',
        // les modules iife doivent être nommés afin de pouvoir y faire référence depuis d'autres modules
        name: 'buzut'
    },
    plugins: [
        commonjs(), // prise en charge de require
        resolve(), // prise en charge des modules depuis node_modules
        babel(), // transpilation
        terser() // minification
    ]
};

On note cette fois un output en iife. Il s’agit d’une fonction lancée lors du chargement du script. Cela permet de ne pas polluer l’espace global et d’éviter les collisions. C’est le format à utiliser pour l’inclusion avec les balises script : <script src="/js/main.min.js"></script>.

On a une autre possibilité, exporter directement en ESM. En effet, tous les navigateurs modernes supportent maintenant nativement les modules ECMAScript.

Comme le souligne Google, il est donc possible d’envoyer un bundle ESM aux navigateurs le supportant et un bundle IIFE aux autres. Cette méthode nous donne également l’assurance que les navigateurs qui supportent les modules ES supportent aussi les versions de JavaScript sortie avant les modules : const, let, les Promise, async/await, les arrow functions, le spread operator

<script async type="module" src="/js/main.esm.min.js"></script>
<script async nomodule src="/js/main.iife.min.js"></script>

C’est tout ce qu’il faut pour livrer la version ESM aux navigateurs modernes et la version legacy aux autres navigateurs (principalement IE). Tous les navigateurs qui comprennent l’attribut type="module" de la balise script ignoreront le script nomodule. Et les anciens navigateurs ignoreront simplement la nouvelle syntaxe car elle leur semblera invalide.

Pour générer deux outputs différents, on utilisera les variables d’environnement de Babel. Exemple :

BABEL_ENV=esm rollup -c

Dans notre fichier de config Rollup, nous n’avons qu’à exporter la config correspondant à l’environnement :

import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import { terser } from 'rollup-plugin-terser';

const iife = {
    input: 'js/main.js',
    output: {
        format: 'iife',
        file: 'js/main.iife.min.js',
        name: 'buzut'
    },
    plugins: [
        commonjs(),
        resolve(),
        babel(),
        terser()
    ]
};

const esm = {
    input: 'js/main.js',
    output: {
        format: 'es',
        file: 'js/main.esm.min.js'
    },
    plugins: [
        commonjs(),
        resolve(),
        babel(),
        terser()
    ]
};

const conf = process.env.BABEL_ENV === 'esm' ? esm : iife;
export default conf;

On pourrait également avoir plusieurs fichier de conf distincts et de préciser quel fichier utiliser, mais je trouve plus clair d’avoir le tout centralisé à un seul endroit. Il ne nous reste plus qu’à configurer Babel intelligemment.

Babel et son .babelrc

Babel, à l’instar de Rollup, concentre toute sa configuration dans son propre fichier de config. Comme à notre habitude, posons nous d’abord deux secondes pour réfléchir à ce que nous voulons faire.

Nous allons donc cibler les navigateurs sortis depuis 2017 pour notre module ECMAScript, tandis que pour notre module IIFE legacy, nous ciblerons tous les navigateurs représentants plus de 0,5% des internautes, ce qui nous ramène à être compatible avec IE11, d’anciennes versions de Safari etc. Vous pouvez tester les navigateurs ciblés en faisant npx browserslist QUERY depuis le répertoire du projet.

{
  "presets": [
    ["env", {
      "esm": {
          "modules": false,
          "targets": {
            "browsers": ["since 2017"]
          }
      },
      "iife": {
          "modules": false,
          "targets": {
            "browsers": ["> 0.5%"]
          }
      }
    }]
  ],
  "plugins": [
    "external-helpers"
  ]
}

Vous remaquez certainement "modules": false et vous vous demandez à quoi cela sert. Babel, via son preset env, converti automatiquement les modules ESM en modules CJS, ce qui empêche Rollup de fonctionner. Cette option permet donc de spécifier à Babel de ne pas transformer les modules.

On constate aussi la présence du plugin external-helpers dont nous avons parlé plus haut. Très succintement, cela permet à Babel de ne pas dupliquer le code de ses helpers et autorise Rollup à l’inclure une seule fois au début du bundle plutôt que de laisser Babel les inclure au début de chaque fichier transformé.

Maintenant que tout est clarifié, intégrons Rollup à notre package.json afin de pouvoir lancer la compilation en une seule commande.

{
    …
    "scripts": {
      "js:esmbuild": "BABEL_ENV=esm rollup --config",
      "js:iifebuild": "BABEL_ENV=iife rollup --config",
      "js:build": "npm run js:esmbuild && npm run js:iifebuild",
      "js:watch": "BABEL_ENV=esm rollup --config --watch"
    }
}

Et voilà, un coup de npm run build:js et tous vos fichiers seront générés comme par enchantement. Si vous êtes un peu perdu avec les scripts npm ou si vous voulez optimiser encore un peu plus les performances en pré-compressant tous vos assets en Gzip et Brotli, lisez mon article sur le workflow avec npm.

Vous constatez aussi que j’ai ajouté une commande watch, cela permet à rollup de détecter lorsque vos fichiers sources sont modifiés et d’automatiquement relancer le bundling. Évidemment, comme votre environnement de config est compatible ESM, on ne relance qu’une compilation ESM, pas la peine de perdre de temps.

En conclusion, toujours avec les performances à l’esprit, lorsque vous incluez des polyfills, ne les mettez pas dans le bundle car tous les navigateurs n’en n’ont pas besoin. Dans la mesure du possible, compilez les polyfills dans des fichiers à part et servez-les seulement au navigateurs qui en ont besoin. Cet article [en] explique dans les détails comment faire !

Déjà 4 réponses, rejoignez la discussion !

  • rikar

    dit :

    Je constate que l’écosystème JS c’est toujours de la merde, comme votre article de noob !
    C’est pour ça que les jeunes dev c’est de la merde !
    Allez vous pendre avec votre code en carton !

    • Buzut

      dit :

      Charmant et constructif commentaire. Montre nous ton code en COBOL qu’on rigole un coup… Et quand on a un email @aol.com d’un autre âge, je pense qu’on peut se la fermer car de toute évidence on ne comprend pas le monde moderne.
      @+

    • Buzut

      dit :

      Je connaissais Bublé seulement de nom, du coup je viens de regarder d’un peu plus près. Ça ne te permet pas d’utiliser toute la gamme des améliorations ES6+, mais c’est plus safe pour supporter des navigateurs un peu plus vieux. Il est plus rapide aussi j’imagine ? Qu’est-ce qui vous a fait prendre cette décision ?

      Et pour node, pourquoi bundler ? Meilleur temps au démarrage pour une codebase très importante ?

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *