Performant npm: il gestore di pacchetti che ripensa node_modules
-
- Sviluppo |
- Settembre 2026 |
- 05 Min di lettura
La scelta del gestore di pacchetti è una delle poche decisioni tecniche che quasi nessun team prende in modo consapevole. Si eredita dal template usato per inizializzare il progetto, dal tutorial seguito il primo giorno oppure semplicemente dal fatto che npm arriva già installato insieme a Node e nessuno ha motivo di cercare altro. Poi il progetto cresce, le dipendenze si moltiplicano, la cartella node_modules diventa la struttura più pesante presente sul disco e ogni installazione pulita in integrazione continua consuma minuti che ormai vengono considerati inevitabili. pnpm nasce esattamente da questa insoddisfazione, e la sua premessa è che gran parte di quel costo non sia intrinseca all’ecosistema JavaScript ma dipenda da come i pacchetti vengono materializzati sul filesystem.
Il nome sta per performant npm e la descrizione che se ne trova più spesso, un gestore veloce ed efficiente nell’uso dello spazio su disco, restituisce solo la metà meno interessante della storia. La velocità è una conseguenza, non un obiettivo perseguito con micro ottimizzazioni. Dietro ci sono due decisioni di progetto che si sostengono a vicenda, uno store globale condiviso tra tutti i progetti della macchina e una struttura di node_modules che rifiuta di appiattire l’albero delle dipendenze. La prima decide quanto spazio e quanto tempo costa installare, la seconda decide che cosa il codice può importare, ed è quella che cambia davvero il modo di lavorare.
Lo store globale e gli hard link
Con npm ogni progetto riceve la propria copia fisica di ogni pacchetto, così che dieci progetti che usano la stessa versione di una libreria pagano dieci volte lo stesso contenuto. pnpm mantiene invece un unico store, indirizzato per contenuto, in cui ogni file viene salvato una sola volta e identificato dal proprio hash. Quando un progetto dichiara una dipendenza, pnpm non copia nulla ma crea degli hard link verso i file già presenti nello store, quindi lo stesso blocco su disco viene referenziato da tutte le installazioni che ne hanno bisogno e occupa spazio una volta sola.
Il fatto che l’indirizzamento avvenga a livello di file e non di pacchetto ha un effetto secondario notevole, perché aggiornare una libreria alla versione successiva comporta lo scaricamento dei soli file effettivamente cambiati mentre tutto il resto viene collegato a quello che c’era già. Installare diventa in buona parte un’operazione di collegamento sul filesystem invece che di copia e decompressione, ed è da qui che arriva la differenza di tempi rispetto agli altri gestori. L’unico vincolo da conoscere è che gli hard link funzionano all’interno dello stesso volume, quindi se lo store risiede su un disco diverso da quello del progetto pnpm ripiega sulla copia e il vantaggio si riduce.
La struttura non piatta di node_modules
La seconda decisione è quella che genera più discussioni. npm e Yarn nella sua versione classica appiattiscono l’albero delle dipendenze, sollevando i pacchetti transitivi al primo livello di node_modules per evitare duplicazioni. La conseguenza è che qualunque libreria installata da qualcun altro nella catena diventa importabile dal codice applicativo anche se non compare in package.json. Sono le cosiddette dipendenze fantasma, e il loro problema non è teorico, perché il giorno in cui una dipendenza intermedia cambia versione o smette di usare quella libreria il progetto smette di compilare senza che nessuno abbia toccato il proprio codice.
# In a pnpm project, only declared dependencies are visible at the top level
node_modules/
express -> .pnpm/express@4.19.2/node_modules/express
.pnpm/
express@4.19.2/node_modules/express
body-parser@1.20.2/node_modules/body-parser
pnpm rende impossibile quella scorciatoia. Al primo livello di node_modules compaiono soltanto i pacchetti dichiarati esplicitamente, sotto forma di collegamenti simbolici che puntano a uno store virtuale interno dove risiede l’albero reale con tutte le versioni risolte. Importare qualcosa che non è stato dichiarato produce un errore immediato, in fase di sviluppo, invece di un guasto differito in produzione.
I comandi di ogni giorno e la migrazione
L’interfaccia è volutamente familiare, al punto che la curva di apprendimento si esaurisce nel giro di una mattinata. Il comando pnpm install ricostruisce le dipendenze a partire dal lockfile, pnpm add aggiunge un pacchetto, pnpm remove lo elimina e pnpm run esegue uno script definito in package.json. Vale la pena conoscere anche pnpm dlx, che esegue un pacchetto senza installarlo come farebbe npx, e pnpm why, che spiega quale dipendenza sta trascinando dentro una certa libreria.
La migrazione di un progetto esistente è meno invasiva di quanto si tema. Il comando pnpm import legge il file package-lock.json già presente e ne deriva un pnpm-lock.yaml equivalente, preservando le versioni esatte già risolte, dopo di che si rimuovono il vecchio lockfile e la cartella node_modules e si reinstalla da zero. Conviene poi fissare la versione del gestore nel campo packageManager di package.json e attivare corepack, in modo che ogni sviluppatore e la pipeline di build usino esattamente lo stesso binario.
# Enable the version pinned in the packageManager field
corepack enable
# Derive a pnpm lockfile from an existing package-lock.json
pnpm import
# Add a dependency to a single package of the workspace
pnpm add zod --filter @app/api
I workspace e i monorepo
Il supporto ai monorepo è nativo e non richiede strumenti aggiuntivi. Un file pnpm-workspace.yaml alla radice elenca i percorsi dei pacchetti che compongono il repository, e da quel momento ognuno conserva il proprio package.json e il proprio node_modules senza che le dipendenze vengano sollevate. Ne deriva la stessa proprietà vista prima applicata su scala più grande, dato che un pacchetto non può usare per sbaglio una libreria dichiarata da quello accanto. Il protocollo workspace permette di riferirsi a un pacchetto interno senza passare dal registro pubblico, e in fase di pubblicazione il riferimento viene sostituito con il numero di versione reale. L’opzione filter consente infine di eseguire uno script su un sottoinsieme del repository, includendo su richiesta anche i pacchetti che dipendono da quello selezionato.
Dove pnpm crea attrito
Sarebbe disonesto presentarlo come una sostituzione priva di costi. Una parte dell’ecosistema dà per scontata la struttura piatta di node_modules, e strumenti legacy, alcuni bundler e certe configurazioni di React Native possono non risolvere i moduli come si aspettano. pnpm mette a disposizione delle vie di fuga graduali, dalla possibilità di sollevare pubblicamente solo i pacchetti indicati da un pattern fino a un linker che ricrea l’intera struttura appiattita mantenendo però lo store condiviso. Sono compromessi accettabili quando servono, purché restino l’eccezione e non la configurazione predefinita.
Il costo di migrazione, per un progetto sano, si misura in ore, mentre il beneficio è strutturale. Si recupera spazio su disco, si accorciano i tempi di installazione in integrazione continua e soprattutto si ottiene un grafo delle dipendenze che corrisponde a quello dichiarato. Il gestore di pacchetti non è una preferenza di gusto ma il livello che decide che cosa il codice può importare, e vale la pena sceglierlo con la stessa attenzione dedicata a qualunque altra scelta di architettura.
Per saperne di più: https://pnpm.io/