Lancez npm install sur un projet, puis pnpm install sur le même package.json : vous obtenez deux arborescences node_modules différentes et deux lockfiles différents. Ajoutez un collègue qui utilise Yarn, et le dépôt finit avec trois lockfiles dans son historique, chacun décrivant une arborescence de dépendances légèrement différente. Les trois lisent le même package.json et téléchargent depuis le même registre npm. Ce sur quoi ils ne sont pas d’accord, c’est ce qu’il faut écrire sur le disque.

Comment npm, Yarn et pnpm organisent node_modules

La structure de npm est plate. Chaque paquet dont vos dépendances ont besoin, quelle que soit sa profondeur dans l’arborescence, est remonté dans la node_modules de premier niveau dès qu’il n’y a pas de conflit de version. C’est le comportement par défaut depuis npm 3. L’effet de bord : votre code peut faire require() ou import d’un paquet que vous n’avez jamais listé dans package.json, pour peu qu’autre chose dans l’arborescence en dépende. Cela fonctionne jusqu’à ce que cet autre paquet abandonne cette dépendance dans une minor release, et votre build casse sans que vous ayez touché votre propre code. C’est une dépendance fantôme, et la structure de npm ne fait rien pour l’empêcher.

Yarn Classic (v1) remonte les dépendances de la même façon et vous expose donc au même risque. Yarn Berry (v2 et versions suivantes) utilise par défaut Plug’n’Play, qui n’a aucun node_modules. Les dépendances restent compressées dans .yarn/cache, et un fichier .pnp.cjs généré automatiquement mappe chaque import directement vers l’archive correspondante. Committez .yarn/cache avec le lockfile et un nouveau clone fonctionne sans étape d’installation : Yarn appelle cela le zero-install. Le coût, c’est la compatibilité. Les outils qui lisent node_modules directement, et les modules natifs qui attendent de vrais fichiers sur disque, peuvent échouer sous PnP. Définir nodeLinker: node-modules dans .yarnrc.yml rétablit une structure plate classique, au prix du zero-install.

pnpm maintient un store unique par machine, indexé par contenu. Chaque version de chaque paquet vit sur disque une seule fois, identifiée par un hash de son contenu, quel que soit le nombre de projets qui l’utilisent. Pour chaque projet, pnpm crée des hard links des fichiers nécessaires dans un store virtuel sous node_modules/.pnpm, puis crée des symlinks uniquement pour vos dépendances directes dans la node_modules de premier niveau. Vous pouvez résoudre node_modules/react parce que vous l’avez déclaré. Vous ne pouvez pas résoudre un paquet qui n’existe que deux niveaux plus bas, dans l’arborescence d’une autre dépendance, parce que rien ne le relie au niveau supérieur. Le problème des dépendances fantômes est réglé par la structure elle-même, pas par la discipline des développeurs.

Les commandes de npm, pnpm et Yarn côte à côte

Au quotidien, les commandes correspondent presque une à une. La différence qui compte, c’est le flag pour la CI : installer exactement ce que dit le lockfile, ne rien résoudre, ne rien écrire.

ActionnpmpnpmYarn Berry
Tout installernpm installpnpm installyarn install
Ajouter une dépendancenpm install axiospnpm add axiosyarn add axios
Ajouter une dev dependencynpm install -D vitestpnpm add -D vitestyarn add -D vitest
Exécuter un scriptnpm run buildpnpm run buildyarn run build
Installation fiable pour la CInpm cipnpm install --frozen-lockfileyarn install --immutable

npm ci supprime d’abord node_modules et installe strictement depuis package-lock.json, en échouant si le lockfile et package.json ne correspondent pas. --frozen-lockfile et --immutable sautent la suppression mais imposent la même règle : aucune nouvelle résolution, aucune écriture du lockfile, un code de sortie non nul si le lockfile ne peut pas être satisfait tel quel. Utilisez la forme fiable pour la CI dans chaque pipeline et l’installation normale sur votre propre machine. Un lockfile obsolète doit faire échouer le build bruyamment, pas être résolu en silence.

Si vous installez les dépendances dans une image Docker, Bonnes Pratiques Dockerfile : Builds Plus Légers et Rapides explique comment copier le manifeste et le lockfile et exécuter l’installation avant COPY . ., pour que cette couche ne se reconstruise que lorsque le lockfile change.

Pourquoi un dépôt ne devrait committer qu’un seul lockfile

npm écrit package-lock.json, pnpm écrit pnpm-lock.yaml, et les deux générations de Yarn écrivent yarn.lock (dans des formats qui diffèrent entre v1 et Berry). Chacun enregistre la version exacte de chaque dépendance transitive. Aucun ne lit celui d’un autre.

Voici un cas concret. Quelqu’un lance yarn add lodash dans un dépôt qui n’avait jusque-là committé que package-lock.json. Maintenant yarn.lock existe aussi, les deux fichiers ne sont pas d’accord sur ce qui a été résolu, et l’outil que la CI exécute par hasard est celui qui l’emporte. La prochaine installation locale avec l’autre outil résout de nouveau quelque chose de différent.

Choisissez un seul gestionnaire de paquets par dépôt, ne committez que son lockfile, et ignorez les autres avant que quelqu’un ne les génère par accident :

1
2
3
# si ce dépôt utilise npm
yarn.lock
pnpm-lock.yaml

Comment figer le gestionnaire de paquets avec corepack

Un .gitignore n’empêche personne de lancer le mauvais outil s’il est sur son PATH. corepack, lui, le fait. Il lit le champ packageManager dans package.json et exécute exactement cette version de npm, Yarn ou pnpm, en la téléchargeant au premier usage :

1
2
3
{
  "packageManager": "[email protected]"
}

Lancez corepack enable une fois pour mettre sur votre PATH les shims de Yarn et pnpm (le shim de npm ne s’installe que si vous le demandez explicitement). À partir de là, pnpm install dans ce dépôt exécute la version figée 10.12.1 sur chaque machine, et personne n’a plus besoin d’y penser.

Corepack est fourni avec Node à partir de la 14.19.0, et jusqu’à la 25.0.0 exclue. Sur Node 25 et au-delà, installez-le avec npm install -g corepack, puis lancez corepack enable comme avant. En CI, l’option cache de actions/setup-node ('npm', 'yarn' ou 'pnpm') fonctionne avec corepack ; Mettre en Cache les Dépendances dans GitHub Actions explique les clés de cache derrière tout ça.

Comment les workspaces diffèrent entre npm, Yarn et pnpm

Les trois gèrent un monorepo de paquets locaux liés entre eux. Ils diffèrent sur l’endroit où vous déclarez les membres, et sur la façon de cibler l’un d’eux.

npm et Yarn lisent un tableau workspaces depuis le package.json racine :

1
2
3
4
5
{
  "name": "my-monorepo",
  "private": true,
  "workspaces": ["packages/*"]
}

Installez une fois à la racine et les paquets locaux se retrouvent liés dans la node_modules de chacun. Pour exécuter un script dans un paquet, npm demande un chemin :

1
npm run build --workspace=packages/api

Yarn demande le nom du paquet :

1
yarn workspace api run build

pnpm ignore le champ workspaces et lit l’appartenance depuis son propre pnpm-workspace.yaml :

1
2
packages:
  - "packages/*"

Il cible les paquets par nom avec --filter :

1
pnpm --filter api build

Migrer un monorepo npm ou Yarn vers pnpm signifie écrire vous-même ce pnpm-workspace.yaml à partir des globs workspaces existants. pnpm import peut convertir l’ancien lockfile pour que la première installation garde les versions que vous aviez déjà.

Quand la structure stricte de pnpm casse une dépendance

La structure qui bloque les dépendances fantômes dans votre code expose aussi les paquets qui comptaient sur l’une d’elles. Un paquet qui importe lodash sans le lister dans ses propres dependencies fonctionne sous npm, parce que lodash se trouve au niveau supérieur, peu importe qui l’y a mis. Sous pnpm, le même require('lodash') lève Cannot find module.

La vraie correction se trouve dans le package.json de ce paquet, donc ouvrez une issue, ou proposez un patch upstream. En attendant, pnpm a des échappatoires. Depuis pnpm 10.6, elles vivent dans pnpm-workspace.yaml ; sur une ancienne version figée, posez les mêmes clés, avec des tirets, dans .npmrc (public-hoist-pattern, shamefully-hoist, node-linker). publicHoistPattern ne remonte que les paquets correspondant à un glob, ce qui convient pour un ou deux cas isolés :

1
2
publicHoistPattern:
  - "lodash"

shamefullyHoist: true remonte tout, proche du layout par défaut de npm. Utilisez-le seulement quand plusieurs paquets sans lien entre eux partent tous du principe qu’il existe une arborescence plate, car vous abandonnez la protection qui justifie l’existence de pnpm. nodeLinker: hoisted va plus loin et supprime carrément les symlinks, pour les outils qui échouent sur les symlinks et non sur l’absence de hoisting. Yarn Berry a le même type de problème sous PnP, et sa réponse est nodeLinker: node-modules dans .yarnrc.yml.

Quel gestionnaire de paquets choisir pour un nouveau projet

Pour un nouveau projet, utilisez pnpm. Une seule copie de chaque version de paquet par machine garde les installations légères sur disque, et la structure stricte fait apparaître une dépendance manquante sur votre machine plutôt que dans une release cassée des mois plus tard. Attendez-vous à ajouter une ou deux entrées à publicHoistPattern la première semaine.

Si vous héritez d’un projet npm qui s’installe sans accroc et que rien ne vous force à changer, restez sur npm. Ajoutez un champ packageManager, lancez corepack enable, et chaque machine exécutera au moins la même version de npm.

Choisissez Yarn Berry quand vous voulez le zero-install avec le cache committé sur git. Sans cela, le coût de compatibilité de PnP ne vous rapporte rien.

Articles associés