Gestion des dépendances, des assets et du packaging de la librairie
Les premières problématiques que nous avons abordées lors de l’étude de faisabilité sont en rapport avec la gestion des dépendances et le packaging de la librairie.
Théoriquement, la création et l’utilisation d’une librairie JavaScript est une tâche assez courante et plutôt simple. Mais qu’en est-il dans le cadre d’un projet React Native ?
Le contexte technique
Nos applications utilisent les outils suivants :
TypeScript, JavaScript avec du typage fort TypeScript ajoute une syntaxe supplémentaire de typage fort à JavaScript permettant de capturer les erreurs au plus tôt. Le compilateur TypeScript permet de sortir du code JavaScript standard. | |
React Native, création d’applications native en React React Native permet de créer des applications mobiles natives écrites en JavaScript. Une seule base de code pour plusieurs applications. | |
Expo, framework ET plateforme pour React Native Expo est un ensemble d’outils et de services qui facilite et améliore le développement d’application en React Native. Il propose des API supplémentaires et une plateforme qui permet de builder et déployer des applications facilement. | |
Metro, le bundler JavaScript pour React Native Metro est un bundler, c’est-à-dire qu’il prend tous les fichiers sources du projet et les compile en un seul fichier de sortie, le « bundle ». Il est capable d’appliquer des étapes de transformation du code ainsi que des conversions d’assets (images par exemple) pour les rendre utilisables dans le code. |
Nos besoins
La librairie devra donc répondre aux besoins suivants :
- Export des types TypeScript
- Éviter au maximum la duplication de dépendance pour ne pas alourdir l’application finale
- La librairie doit pouvoir embarquer ses propres assets (images, fonts, etc.)
- La compilation de la librairie doit rester simple et rapide
Les problématiques
Dès le début du développement, plusieurs questions sont apparues :
- Comment va se comporte Metro, le bundler de React Native, avec notre librairie en dépendance ?
- Quelle syntaxe est-il capable de gérer dans le contexte Expo ? ES5 ? ES20xx ? ESNext ? TypeScript ??
- Est-ce que les assets de la librairie seront embarqués correctement dans l’application finale ?
- Comment permettre le partage de dépendances entre l’application et la librairie ?
Gestion des dépendances
À noter que nous utilisons le gestionnaire de paquet Yarn classic (v1). Les autres gestionnaires de paquet comme npm v7+ ou Yarn 2+ peuvent avoir des comportements différents, notamment avec les peerDependencies.
Dans l’environnement node, il existe plusieurs types de dépendances que nous pouvons déclarer dans notre package.json, nous allons nous concentrer sur les trois suivantes :
- dependencies : dépendances directes de notre projet, nécessaire au fonctionnement du programme final
- devDependencies : dépendances seulement nécessaires au développement : outillage de build, de test, de typage, de qualité de code etc.
- peerDependencies : dépendances qui ne sont pas ajoutées par la librairie mais qui doivent être présentes dans le projet cible
Pour rappel, notre problématique ici est que nous souhaitons que l’intégration de notre librairie ne vienne pas alourdir l’application. Il faut que ce soit quasiment transparent en terme de poids et de performance.
Pour ce faire, il faut que la librairie déclare correctement ses dépendances, sinon elles risquent de se retrouver en double dans les modules de l’application finale.
De toute façon, certaines dépendances dites “principales” (par exemple react, react-native ou expo) ne peuvent pas être présentes en double dans les modules, l’application ne pourrait pas fonctionner.
Pour les dépendances dites “utilitaires” (par exemple axios ou date-fns), l’application peut fonctionner avec plusieurs instances de ces dépendances mais elle serait alourdi, ce qu’on nous souhaitons justement éviter.
Pour mieux visualiser la problématique, voici une image qui illustre une mauvaise déclaration de dépendances de la librairie hypothétique “lib-shared-components” :
Les dépendances principales (ici react-native et expo) sont dupliquées, l’application ne pourra probablement pas fonctionner. Et en plus, cette librairie fait exploser le poids total des dépendances.
Maintenant imaginons que la librairie déclare correctement ses dépendances, c’est-à-dire en mettant react-native et expo en peerDependencies, voici le résultat :
La librairie utilisera ainsi les packages react-native et expo déjà présents dans les modules de l’application. Donc pas de problème de duplication ni d’incompatibilité de version.
Plus concrètement, voici à quoi ressemble le package.json de notre librairie :
{
...
"dependencies": {
"color": "^x.x.x",
...
},
"peerDependencies": {
"axios": "^x.x.x",
"date-fns": "^x.x.x",
"expo": "^x.x.x",
"react": "~x.x.x",
"react-native": "x.x.x",
"react-native-paper": "^x.x.x",
...
}
}
La dépendance color est un utilitaire utilisé seulement dans la librairie, nous pouvons la laisser dans les dependencies.
Dans les peerDependencies, on a spécifié tous les modules dont la librairie a besoin, ainsi que leur version compatible. La librairie utilisera ainsi les modules déjà en place et ne viendra pas les dupliquer.
Si nous avons tout de même besoin de ces dépendances en mode développement, pour les tests ou pour travailler en isolation par exemple, nous pouvons très bien ajouter ces dépendances dans les devDependencies. Ce type de dépendance ne sera pas ajouté lors de l’installation de la lib par le projet “hôte” et ne risque donc pas de polluer les dépendances de l’application finale.
Maintenant que tout cela est en place, restait à s’assurer que ça fonctionne avec Metro, ce que nous allons voir dans la partie suivante sur le packaging de la librairie.
Packaging de la librairie
Lorsque Metro construit une application React Native, il prend un fichier d’entrée et des options, et sort un seul fichier JavaScript qui contient tout le code et les dépendances. Ce processus contient trois grandes étapes :
- Résolution : construction d’un graphe de tous les modules qui sont requis depuis le point d’entrée.
- Transformation : tous les modules passent par un “transformer”, qui est responsable de convertir (transpiler) un module dans un format compréhensible par la plateforme cible (React Native).
- Serialization : fusion de tous les modules transformés dans un seul fichier JavaScript, le bundle.
Habituellement pour des raisons de performance, on ne transforme pas les modules dans le dossier node_modules. Il a donc fallu s’assurer qu’il était possible de configurer les chemins à transformer ou ignorer. Si ce n’est pas possible, la lib devrait être publié dans un format compréhensible par React Native.
Après quelques essais, il se trouve que par défaut, Metro transforme tous les modules. Donc même si notre librairie était publiée en TypeScript directement, l’étape de transformation serait appliquée sur notre module et la compilation se passerait sans problème.
Attention, cela fonctionne car nos applications cibles sont toutes en Expo TypeScript. Si la librairie avait vocation a être distribué plus largement, il faudrait proposer plusieurs formats (ES5, ES2015, TypeScript) pour être compatible avec toutes les configurations.
Notre librairie pourrait donc exposer directement les sources TypeScript, sans étape de build. Mais pour avoir un résultat plus propre et dans les règles de l’art, une étape de build est quand même utile pour effectuer certaines tâches :
- Vérification du typage
- Ne pas exposer les fichiers de test
- Transformer le code TypeScript en JavaScript, ce qui permet d’extraire le typage dans des fichiers séparés
Le compilateur TypeScript (tsc) est capable d’effectuer toutes ces tâches, nous choisissons donc d’utiliser seulement tsc. Pas besoin de se compliquer la vie avec un bundler type Babel ou autre.
Voici à quoi ressemble notre fichier de configuration (tsconfig.json) utilisé pour le build de la librairie :
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"moduleResolution": "Node",
"jsx": "react-jsx",
"strict": true,
"declaration": true,
"outDir": "./dist",
...
},
"include": ["src/**/*", "assets/**/*"],
"exclude": ["src/**/__tests__", "src/**/*.test.*"]
}
Avec le fichier de configuration ci-dessus, le compilateur TypeScript se comportera comme ceci :
- il parcours les dossiers src et assets à la recherche de fichiers TypeScript (*.ts et *.tsx)
- il ignore tous les autres types de fichier ainsi que ceux qui matchent les patterns spécifiés dans le champs exclude (tous les fichiers de test dans notre cas)
- pour chaque fichier ts / tsx trouvé, il sort deux fichiers : un fichier JavaScript (.js) dans la syntaxe spécifiée (ESNext) et un fichier de typage (.d.ts)
- les fichiers de sortie sont envoyés dans le dossier dist
DOSSIER SRC | DOSSIER DIST | |
COMPILATION |
Gestion des assets
Un asset est un fichier qui vit à côté du code source de votre application dont l’application a besoin au moment de l’exécution. Les assets peuvent être des images, des policesdes fichiers audio, etc.
Notre librairie embarque ses propres assets, principalement des images. Ce sont toutes les images que l’on peut retrouver dans l’application finale : des logos, des icônes etc.
Par exemple dans le screenshot ci-dessous, tous les logos de moyen de paiement (encadrés en rouge) sont des images embarquées en tant qu’asset :
La première question est de savoir si Metro sait résoudre correctement nos assets sachant qu’ils se trouvent dans les node_modules, dossier qui peut parfois être exclu de la résolution des assets ?
Après un test, on a pu confirmer que cela ne pose pas de problème, Metro résout par défaut tous les assets quel que soit leur emplacement.
La seconde question est de savoir si Metro va réussir à résoudre nos assets dans la compilation de l’application finale ?
Lorsque nous développons, ce sont les sources du dossier src qui sont utilisées. Par contre en mode production, lorsque la librairie est publiée, seuls les fichiers du dossier dist sont exposés.
Il faut savoir que le compilateur tsc ignore complètement les fichiers qui ne sont pas en TypeScript. Donc si des assets sont présents dans le dossier src, ils ne seront pas copiés dans le dossier de sortie dist.
Une solution serait donc de copier manuellement nos assets dans le dossier dist juste après l’étape de compilation. Cela pourrait être automatisé mais ça reste une étape supplémentaire (qu’on souhaite éviter).
Une seconde solution serait de penser notre structure de dossier pour que la résolution d’asset fonctionne aussi bien depuis le dossier src que depuis le dossier dist, en mettant le dossier assets au même niveau que les dossiers src et dist, comme dans cet exemple :
Comme le montre les deux flèches rouges, qu’on se trouve dans le dossier src ou dans le dossier dist, le chemin relatif vers notre asset “logo.png” est valide donc l’import sera correctement résolu.
Avec cette structure, on peut donc importer des assets de manière relative qui fonctionneront aussi bien en mode développement qu’en mode production. Et cela sans étape supplémentaire au moment de la compilation.
Après quelques tests, nous avons pu confirmer que cette solution fonctionne. La problématique des assets est donc résolue !
Conclusion
- Notre librairie est écrite en TypeScript à 100 % et elle expose le typage de tous ses composants.
- Le partage des dépendances fonctionne entre l’application et la librairie : pas de duplication de dépendance donc pas de surcharge de l’application finale.
- La compilation de la librairie reste simple et rapide (1 seule étape) vu qu’on utilise seulement le compilateur TypeScript.
- Grâce à notre structure de dossier, la gestion des assets dans la librairie ne pose pas de problème. Cela fonctionne aussi bien en mode développement qu’en mode production.