🌐 Comment publier une bibliothèque TypeScript pour les navigateurs
Apprenez à distribuer une bibliothèque JavaScript compatible avec tous les environnements (ESM, CJS, UMD) en utilisant TypeScript, Rollup et Babel.
I] Introduction 📜
Lors du développement d'une bibliothèque, la publier afin de la rendre utilisable par les autres développeurs est une étape très importante 🚀, mais c'est surtout une tâche très complexe 🧩.
Cet article vous aidera à rendre votre bibliothèque facile d'accès pour le plus de personnes 👥, tout en garantissant un niveau de compatibilité satisfaisant pour les utilisateurs finaux. Il guide peut vous être utile, que vous soyez débutant 🌱 ou développeur confirmé 💻, en vous expliquant les défis de la distribution de bibliothèques web modernes 🌐 mais aussi en vous montrant les solutions qui s'offrent à vous. Il a également pour but d'être aussi complet que possible 📖, et de vous éviter de devoir lire des dizaines d'autres articles avant de parvenir à publier correctement votre projet 🎯.
Prérequis
Cet article vise principalement les auteurs de bibliothèques JavaScript web 🌟. Ainsi, il convient de noter que pour suivre ce guide, il vous faut avoir une maîtrise ferme des bases de l'écosystème JavaScript. Pour être plus précis, vous devez comprendre les bases du langage lui-même, de TypeScript, et surtout les différents systèmes de modules (ES6 et CommonJS) 🔍.
II] Quel est le défi rencontré? 🤯
Le défi le plus important quant à la distribution de modules est la diversité des environnements dans lesquels votre code va être utilisé 🌍. En effet, il existe trois principaux cas d'utilisation:
1. Base de code utilisant les modules ES6 📦
Tout d'abord, l'utilisateur peut avoir une base de code utilisant le système de module ECMAScript
, introduit dans la version 6 (d'où le nom de modules ES6). Alors, pour déployer son application, il crée un bundle
📦. C'est un unique fichier JavaScript généré automatiquement par un outil appelé bundler
(comme Rollup, Webpack, ou Parcel), et qui contient l'intégralité du code. Ces bundles contiennent souvent des polyfills
, générés par d'autres applications comme Babel.js. Ce sont des morceaux de code qui sont ajoutés par le bundler et qui garantissent que les fonctionnalités de JavaScript utilisées dans le code fonctionneront correctement, même si certains navigateurs ne les supportent pas 🌐. Ainsi, nous n'avons pas à nous soucier des problèmes de rétro-compatibilité dans ce scénario, car c'est l'utilisateur de la bibliothèque qui se charge de cette tâche 🛠️.
Les modules ES6 sont aussi supportés nativement sur les navigateurs les plus récents, mais ils sont peu utilisés de cette manière pour l'instant 📉.
// Exemple de code utilisant les modules ES6
import MyModule from 'mymodule'
const mod = new MyModule()
mod.do_something()
Dans ce cas, nous devons fournir une version de notre bibliothèque compatible avec les modules ES6, et sans polyfills.
2. Base de code utilisant CommonJS 📦
De la même manière, bien que ce cas d'utilisation soit assez rare, l'utilisateur peut avoir une base de code utilisant les modules CommonJS
, et également générer un bundle à inclure dans son application, avec Browserify par exemple.
❌ Les modules CommonJS ne sont pas supportés nativement par les navigateurs.
// Exemple de code utilisant CommonJS
const MyModule = require("mymodule")
const mod = new MyModule()
mod.do_something()
De manière similaire au cas précédent, nous devons fournir une version CommonJS, sans polyfills.
3. Intégration directe aux pages web 🌐
Finalement, l'utilisateur peut utiliser notre bibliothèque sans créer de bundle, en l'incluant directement dans sa page et en y accédant grâce à une constante globale au sein des autres scripts 🔄.
<script src="https://notre/bibliotheque.js"></script>
<script>
// Notre bibliothèque est déclarée globalement
// comme une unique classe : MaBibliotheque
let bibli = new MaBibliotheque()
bibli.exemple()
</script>
Pour permettre une telle utilisation, nous devons créer un bundle UMD (Universal Module Declaration) de notre bibliothèque. Comme pour les bundles évoqués précédemment, c'est un unique fichier JavaScript qui contient l'ensemble de notre code, et il a pour objectif, sans entrer dans les détails, de supporter plusieurs systèmes de modules, dont la méthode décrite ci-dessus. Etant donné que notre code sera directement intégré aux pages web, nous allons dans ce cas-là devoir ajouter des polyfills à notre bundle final 📦.
Solution possible 💡
En résumé, pour couvrir tous ces cas d'utilisation, nous devrons distribuer trois versions de notre bibliothèque 📜:
- Une version ES6 sans polyfills
- Une version CommonJS sans polyfills
- Une version UMD avec polyfills
Cela peut paraître compliqué 🤯, mais nous allons voir qu'il est possible, en effectuant les configurations nécessaires, de produire toutes ces versions automatiquement à partir d'une seule base de code TypeScript 🚀.
Typescript est un langage basé sur JavaScript, qui permet d'effectuer des vérifications de type, ce qui nous laisse détecter les erreurs pendant le développement et non pendant l'utilisation de la bibliothèque 🔍. Cependant, la fonctionnalité de TypeScript qui nous intéresse est son principe de transpilation, permettant de créer différentes versions (CJS, ES6...) de notre base de code à partir de l'originale. La transpilation se fait grâce à l'outil tsc
(TypeScript Compiler) qui joue le rôle de traducteur, entre les différentes versions.
III] Configuration de la pipeline 🏗️️
La pipeline, ou chaîne de production, est l'ensemble des étapes de transformation de notre code par lesquelles nous allons passer afin d'arriver aux résultats attendus.
1. Préparation 🛠️
Si ce n'est pas déjà fait, initialisez votre module NPM avecnpm init
. Cette commande créera un fichier 📄package.json
avec des informations de base.
Les différents builds de notre bibliothèque seront enregistrés dans un répertoire /dist
(distribution) selon ce schéma 📁:
./dist 📁
├── lib-cjs<---------Version CommonJS
│ ├── main.d.ts
│ ├── main.js
│ ├── main.js.map
│ └── ...
├── lib-esm<---------Version ESM
│ ├── main.d.ts
│ ├── main.js
│ ├── main.js.map
│ └── ...
└── MyLibrary.umd.js<---------Bundle UMD
A chaque démarrage du processus, il est important de s'assurer que ce dossier ne contient pas déjà d'autres versions, nous allons donc créer un script NPM permettant de le supprimer.
Etant donné qu'il n'existe pas de commande universelle, permettant de supprimer des fichiers sur tous les OS, nous allons utiliser shx
. C'est un utilitaire qui met à disposition des commandes fonctionnant sur toutes les plateformes. Parmi ces commandes, nous retrouvons rm
, qui permet de faire ce que nous voulons 🗑️. Le fonctionnement de cette commande est le même que celle sur Linux. Nous allons maintenant ajouter la première dépendance de développement à notre projet 📦.
# Installation de shx
npm install --save-dev shx
Puis, nous allons créer un script NPM qui permet de supprimer le dossier /dist
🧹.
// 📄 package.json
{
"scripts": {
"clean": "shx rm -rf ./dist",
...
},
...
}
Maintenant, nos autres scripts pourront utiliser npm run clean
afin de garantir que d'autres fichiers ne gêneront pas la compilation ✅.
A noter: 📄package.json
est un fichier JSON. La syntaxe de JSON ne permet pas d'ajouter de commentaires, il faut donc faire attention à ne pas copier les lignes commençant par//
, ou le fichier sera invalide.
2. Générer la version ES6
Nous allons utiliser tsc
afin de générer une version JavaScript ES6. Pour cela, nous allons d'abord configurer TypeScript avec le fichier 📄tsconfig.json
.
// tsconfig.json
{
"compilerOptions": {
// Nous voulons utiliser les dernières fonctionnalités
// ECMAScript (ESNext), ainsi que le DOM
"lib": ["ESNext", "DOM"],
// Nous voulons aussi générer les fichiers de déclaration
// de types .d.ts et les fichiers de carte source .js.map
"declaration": true,
"sourceMap": true,
// Notre projet utilise le dossier node_modules
"moduleResolution": "node",
// Optionnel: utilise des vérifications plus strictes
"strict": true,
"noUncheckedIndexedAccess": true
},
// L'ensemble des fichiers que nous voulons "traduire"
// doit être spécifié ici. Dans ce cas, nous voulons
// transpiler tous les fichiers dans /src
"include": ["src/**/*"]
}
Nous pouvons maintenant ajouter un nouveau script à notre 📄package.json
, qui générera la version ES6 en utilisant tsc
🔧.
// 📄 package.json
{
"scripts": {
// Nous voulons un build utilisant les modules ES6
// et la syntaxe doit être compatible avec ES2017
"build:esm": "tsc -m es6 --target es2017 --outDir ./dist/lib-esm/",
...
},
...
}
Pour utiliser cette commande, nous devons avoir correctement installé le module typescript
:
npm install --save-dev typescript
3. Générer la version CommonJS 🛠️
De la même façon que pour générer la version ES6, nous allons utiliser tsc
. Nous allons également réutiliser le même fichier 📄tsconfig.json
. Il nous suffit alors seulement de créer un nouveau script.
// 📄 package.json
{
"scripts": {
// Nous voulons un build utilisant CommonJS
// et la syntaxe doit être compatible avec ES2017
"build:cjs": "tsc -m commonjs --target es2017 --outDir ./dist/lib-cjs/",
...
},
...
}
4. Générer le bundle UMD 🛠️
Pour créer notre bundle UMD, il est plus simple de se baser sur la version JavaScript ES6, et non pas sur notre version source TypeScript. Nous allons utiliser le bundler Rollup.js
, qui est le plus adéquat selon moi quand il s'agit de distribuer une bibliothèque. Avec Rollup, nous allons utiliser Babel.js en tant que plugin. C'est un outil qui permet d'ajouter des polyfills à notre bundle final, dans le but de garantir la rétro-compatibilité avec les navigateurs. Premièrement, nous devons installer les modules nécessaires 📦.
npm install --save-dev \
@babel/cli \
@babel/core \
@babel/preset-env \
@rollup/plugin-babel \
@rollup/plugin-node-resolve \
rollup
Puis, nous créons un fichier de configuration pour Babel.js, qui permet d'ajouter les polyfills nécessaires pour supporter tous les navigateurs utilisés par plus de 0.25%
des utilisateurs 🌐.
// babel.config.json
{
"presets": [
[
"@babel/preset-env",
{ "targets": "> 0.25%, not dead" }
]
]
}
Ensuite, nous devons configurer Rollup, afin de générer notre bundle. 📦
// rollup.config.mjs
import { babel } from '@rollup/plugin-babel'
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'dist/lib-esm/main.js',
output: {
file: 'dist/MyLibrary.umd.js',
format: 'umd',
// Le nom de la constante globale donnant accès
// à la bibliothèque à partir des autres scripts
name: 'MyLibrary'
},
plugins: [
// Use Babel to include polyfills
babel({ babelHelpers: 'bundled' }),
// Also search for imports in node_modules
nodeResolve()
]
}
Finalement, nous allons créer le script NPM. 📄
// 📄 package.json
{
"scripts": {
// l'option --config dit à rollup de cherchez
// des noms de fichier de configuration connus
"build:umd": "rollup --config",
...
},
...
}
5. Automatisation de la pipeline 🔄
A ce stade, nous pouvons exécuter ces quatre commandes pour générer toutes les versions de notre bibliothèque:
npm run clean
npm run build:esm
npm run build:cjs
npm run build:umd
Cependant, il serait utile de pouvoir effectuer cela en une seule commande. Nous allons donc ajouter un nouveau script à notre 📄package.json
📝.
// 📄 package.json
{
"scripts": {
// '&&' permet d'arrêter le processus dès
// qu'une erreur est rencontrée
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:umd",
...
},
...
}
Il est maintenant possible d'exécuter toute notre chaîne de production en utilisant une unique commande. Voici un schéma récapitulatif de tout le processus : 📊
IV] Configuration du projet
Pour que tous les systèmes de module fonctionnent, et que notre module puisse être publié sur NPM, nous devons spécifier quels fichiers utiliser dans quels environnements. Nous allons ajouter les champs suivants à 📄package.json
:
// 📄 package.json
{
// Les champs main et module sont les ancien champs utilisés
// pour spécifier comment notre module doit être importé.
// Assurez vous que main.js correspond bien au nom
// de votre script principal.
"main": "./dist/lib-cjs/main.js",
"module": "./dist/lib-esm/main.js",
// L'objet 'exports' est une façon plus moderne de le faire.
"exports": {
"import": "./dist/lib-esm/main.js",
"require": "./dist/lib-cjs/main.js"
},
// Quels fichiers doivent être publiés sur NPM
"files": [
"/dist/**/*.js",
"/dist/**/*.d.ts",
"/dist/**/*.js.map"
],
// On rend le module publique.
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"private": false,
...
}
Notre module peut désormais être publié avec ces deux commandes:
# On compile chaque version
npm run build
# Et enfin, nous publions le projet
npm publish
V] Aperçu en direct 👀
Pour terminer, nous allons voir comment configurer un environnement de développement adéquat, permettant d'avoir un aperçu en direct des modifications apportées au projet. En d'autres termes, nous allons faire en sorte que chaque modification apportée au code source soit immédiatement et automatiquement reflétée sur tous les builds, ainsi que dans une page HTML dans le navigateur 🌐.
Par chance, cela ne sera pas compliqué, car chaque outil impliqué dans notre pipeline possède une fonctionnalité dite de watching
, permettant de mettre à jour les builds dès qu'un changement sur la source est détecté. Il est fort probable que vous testiez votre bibliothèque grâce à un simple fichier HTML au sein de votre projet, et que vous importiez votre bibliothèque dans cette dernière par une balise <script>
, utilisant ainsi la version UMD. Si ce n'est pas de cette manière que vous testez votre bibliothèque, vous pouvez toujours adapter les étapes suivantes à votre propre situation 🛠️.
Notre but est alors, dès qu'un changement est effectué sur la source, de lancer un nouveau build de la version ES6, puis de la version UMD, et finalement, de recharger la page du navigateur. Voici un schéma théorique de cette chaîne de production 📊.
1. Build continu de la version ES6 🔄
Nous pouvons lancer une compilation ES6 continue de notre bibliothèque en rajoutant simplement l'option --watch
à notre script build:esm
. Nous devrons donc exécuter npm run build:esm -- --watch
.
2. Build continu du bundle UMD et mise a jour automatique de la page HTML d'aperçu en direct 👀
Afin de recharger automatiquement notre page d'aperçu en direct, nous allons utiliser Rollup pour créer un serveur web local, mettant a disposition notre page. Pour réaliser cela, nous allons utiliser deux plugins:
npm install --save-dev \
rollup-plugin-livereload \
rollup-plugin-serve
Nous allons alors réutiliser notre script build:umd
et activer les deux extensions.
npm run build:umd -- --watch --plugin rollup-plugin-serve --plugin rollup-plugin-livereload
Il nous faut maintenant lancer parallèlement les deux scripts de build continu (build ES6 et UMD). Pour ce faire, nous allons utiliser le module concurrently
qui permet d'exécuter plusieurs commandes en même temps.
npm install --save-dev concurrently
Nous ajoutons donc les derniers scripts a notre 📄package.json
.
// 📄 package.json
{
"scripts": {
"watch-esm": "npm run build:esm -- --watch",
"watch-umd": "npm run build:umd -- --watch --plugin rollup-plugin-serve --plugin rollup-plugin-livereload",
"serve": "npm run build:esm && concurrently 'npm:watch-esm' 'npm:watch-umd'",
...
},
...
}
Nous exécutons npm run build:esm
avant de lancer les deux builds continus car il faut s'assurer que le build ES6 soit présents afin d'éviter une erreur ⛔ avec Rollup, qui dépend de cette version.
Nous pouvons maintenant utiliser npm run serve
afin de lancer un serveur de développement local, et ouvrir n'importe quel fichier HTML à la racine du projet, qui sera actualisé à chaque changement 🔄.
VI] Conclusion 🏁
Pour conclure, dans ce guide nous avons créé des scripts NPM permettant de:
- 📦 Générer une version utilisant le système de module ES6
- 📦 Générer une version utilisant le système de module CommonJS
- 📦 Générer un bundle UMD
- 🛠️ Effectuer tous les builds en une seule commande
- 👀 Lancer un serveur local, mettant a disposition une page d'aperçu en direct
Voici un récapitulatif de tout ce que nous avons ajouté à 📄package.json
// 📄 package.json
{
"main": "./dist/lib-cjs/main.js",
"module": "./dist/lib-esm/main.js",
"exports": {
"import": "./dist/lib-esm/main.js",
"require": "./dist/lib-cjs/main.js"
},
"files": [
"/dist/**/*.js",
"/dist/**/*.d.ts",
"/dist/**/*.js.map"
],
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"private": false,
"scripts": {
"clean": "shx rm -rf ./dist",
"build:cjs": "tsc -m commonjs --target es2017 --outDir ./dist/lib-cjs/",
"build:esm": "tsc -m es6 --target es2017 --outDir ./dist/lib-esm/",
"build:umd": "rollup -c",
"build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:umd",
"watch-esm": "npm run build:esm -- --watch",
"watch-umd": "npm run build:umd -- --watch --plugin rollup-plugin-serve --plugin rollup-plugin-livereload",
"serve": "npm run build:esm && concurrently 'npm:watch-esm' 'npm:watch-umd'",
...
},
...
}
Si vous rencontrez un problème, n'hésitez pas a demander de l'aide dans les commentaires sous cet article et je serai ravi de vous aider!