NPM - Tout ce que vous n'avez pas compris

illustration de l'article

Cover Photo by Paul Esch-Laurent on Unsplash

NPM

Préambule : Contrairement aux croyances, NPM ne veut pas dire Node Package Manager, c’est une rétroacronymie, même si concrètement, c’est exactement ce qu’il fait 😅. Il est développé et maintenu par la société npm (site officiel), qui s’occupe aussi du registry npm publique.

Vous ne comprenez pas ce qu’il se passe lorsque vous faites un npm install ?
D’où sort le package-lock.json ?
À quoi servent toutes les commandes autres que npm install ou npm run start ?

Eh bien moi non plus je ne comprenais pas ! Mais ne vous inquiétez pas, je vais vous aider à y voir plus clair.

Dans cette série d’articles, je vous propose de faire un tour sur les différentes fonctionnalités de base de NPM.

Partie 1 : Installer un projet et gérer les versions des dépendances

Démarrage d’un projet

La première chose que je fais quand je récupère un projet node, c’est de lancer la commande npm install.

terminal-npm-install-trace

Je vois plein d’informations, et au début, voilà ce que je me disais :

  • Peer dependencies ??? Bah c’est juste WARN osef
  • Vulnerabilities ?? Allez, on va dire que j’ai pas vu
  • Un dossier node_modules ??? Si c’est un dossier ça doit être important, je commit !
  • package-lock.json, deux versions possibles :
    1. Un nouveau fichier ?? Je commit pas, on va me demander ce que c’est.
    2. Comment ça modifié ?? J’y ai pas touché ! Allez git reset.

meme me on my project, using npm and stuff

Décrire son projet : package.json

Pour essayer de mieux comprendre toutes ces informations et alertes, commençons par regarder le fichier qui défini notre projet, le package.json.

{
  "name": "my-project",
  "version": "1.0.0",
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test"
  },
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^5.15.1",
    "@testing-library/react": "^11.2.7",
    "@testing-library/user-event": "^12.8.3",
    "react-scripts": "^4.0.3"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  }
}

Un exemple issu de la génération d’un nouveau projet react

Les champs génériques

Les premières entrées name et version donnent des informations générales sur votre projet. Il en existe plein d’autres que vous pouvez retrouver sur la documentation officielle.

Scripts

Le block scripts contient une série de commandes à utiliser pour faciliter le développement dans votre projet. Chaque commande est définie par un nom et la commande à exécuter.
Pour utiliser un script, il suffit d’utiliser la commande npm run <nom du script>.

Dans tout projet de qualité, vous devriez retrouver la commande npm run test (ou le raccourci npm test).

Dépendances

Les dépendances du runtime

Le block dependencies contient l’ensemble des packages utilisés dans votre projet. Vous pouvez ajouter des dépendances via la commande npm install <package_name>.

Dans notre exemple, nous retrouvons le package react, mais vous pouvez aussi retrouver des packages de composants graphiques (antd, @mui/material, chartjs, …) ou tout autre package utilisé au runtime de votre application (lodash, axios, …).

Les dépendances de développement

Le block devDependencies contient l’ensemble des packages utilisés lors du cycle de développement de votre projet. Vous pouvez ajouter des dépendances via la commande npm install <package_name> -D.
C’est une bonne pratique de séparer les dépendances entre dependencies et devDependencies. Dans le cas d’un package, seule les dependencies seront installées et le poids du package parent en sera allégé.

Dans notre exemple, nous retrouvons les packages react-scripts et @testing-library, qui vous permettent de compiler, tester ou exécuter votre projet. Vous pouvez aussi retrouver d’autres librairies de testing (jest, mocha, chai, cypress, …), des librairies de types dans des projets en typescript (typescript, @types/node, @types/react, …).

D’autres dépendances

Il existe aussi d’autres types de dépendances, que je n’aborderais pas ici par soucis de simplicité. Ces dernières sont peu utilisées dans le développement d’application, mais peuvent servir dans le développement de packages : peerDependencies, bundledDependencies, optionalDependencies. (plus d’info dans la doc officielle).

Versioning

Concernant le versioning des dépendances, c’est la norme semver qui est de rigueur. Ce système de notation vous permet de sélectionner de façon intelligente la version du package à utiliser.

Une version s’écrit sous le format suivant : major.minor.patch

  • major : est incrémenté en cas de breaking change;
  • minor : nouvelle feature, rétrocompatible;
  • patch : bug fix, rétrocompatible.

En général, il est possible de monter de version minor et patch sans risquer un changement de comportement, et en récupérant les correctifs de sécurité et bug, ainsi que les nouvelles fonctionnalités. Un changement dans la version major indique souvent un effort supplémentaire de réécriture de votre code pour s’adapter aux changements apportés.

Il existe plusieurs façons d’écrire un ensemble de versions visées, je vous en détaille quelques-unes :

  • 1.2.3: version figée, on souhaite avoir exactement cette version
  • ^1.2.3: première version non 0 figée. Ici version major figée => 1.2.3, 1.3.5
  • ~1.2.3: version major et minor figées => 1.2.3, 1.2.4, 1.2.5
  • >=1.2.3: toute version supérieure ou égale => 1.2.3, 1.2.5, 2.5.8
  • latest: la dernière version

De manière générale, j’utilise principalement la notation ~1.2.3, qui me permet de bénéficier des nouveautés et des correctifs sans introduire d’incompatibilité.

Je vous invite à tester via ce petit site pour mieux appréhender les différentes notations et visualiser les versions qui correspondent à votre notation.

Plus d’info dans la doc officielle

Le fourzitout de la conf

Il est courant de voir dans le package.json des blocs spécifiques permettant de configurer les outils de développement. En fonctions des outils que vous utilisez, vous avez le choix d’un fichier spécifique à la racine, de variables d’environnement ou d’un bloc dans le package.json.

exemple de config dans un package.json

Un exemple de config dans un package.json

Dans notre cas, la configuration d'eslint a été mise dans le package.json via le bloc eslintConfig, mais il était aussi possible d’avoir un fichier .eslintrc.js à la racine du projet.

Installer un projet : npm install

Maintenant que nous avons éclairci le contenu du package.json, passons à la toute première étape lorsque l’on clone un nouveau projet : npm install.

Cette commande permet de télécharger toutes les dépendances et sous-dépendances du projet, en accord avec les versions définies dans le package.json.

À chaque npm install, une résolution des versions des dépendances est calculée, les sources des dépendances sont téléchargées dans le dossier node_modules (plus de détail ici), et un fichier package-lock.json est généré/mis à jour avec l’arbre des dépendances et leur version résolue.

Une fois cette commande lancée, vous pouvez démarrer votre projet et commencer à coder !

⚠️ Si deux développeurs installent un même projet, il est possible de ne pas avoir le même arbre de dépendances (comme expliqué très brièvement dans la doc).
Il est alors important de mettre en place un mécanisme afin de toujours obtenir le même résultat après installation. Ce qui nous permettra d’éviter des bugs aléatoires non reproductibles, et d’être plus serein quant au déploiement de notre projet.

Garantir un environnement reproductible : package-lock.json

Une petite minute, on parle de résolution de version, package-lock.json, mais ça correspond à quoi tout ça ?

Pour mieux comprendre, prenons l’exemple d’un petit projet, défini par ce package.json :

{
  "name": "my-project",
  "version": "1.0.0",
  "dependencies": {
    "X": "^1.0.1"
  }
}

Évolution de la version dans le temps 😖

Nous avons vu qu’à chaque npm install, les versions des dépendances sont résolues. On risque donc d’avoir des versions différentes en fonction de la date d’installation du projet :

Date Package X
D 1.0.2
D+5 1.0.5
D+X 1.2.1

Dans le monde parfait de semver, cela ne devrait pas poser de problèmes, mais comment éviter que 2 ans plus tard, on soit incapable d’installer le projet ?

Mauvaise idée : Et si on fige la version ? 😕

J’ai longtemps eu la superbe idée de figer la version dans le package.json en utilisant "X": "1.0.2". Après un npm install, j’aurai en effet toujours la même version :

Date Package X
D 1.0.2
D+5 1.0.2
D+X 1.0.2

Mais j’oublie quelque chose d’important ! Et les sous-dépendances alors ?

En considérant "X": "1.0.2" et une sous-dépendance "Y": "^2.0.3" définie dans le package.json de X, après de multiples npm install, on obtient le tableau de version suivant :

Date Package X Package Y
D 1.0.2 2.0.3
D+5 1.0.2 2.0.7
D+X 1.0.2 2.1.4

Bon on fait comment alors ? Je ne vais pas tout figer non plus, j’en aurais pour mille ans ! 🤨

Figer les versions avec le package-lock.json

Ce fichier permet de conserver l’arbre de résolution des dépendances et de leur version.

En considérant :

  • Un package.json avec "X": "^1.0.1", X dépend de "Y": "^2.0.0"
  • Un package-lock.json qui a résolu les versions "X": "1.0.2" et "Y": "2.0.2"
{
  "name": "my-project",
  "version": "1.0.0",
  "lockfileVersion": 2,
  "requires": true,
  "packages": {
    "": {
      "name": "my-project",
      "version": "1.0.0",
      "dependencies": {
        "X": "^1.0.1"
      }
    },
    "node_modules/X": {
      "name": "X",
      "version": "1.0.2",
      "dependencies": {
        "Y": "^2.0.0"
      }
    },
    "node_modules/Y": {
      "name": "Y",
      "version": "2.0.2"
    }
  }
}

package-lock.json généré, plus d’informations sur la structure dans la doc officielle

Je lance ma commande npm install et je me prépare à admirer la puissance de NPM

Date Package X Package Y
D 1.0.2 2.0.3
D+5 1.0.5 2.0.7
D+X 1.2.1 2.1.4

Quoi ?? Toutes mes versions changent ?? Mais je ne comprend plus rien ça sert à rien ce fichier !!? En plus il est modifié à chaque npm install ! 😡

La commande npm ci

Et oui, pour dire à NPM d’installer les dépendances à partir du fichier package-lock.json, il faut utiliser une autre commande : npm ci.

Date Package X Package Y
D 1.0.2 2.0.3
D+5 1.0.2 2.0.3
D+X 1.0.2 2.0.3

Ouf ! On a enfin figé nos versions, si on doit faire un patch dans 2 ans on ne cassera pas tout ! 🤩

Résumé de cette première partie

  1. On utilise semver pour maitriser les versions de nos dépendances;
  2. npm install résout les versions et ajoute/met à jour le package-lock.json;
  3. On utilise npm ci :
    • Dans l’intégration continue pour garantir une pipeline reproductible;
    • En local quand on ne veut pas modifier les versions résolues.
  4. On commit le package-lock.json pour partager la résolution de version avec ses collègues et pour l’intégration continue
  5. On ne commit pas le node_modules, qui est construit par les commandes d’installation

Dans les articles suivants, nous découvrirons des fonctionnalités NPM un peu plus poussées, et je vous présenterais des outils pour booster votre expérience développeur sur Node !

Date

Auteur

Avatar Vivien AUGUY

Vivien AUGUY

Software Crafter

Tags

#NPM #Node