đ» CrĂ©er des dĂ©pendances "propre" en JS
Cet article est destiné à un public JavaScript avertit.
J'y explique comment créer depuis un seul repository, des packages indépendants avec leur propres traductions, style, js, tests et rendus isolés.
Le process est valable pour n'importe quelle techno.
Si vous ĂȘtes un dĂ©veloppeur web "2.0" alors vous connaissez forcĂ©ment npm, cet outil pratique qui permet de "tirer" puis d'utiliser des dĂ©pendances.
A la base npm est un outil dédié à NodeJS (et donc au back) mais avec les nouveaux outils front, de plus en plus complet et complexe, npm devient indispensable pour assister nos "build" front.
Dans cette marrée de différentes technos (Vue, React, Angular pour ne citer qu'eux) un problÚme reste présent:
- comment puis-je faire des modules que je pourrais réutiliser sur tous mes projets sans tenir compte de la "stack" du projet final
Et plus ça va, plus on a des modules et plus on fait du "copié-collé" d'un projet à l'autre. Pas top question maintenance.
Donc on se dit qu'on va faire un repos qui contiendra tout ça (pas bĂȘte), on va peut-ĂȘtre faire un submodule au lieu d'une npm (car on veut que ça reste privĂ©), mais la procĂ©dure npm n'est pas si complexe.
Puis le temps passe, on a des composants de toutes sortes, des petits, des complets, des mĂ©tiers, des techniques et la taille de notre build dĂ©passe allĂšgrement le mĂ©ga-octet (je suis sympa, en vrai si vous faites de l'es6 cĂŽtĂ© front et que vous utilisez babel et React, votre build fait trĂšs vite 1Mo sans mĂȘme avoir codĂ© quoi que ce soit !).
Dans notre super salade nous allons mettre les ingrédients suivants: Lerna, Rollup, Yarn (et npm sisi), Babel, Storybook, du CSS via PostCSS et CSSNext et du code React (car c'est ce que j'utilise, mais le principe fonctionnera avec n'importe quelle techno).
Petit tour de table
- Lerna: pour faire simple c'est un outil qui permet de lancer des commandes en parallÚle dans plusieurs sous-dossier, l'outil indispensable pour gérer une bibliothÚque de composants indépendants
- Rollup: concurrent de Webpack plus simple et plus orienté dépendences
- Yarn: concurrent de npm, on va s'en servir pour initialiser et utiliser le "workspace", les commandes npm fonctionneront toujours, mais derriĂšre cela appelera yarn
- Babel: transpilateur ES6+ vers ES5 (en français ça donne, faire que du code JS de derniÚre génération s'exécute correctement sur tous les navigateurs)
- Storybook: un outil qui permet d'instancier des composants d'UI dans un environnement vierge (un peu comme des calques photoshop)
- React (y'a besoin de le présenter ?)
- i18next est un framework d'internationalisation, on l'utilisera dans les derniers chapitres de l'article "aller plus loin" afin d'intégrer des json à notre fichier
- PostCSS / CSS Next, une variante de SASS/LESS en gros :) que l'on utilisera aussi à la fin, histoire de mettre en place du style indépendant dans nos composants.
Je vais faire au plus simple au dĂ©but, notre "composant" dĂ©mo ne sera pas extra-ordinaire en termes de fonctionnalitĂ©s, mais ça fonctionnera et vous permettra de comprendre l'environnement. Si vous n'avez jamais fait de React, pas de soucis. Suivez l'article et une fois que vous aurez compris comment ça fonctionne, il devrait vous ĂȘtre aisĂ© de migrer vers votre stack favorite.
Initialisation du projet
Positionnez-vous dans le dossier oĂč vous voulez crĂ©er votre projet lerna.
Installez lerna en global via npm: npm i -g lerna
Ensuite, initialisez un repo multi-packages "lerna": npx lerna init
Ă partir de lĂ vous devriez voir un dossier "packages" vide, un fichier package.json et un fichier lerna.json.
Votre package.json ne devrait contenir que "lerna" comme devDependencies, on va donc ajouter toute la clique correspondant a notre stack:
"devDependencies": {
"babel-cli": "^6.26",
"babel-core": "^6.26",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "latest",
"babel-preset-stage-0": "latest",
"lerna": "^3.4.0",
"prop-types": "latest",
"react": "latest",
"rollup": "^0.63.5",
"rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-babel": "^3",
"rollup-plugin-commonjs": "^9.1.8",
"rollup-plugin-node-resolve": "^3.4.0"
}
Babel et ses presets pour la transpilation ES6+ vers ES5, react et rollup avec ses plugins nécessaires pour matcher le reste de notre stack.
LĂ on a le minimum requis.
Maintenant, on descend dans packages/ et on va créer notre premier package "alpha".
cd packages/ && mkdir alpha && cd $_
Il faut maintenant créer un fichier package.json et y mettre le contenu suivant:
{
"name": "my-demo-package-alpha",
"version": "0.0.0",
"description": "Alpha is the first",
"author": "Someone who was here before you",
"license": "MIT",
"repository": "",
"main": "dist/index.js",
"module": "dist/index.es.js",
"scripts": {
"build": "rollup -c",
"prepare": "yarn run build"
},
"dependencies": {
"react": "*",
"prop-types": "*"
},
"files": [
"dist"
]
}
Ajoutez aussi le fichier qui permettra de lancer la build, le fichier rollup.config.js avec le contenu suivant:
import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';
import commonjs from 'rollup-plugin-commonjs';
import autoExternal from 'rollup-plugin-auto-external';
import pkg from './package.json';
export default {
input: 'src/index.js',
output: [
{
file: pkg.main,
format: 'cjs'
},
{
file: pkg.module,
format: 'es'
}
],
external: [
],
plugins: [
// say to rollup how to resolve node dependencies
resolve(),
// allow to include all kind of amd package in an ES6 way
commonjs({
include: 'node_modules/**',
namedExports: {
'node_modules/react/index.js': ['Component', 'PureComponent', 'Fragment', 'Children', 'createElement', 'cloneElement']
}
}),
// babel
babel({exclude: "node_modules/**"}),
// automatically detect what should be excluded from the build
autoExternal()
]
}
Enfin, ajoutez un dossier "src" et mettez ceci dedans un fichier index.js:
/**
* @class AlphaComponent
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class AlphaComponent extends Component {
static propTypes = {
text: PropTypes.string
}
constructor(props) {
super(props);
this.myRef = React.createRef();
}
render() {
const {
text
} = this.props;
return (
<div ref="test">
Alpha Component: {text}
</div>
)
}
}
Remontez au dossier racine du projet et ajoutez un fichier .babelrc pour les options babel (on pourrait mettre ce fichier dans chacun des packages, mais c'est plus simple d'avoir le mĂȘme pour tous les packages):
{
"presets": [
["env", {
"modules": false
}],
"react",
"stage-0"
],
"plugins": []
}
Attention, Storybook utilisera aussi ce babelrc, donc si vous deviez ajouter des plugins ou autre et que le storybook (ou les tests) ne fonctionne plus, le mieux sera d'injecter un babelrc dans la config rollup au vol (voir les derniers chapitres "aller plus loin").
Yarn est un concurrent de NPM. Selon moi il n'apporte pas grand chose, n'est pas plus rapide et pas plus fiable.
Cependant, Yarn apporte un truc vraiment sympa, le workspace.
Grùce à cela vous allez pouvoir centraliser toutes les dépendances communes à chacun de vos packages.
En plus de sauvegarder de la place sur le disque dur (ce qui en soit n'est plus un problÚme aujourd'hui), vous allez grandement gagner du temps lorsqu'il faudra les récupérer, mettre à jour. Et autre cas relativement fréquent, lorsque vous aurez des conflits de versions.
IdĂ©alement vous aimeriez que chaque package ait exactement les mĂȘmes versions de dĂ©pendances (sinon bonjour la galĂšre). Avoir un point d'entrĂ©e va permettre de simplifier ça.
Le workspace vous permettra aussi de rĂ©aliser plus facilement les cross-dependencies (il me semble que ça marche sans, mais bon autant faire le plus propre qu'on peut đ ).
Si vous n'avez pas Yarn, installez-le
Ensuite configurez le workspace pour qu'il pointe sur le dossier packages.
En cas de doute, voilĂ ce qu'il faut ajouter dans le fichier lerna.json (au root):
"npmClient": "yarn",
"useWorkspaces": true,
puis dans package.json (toujours depuis le root):
"workspaces": [
"packages/*"
]
Maintenant ajoutez ces commandes dans votre package.json:
"scripts": {
"bootstrap": "lerna bootstrap --use-workspaces",
"build": "lerna exec --parallel -- yarn run build",
"linkLerna": "lerna link --force-local"
},
Nous allons utiliser ces commandes une par une, tout d'abord, lancez bootstrap avec npm run bootstrap (ou yarn bootstrap), cela va fetcher toutes les dépendances liées au workspace.
Puis yarn linkLerna, cela permet de faire des symlink entre vos packages en cas de cross-dépendances (il n'y a pas besoin de cette commande si vous n'avez pas de cross-dépendances, mais elle ne coûte rien donc prenez le réflexe de la faire aprÚs un bootstrap !).
Ces 2 commandes doivent toujours ĂȘtre appelĂ©es lorsque vous ajoutez des packages ou modifiez une config (que ce soit un ajout/suppression dans un package.json, peut importe lequel).
Logiquement vous devriez pouvoir lancer yarn build sans erreur.
Cool, a ce stade vous ĂȘtes fin prĂȘt pour publier vos packages.
Si l'ajout de Storybook ne vous intéresse pas, sautez directement au chapitre suivant: "npm publish".
Sachez qu'on a fait le tour des basiques concernant Lerna!
Storybook permet d'instancier des composants dans un espace vide, un peu comme si vous aviez un laboratoire et que vous pouviez afficher un composant dans le vide sans personne d'autres.
C'est plutĂŽt pratique pour gĂ©nĂ©rer des test live (en ajoutant des commandes diverses ou en faisant plusieurs scĂ©nario), de mĂȘme qu'Ă©crire de la doc !
On peut spĂ©cifier de la config custom Ă Storybook, ça demande de creuser un peu, mais je n'ai pas vu de limite Ă ce que Storybook propose (et au pire, c'est un projet open-source đ ).
Commencez par rĂ©cupĂ©rer Storybook, lorsque j'ai Ă©crit cet article j'Ă©tais en LTS 3.4.11, la v4 Ă©tant en alpha, d'ici quelques mois ces informations ne seront peut-ĂȘtre plus exacte.
Referez donc vous aux liens que je vais vous passer ci-et-lĂ .
Pour pouvoir lancer notre storybook, il faut d'abord ajouter dans notre package.json principal, les devDependencies dont on va avoir besoin:
"glob-loader": "^0.3.0",
"react-dom": "^16.5.2",
"@storybook/addon-actions": "^3",
"@storybook/addon-info": "^3",
"@storybook/addon-options": "^3",
"@storybook/addons": "^3",
"@storybook/react": "^3",
"storybook-addon-jsx": "latest"
Ajoutez aussi la commande "start" dans vos scripts:
"start": "start-storybook -h 0.0.0.0 -p 9001 -c .storybook"
Voilà le guide de démarrage lié a react.
Vous pouvez suivre le guide de storybook, ou ce que j'ai écrit selon votre préférence.
Sachez que la config que je vous présente est plus complÚte que le minimum requis, de cette maniÚre vous verrez plus d'options et de possibilités ("dans la vraie vie" le truc de base exposé en démo n'est jamais suffisant).
Créez donc un dossier ".storybook" à la racine du projet.
Ensuite, ajoutez les fichiers et contenus suivant dans ce dossier:
stories.pattern (regex pour récupérer les fichiers "story"):
../stories/**/*.js
config.js (configuration de base de l'appli "wrapper" React):
import React from 'react';
import { configure, addDecorator } from '@storybook/react';
import './storybook.css';
addDecorator((story) =>
<div style={{padding:"10px"}}>
{story()}
</div>
);
function loadStories () {
require('glob-loader!./stories.pattern');
}
configure(loadStories, module);
storybook.css (pour pouvoir modifier le style par défaut du Storybook, c'est toujours sympa):
body {
background-color: rgba(0, 0, 0, 0.05);
background-image: repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(0, 0, 0, 0.2) 1px, transparent 8px), repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(0, 0, 0, 0.2) 1px, transparent 8px);
background-size: 8px 8px;
display: flex;
flex-direction: column;
justify-content: space-around;
}
html {
box-sizing: border-box;
font-family: Helvetica, "sans-serif";
font-size: 14px;
}
webpack.config.js (pour surcharger la configuration webpack par défaut, si besoin):
const genDefaultConfig = require('@storybook/react/dist/server/config/defaults/webpack.config.js');
const path = require('path');
module.exports = (baseConfig, env, defaultConfig) => {
const config = defaultConfig;
// For example, add typescript loader:
// config.module.rules.push({
// });
return config;
};
Maintenant, il faut ajouter une "story", voyez ça comme un test unitaire un peu.
Ajoutez donc un dossier "stories" puis créez un fichier par composant (du nom de chaque composant si vous voulez), on va donc ajouter alpha.js
VoilĂ le contenu Ă mettre dans votre fichier, cela devrait ĂȘtre sensiblement pareil d'un fichier Ă l'autre. Attention Ă bien mettre des noms unique, vous pouvez crĂ©er l'arborescence que vous voulez dans le dossier stories:
// stories/alpha.js
import React from 'react'
import {storiesOf} from '@storybook/react'
import {withInfo} from '@storybook/addon-info';
import Alpha from "../packages/alpha/dist";
storiesOf('Alpha', module)
.add('default', withInfo(``)(() =>
<Alpha text="Coucou je suis le composant alpha" />
));
N'oubliez pas de faire un yarn bootstrap
puis yarn linkLerna
afin d'avoir toutes les dépendances. Un npm i
fonctionne aussi mais il requiert quand mĂȘme le bootstrap aprĂšs.
Et maintenant showtime, on lance storybook avec npm start et on consulte le résultat sur 0.0.0.0:9001.
Je vous laisse le soin de consulter les commentaires pour comprendre chaque partie.
Libre Ă vous de rajouter des boutons, un state, ou quoi que ce soit dans chaque fichier, qui vous permet de jouer avec votre composant.
Vous pouvez aussi faire une documentation, en complétant par exemple chaque "withInfo", rajouter des tests unitaires, ajouter des snapchots avec Jest.
En tout cas, vous avez vos composants qui fonctionnent, il ne reste plus qu'Ă ... publier !
Histoire d'ĂȘtre raccord, voici la branche qui correspond Ă tout ce que l'on vient de faire.
npm publish
D'abord, on va ajouter la commande "publish" dans nos scripts du package.json principal:
"publish": "lerna publish"
Libre à vous de publier sur le repo public npm ou sur un repo privé.
Si vous publiez des packages privés qui ne dépendent pas du service npm, il vous faut utiliser cette commande à la place:
"publish": "lerna publish --no-verify-access"
Vous pouvez aussi consulter la documentation de lerna publish
Aussi, avant d'aller plus loin retenez bien ceci:
Le but de Lerna est de centraliser la maintenance, tests et publications de vos dépendances.
Par conséquent, vous ne DEVEZ JAMAIS publier un package seul, "à la main". Sinon lerna ne sera plus capable de publier, il vous faudra sauter des versions ou supprimer les packages publiés, bref. Ne faites pas ça.
On est bon ? On retourne au dossier racine du projet.
Vérifiez que vous avez bien configuré chacun de vos packages, si c'est un repo privé il faut ajouter la configuration qui va bien, s'il est publique il faut y mettre les bonnes informations.
Instanciez votre repo git si ce n'est déjà fait, faite votre commit/push puis...
Lancez npm run publish (ou yarn publish) pour publier!
Cette commande lance un build avant de publier, libre à vous de modifier cette commande pour y intégrer les tests.
npm vous demande de quelle "upgrade" il s'agit, vous pouvez suivre la convention par défaut ou le faire "custom". Moi je fais mon possible pour respecter le standard:
- +0.0.1 pour un fix
- +0.1.0 pour une feature
- +1.0.0 pour un changement majeur (potentiellement "breaking changes")
Aller plus loin 1 - une config pour les gérer toutes
On peut encore améliorer tout ça.
Un problÚme reste présent dans notre projet actuel, lorsque l'on aura plusieurs packages et que l'on fera "une évolution" ou "un petit changement" dans la config "rollup" de nos packages:
Il faudra faire le changement pour chaque package, 1 par 1. Rien de plus pénible.
Donc, on va faire un fichier type "factory" au répertoire racine, puis chacun de nos packages appellera ce fichier-là , y précisera "ses spécificités" et lance la fonction.
Comme ça, on a plus qu'un fichier à maintenir.
Voilà la théorie:
- On fait un fichier "global.rollup.config.js" à la racine du répertoire parent.
Ce fichier est censé exporter une fonction qui prend en paramÚtre un objet dans lequel on pourra écraser n'importe quel paramÚtre. - Dans le fichier rollup.config.js de chacun de nos packages on enlÚve tout et on appelle ce fichier dans le parent.
Il ne reste qu'à appeler la fonction retournée par le fichier en lui passant en paramÚtre nos options customisée pour ce composant-là .
Je vous invite à consulter la branche plus_loin_1_factory pour pouvoir consulter cette amélioration.
Aller plus loin 2 - embarquer du json tu pourras (traductions)
Dans ce chapitre additionnel on va ajouter le chargement de JSON à notre package "alpha" (le json ne sera pas chargé via un appel AJAX mais sera inliné dans la build).
Afin de faire une démo "utile", on va faire comme si l'on voulait ajouter un fichier de langue qui sera chargée automatiquement par i18next.
Cela sous-entend que vous utilisez i18next dans votre projet principal, si ce n'est pas le cas vous trouverez un moyen d'adapter ce principe Ă votre stack :)
On ajoute dans notre package.json principal ces devDependencies:
"i18next": "^11.9.0",
"rollup-plugin-json": "^3.1.0",
Ou en commande npm i --save-dev i18next rollup-plugin-json
Une fois ces nouvelles dépendances installées, dans le fichier global.rollup.config.js il faut ajouter le json loader.
import json from 'rollup-plugin-json';
// a ajouter juste aprĂšs "resolve()" (ligne 31 normalement)
// allow to load inlined json
json( Object.assign( optional.json || {}, {
include: 'src/**',
indent: ' '
} ) ),
Un petit yarn bootstrap
suivi d'un yarn linkLerna
et un yarn build
nous confirment que notre build rollup fonctionne toujours.
Maintenant on va ajouter notre json dans notre package.
Je vous invite à consulter la branche github associée
Dans le fichier package.json du package alpha, ajoutez dans les dépendances: "i18next": "*"
.
Dans le dossier alpha on ajoute un sous dossier src/locales
qui contiendra chacun de nos fichiers de langues, on va mettre fr.json
et en.json
pour cet exemple.
Vous pouvez directement reprendre les fichiers depuis le github.
Les fichiers sont importés comme une dépendance normale, ensuite dans le constructeur du composant on vérifie si i18next connait déjà ce namespace. Sinon, on y injecte nos langues.
Attention, cette technique ne fonctionne que si votre projet utilise i18next dans la mĂȘme version, vu que le package demande n'importe quelle version de i18next via "*", vous n'aurez normalement pas de conflits de versions. Sinon, passez vos dependencies en peerDependencies rĂšgle toujours le problĂšme, mais le projet parent devient alors responsable de la prĂ©sence des dĂ©pendances.
Dans le code du composant on fait ces modifs:
// ajoutez ces imports
import FR_LOCALES from "./locales/fr.json";
import EN_LOCALES from "./locales/en.json";
// dans le constructeur
// charge le bundle fr s'il n'existe pas dans i18n
if ( !i18n.hasResourceBundle( "fr", "namespace" ) ) {
i18n.addResourceBundle( "fr", "namespace", FR_LOCALES );
}
// charge le bundle en s'il n'existe pas dans i18n
if ( !i18n.hasResourceBundle( "en", "namespace" ) ) {
i18n.addResourceBundle( "en", "namespace", EN_LOCALES );
}
// dans le render afin de vérifier que cela fonctionne
Localization: {i18n.t("namespace:key_of","i18n_error")}
Il va nous falloir faire une petite modification Ă l'initialisation de storybook pour initialiser i18next (sinon cela ne marchera pas).
Dans le fichier .storybook/config.js
ajoutez le code suivant juste aprĂšs les imports:
import i18n from "i18next";
i18n.init({
lng: 'en',
fallbackLng: ['en'],
fallbackOnNull: true,
fallbackOnEmpty : true,
returnNull: false,
returnEmptyString: false,
debug: true,
resources: {
}
}, () => {
});
Une fois ce code ajouté, lancez yarn build et enfin yarn start.
En navigant Ă l'adresse du storybook vous devriez voir votre composant avec la phrase traduite.
Maintenant pour tester vous pouvez changer le lng: 'en',
Ă lng: 'fr',
dans la config, attendre que le code soit rechargé à la volée et actualiser la page (ou passer d'un module à l'autre pour forcer le re-rendering), vous devriez l'avoir en français !!
Aller plus loin 3 - avec du css c'est mieux
Comment ? Vous n'ĂȘtes pas satisfait de vos composant en noir et blanc ?
Alala. Pas de soucis, j'ai une solution (à défaut d'avoir là solution).
Si vous voulez gagner du temps, voilĂ la branche qui contient tout ce qu'il faut.
Les Ă©tapes sont assez rapide, on va utiliser PostCSS (pourquoi? Parce que c'est ce qu'on utilise Ă Viareport et que j'avais pas envie de faire une version de chaque đ) mais vous devriez pouvoir trouver chaussure Ă votre pied si cela ne vous convient pas.
Sachez que comme le CSS sera transpilé, ce choix n'impactera pas votre projet final de toute façon !
Une bonne chose en somme.
Dans le fichier global.rollup.config.js faites les modifications suivantes:
// on ajoute ces imports spécifique à postcss
import postcss from 'rollup-plugin-postcss';
import cssNext from 'postcss-cssnext';
import cssReporter from 'postcss-reporter';
import cssImport from "postcss-import";
// dans plugins, juste avant "resolve()," (ligne 34 normalement)
postcss({
// modules: true, // if true, create a css namespace => will add the name of the css file as prefix to all classname (ux-header became style_ux-header)
plugins: [
cssImport( { skipDuplicates: true } ),
cssNext(),
cssReporter()
]
}),
Il faut ajouter les nouveaux outils dans le package.json principal:
npm i --save-dev rollup-plugin-postcss postcss postcss-cssnext postcss-reporter postcss-import
Comme Ă l'habitude il faut lancer yarn bootstrap && yarn linkLerna
Un petit test avec yarn build
pour confirmer que tout fonctionne comme avant.
Et maintenant, il nous suffit d'ajouter un fichier css à notre composant alpha (je vous laisse le soin de découvrir ce que CSSNext offre):
// dans le fichier alpha/src/index.js
import './style.css'
// pensez Ă modifier le render pour avoir une classe
<div className="alpha" ref="test">
// exemple dans le fichier style.css, mais n'importe quoi marcherait
.alpha {
padding: 10px;
background: white;
border: 1px solid black;
}
Vous lancez Ă nouveau yarn build && yarn start
puis allez vérifier sur Storybook que tout est conforme.
Alors, c'Ă©tait dur ?
J'espĂšre que non !
PS: a la vitesse a laquelle évolu le JS, il est probable que certaines config ne fonctionnent plus, auquel cas il faut vérifier chaque dépendance une par une pour voir et comprendre comment elle fonctionne et comment l'upgrader.
Mais si vous utilisez mon repo tout devrait bien se passer đ
Si vous avez aimĂ© cet article n'hĂ©sitez pas Ă me le faire savoir, j'Ă©crirai peut-ĂȘtre un autre article dans la lignĂ©e de celui-ci, concentrĂ© sur les tests.
Subscribe to Les trucs d'Inateno
Get the latest posts delivered right to your inbox