Le dossier node_modules
est un dossier un peu mystérieux qui est la source de pas mal de problèmes. Aujourd'hui je vous propose de plonger dans le vif du sujet et d'analyser son rôle dans la résolution des dépendances dans le cadre de NodeJS.
Comment NodeJS résoud
Prenons donc un exemple simple d'import
import {hello} from 'hello'
hello()
Dans le cas d'un import qui ne consitue pas un chemin relatif (non préfixé par ./
, ../
ou /
) NodeJS dispose de son propre système de résolution qui peut être résumé de la manière suivante :
- Il cherche un dossier
node_modules/NOM_DU_MODULE
adjacent au fichier courant - Si le dossier n'est pas trouvé il fait la même recherche dans le dossier parent (et cela récursivement)
Si le fichier qui demande le module est lui même dans un dossier node_modules
dans ce cas là il cherche directement dans ce dossier (plutôt que de cherche un dossier node_modules
adjacent).
Si un dossier portant le nom du module est trouvé il va alors lire le fichier package.json
de ce dossier pour connaitre le fichier à importer gràce aux clef main
ou exports
.
{
"main": "src/index.js"
}
Cela lui permet de résoudre le fichier qui va donc être importé dans notre fichier original et l'import sera traduit comme cela :
import {hello} from './node_modules/hello/src/index.js'
hello()
Le cas des liens symbolique
Les liens symboliques viennent rajouter une petite couche de complexité dans la résolution des dépendances car NodeJS utilise le chemin réel des fichier dans sa résolution. Prenons cet exemple :
/Projet1
/node_modules
/a -> /Projet2/node_modules/a
/b
/src
index.js
package.json
/Projet2
/node_modules
/a
/b
Le module a
dépend du module b
et on a à l'intérieur de son fichier un import b from 'b'
Si le fichier index.js
essaie de charger le module a
, le lien symbolique sera suivi et lors de la recherche du module b
NodeJS explorera le dossier Projet2
et ses dossiers parents. Ce comportement peut être modifié à l'aide d'un drapeau --preserve-symlinks
(drapeau que l'on utilisera peu souvent car ça crée d'autres problèmes).
Et npm dans tout ça ?
Maintenant que l'on a compris le fonctionnement de la résolution des paquets, on va parler du gestionnaire de dépendance npm et sa manière d'installer les dépendances. Si on installe une dépendance vite
par exemple on se retrouve avec la structure suivante.
{appDir}
├── src
│ ├── index.js
└── node_modules
|── vite
|── esbuild
|── postcss
|── resolve
|── rollup
└── fsevents
On remarque qu'il place toutes les dépendances à la racine du dossier node_modules
pour exploiter le système de dossiers parents dans la résolution des dépendances. Ce hoisting
permet à plusieurs modules qui partage une même dépendance d'utiliser le même dossier (plutôt que d'installer plusieurs fois la librairie). Mais que se passe-t-il si on installe un paquet a
qui a besoin d'une version de esbuild
différente par exemple ?
{appDir}
├── src
│ ├── index.js
└── node_modules
|── a
| |── package.json
| |── index.js
| └── node_modules
| └── esbuild
|── vite
|── esbuild
|── postcss
|── resolve
|── rollup
└── fsevents
Dans ce cas là npm
n'a pas d'autres choix que d'installer 2 fois esbuild. Une version sera laissée dans le sous dossier node_modules
de a
afin que les import fait depuis ce dossier résolve cette version, tandis que les autres fichiers en dehors du module a
continuerons à résoudre le dossier esbuild
à la racine.
Attention par contre si vous utiliser npm link
pour gérer des dépendances croisées car la résolution des liens symboliques s'applique. Aussi, dans le cas d'un monorepo il faudra être vigilant à ce que les dépendances communes à plusieurs modules se retrouvent bien dans un dossier à la racine au risque de dupliquer la dépendance plusieurs fois.