Laisser un commentaire

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’exporter 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 (dont 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 -D rollup rollup-plugin-terser @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-babel @babel/preset-env

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

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 maîtrisez 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 :

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": "^2.33.1"
    },
    "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é ! Pour plus de rapidité, j’ai un template de module npm prêt à être utilisé sur GitHub. 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 :

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 noderesolve 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
        noderesolve(), // prise en charge des modules depuis node_modules
        babel({ babelHelpers: 'bundled' }), // 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 noderesolve 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(),
        noderesolve(),
        babel({ babelHelpers: 'bundled' }),
        terser()
    ]
};

const esm = {
    input: 'js/main.js',
    output: {
        format: 'es',
        file: 'js/main.esm.min.js'
    },
    plugins: [
        commonjs(),
        noderesolve(),
        babel({ babelHelpers: 'bundled' }),
        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.

Cache busting

C’est une thématique abordée en détails dans l’article sur npm, le cache busting consiste à ajouter le numéro de version aux assets afin que les navigateurs ne servent pas d’anciennes versions mises en cache.

On utilise en général le numéro de version du projet npm. Pour le récupérer dans le fichier de config de Rollup, c’est très simple.

…
const iife = {
    input: 'js/main.js',
    output: {
        format: 'iife',
        file: 'js/main-${process.env.npm_package_version}.iife.min.js',
        name: 'buzut'
    },
    …
};

Et voilà ! Le fichier généré sera automatiquement versionné. Pour le CSS, généré depuis npm, vous pouvez consulter l’article sur npm pour voir la méthode en détail.

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 2018 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.

Ci dessous le fameux .babelrc :

{
  "env": {
    "esm": {
      "presets": [
        ["@babel/preset-env", {
          "targets": {
            "browsers": ["since 2018"]
          }
        }]
      ]
    },
    "iife": {
      "presets": [
        ["@babel/preset-env", {
          "targets": {
            "browsers": ["> 0.5%"]
          }
        }]
      ]
    }
  }
}

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 !

Commentaires

Rejoignez la discussion !

Vous pouvez utiliser Markdown pour les liens [ancre de lien](url), la mise en *italique* et en **gras**. Enfin pour le code, vous pouvez utiliser la syntaxe `inline` et la syntaxe bloc

```
ceci est un bloc
de code
```