Je vous propose aujourd'hui de partager avec vous l'organisation de mes projets JavaScript.
00:00 Introduction
01:00 Workspaces & "monorepo"
04:00 Eviter les export default
09:12 Réexporter les dépendances
12:33 La librairie de composant
16:20 Organisation "atomic design"
23:25 Next et la transpilation des modules
26:39 Partager la configuration
34:35 Tests
36:50 Turborepo
Workspaces
Lorsque l'on travaille sur un projet JavaScript qui fait intervenir plusieurs technologies maintenir notre code source dans une seule structure n'est pas forcément adéquat et on se retrouve rapidement avec un projet qui est trop complexe. On va chercher à séparer notre code en plusieurs applications distinctes qui vont pouvoir être gérées de manière indépendante (mais on ne veut pas non plus créer plusieurs projets séparés car les synchroniser devient trop complexe).
Heureusement pour nous, la plupart des gestionnaires de paquets disposent d'un système de workspaces qui va permettre de créer plusieurs librairies à l'intérieur d'un seul et même projet (les workspaces fonctionnent avec npm, yarn et pnpm). Avec cette approche en tête nous allons morceler notre projet de la manière suivante :
- Un dossier
apps
qui contiendra toutes nos applications. - Un dossier
packages
qui contiendra des librairies qui seront partagées par les différentes applications (fonctions, hooks, helpers, types...).
Le package "functions"
En général, un package que j'aime bien créer est celui qui va contenir des fonctions utilitaires dont je vais avoir besoin dans l'ensemble de mon application. Ces fonctions vont être regroupées en fonction du type de données qu'elles vont traiter. Par exemple je vais avoir un fichier string.ts
pour les fonctions qui ont attrait aux chaînes de caractères. Pour l'ensemble de mon projet je vais utiliser le langage TypeScript que je trouve aujourd'hui incontournable pour assurer une bonne stabilité du projet et m'assurer de pouvoir faire des réorganisations avec le plus de sécurité possible. Par contre, vu que cette librairie est destinée à être utilisée par le reste de mes applications je vais créer un fichier qui va permettre de centraliser les exports et l'utiliser comme point d'entrée dans mon package.json
Exemple de fichier index.ts
export {capitalize, isSimilar, slugify} from './string'
export {subDays, addDays, dateFormat} from './date'
export {reverse, numericSort} from './array'
// ...
Et dans mon package.json
{
"main": "src/index.ts"
}
Ensuite ce module pourra être inclu dans mes applications gràce à pnpm
dans mon cas.
{
"dependencies": {
"functions": "workspace:*"
}
}
TypeScript de bout en bout
Pour mes librairies intermédiaires je ne vais pas transpiler mon code en javascript et je vais conserver du TypeScript comme point d'entrée. Ce sont mes applications qui auront la responsabilité de transpiler les sources TypeScripts (vite, next, nuxt, nest...). De manière générale, cette approche fonctionne plutôt bien mais il faudra faire attention à certains projets qui parfois ne transpilent pas ce qui se situe dans le dossier node_modules
. Dans ce cas-là il faudra faire quelques adaptations en termes de configuration.
Pas de "export default"
Tant que possible j'essaie de ne pas utiliser de export default
, car ce type d'export permet d'importer un module sans forcément imposer un nom spécifique.
import Hello from 'library'
Cela peut être problématique car en fonction des fichiers, et des développeurs, on peut se retrouver avec un même module qui va être importé avec des noms différents ce qui peut rendre difficile sa résolution (arriver à retrouver son origine). Les imports nommés apportent à mon sens plus de clarté sur l'origine d'une méthode même si un alias est utilisé.
import {Hello as maFonction} from 'library'
Une simple recherche sur maFonction
permet de trouver tous les fichiers qui l'utilisent.
On réexporte les dépendances tiers
Un autre point important lorsque l'on crée un "package" est de réexporter les dépendances. Par exemple, si dans mon application je souhaite utiliser date-fns
qui contient une série de fonctions en lien avec les dates je vais plutôt l'importer pour réexporter ses méthodes.
export {capitalize, isSimilar, slugify} from './string'
export {subDays, addDays} from 'date-fns' // On peut faire un export * pour tout exporter
Cela peut sembler redondant mais apporte une vrai plus value lorsque l'on met à jour les librairies. En effet, si une méthode change de signature je pourrais la réécrire et la remplacer dans ma librairie sans que cela affecte l'ensemble de mes application.
export {capitalize, isSimilar, slugify} from './string'
export {subDays} from 'date-fns'
// addDays a changé mais je veux utiliser l'ancienne signature, j'ai donc réécris la fonction
export {addDays} from './date'
On appliquera cette approche tant que possible afin de limiter l'impact des changements des librairies tiers.
Le package "ui"
Si on travaille sur un projet qui nécessite du front-end avec un framework comme React, VueJS, Svelte ou autre on commencera en général par créer une librairie de composant. Pour travailler sur cette librairie j'utilise Storybook qui permet de tester ces composants de manière isolée. Cet outil peut aussi être utilisé pour tester les composant à l'aide d'un système de Snapshot pour détecter les régressions.
La structure des dossiers peut dépendre du projet pour refléter l'organisation faite par les designers (ais en général je suis une structure Atomic Design).
/src
index.ts
/Atoms
/Button
Button.tsx
Button.module.scss
Button.stories.tsx
Button.test.tsx
/Card
/Box
/..
/Molecules
/SearchForm
/WelcomeCard
/..
Comme tout à l'heure on retrouvera à la racine le fichier index.ts
qui se chargera d'exporter les différents modules et qui servira de point d'entrée à mon application. Ensuite, j'utilise des noms de fichiers qui correspondent aux nom de mes composants React (cela permet de les trouver plus rapidement avec une recherche basée sur le nom du fichier). Les stories et les tests seront placées dans le même dossier afin de regrouper tout au même endroit pour ne pas avoir à parcourir plusieurs arhitecture de dossiers en simultané.
Le package "tools"
Afin d'assurer une bonne qualité de code on va utiliser toute une série d'outils pour contrôler le code mais on veut que tous les modules utilisent la même configuration. On pourra pour cela créer un paquet "tools" qui contiendra les différents fichiers de configurations pour le projet.
Par exemple pour prettier, on peut créer un fichier prettier.config.js
à la racine de notre module avec la configuration que l'on souhaite voir appliquer partout. Ensuite, dans le projet où je souhaite utilise prettier il me suffit d'y faire référence dans le fichier package.json
.
{
"prettier": "tools/prettier.config.js"
}
On peut aussi faire ça pour les fichiers de configuration TypeScript. Il faudra cependant faire attention car la configuration peut varier d'un projet à l'autre.
{
"extends": "tools/tsconfig-next.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Turborepo
Turborepo est un outil qui va permette de mieux contrôler les process liés au monorepo et d'améliorer les performances de compilation. Le principe est très simple, turbo va mémoriser un hash correspondant aux fichier de nos différents module et ne relancer les tâches que si les fichiers ont été modifiés. Cela permet de ne pas avoir à relancer les tests de l'entiereté de l'application si seulement une partie a été modifiée. Turbo peut aussi utiliser un cache centralisé pour conserver le résulat d'un build par exemple pour le récupérer directement plutôt que de le relancer. Son installation se fait simplement et s'intègre très bien dans un projet existant
pnpm install turbo --save-dev --workspace-root
Ensuite on crée un fichier de configuration turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false
}
}
}
Puis dans le package.json
{
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
}
}
On pourra aussi utiliser turbo pour initialiser un projet préconfiguré.