Ejecuta npm install en un proyecto, luego pnpm install sobre el mismo package.json, y obtienes dos árboles node_modules distintos y dos lockfiles distintos. Añade un compañero que usa Yarn y el repositorio termina con tres lockfiles en su historial, cada uno describiendo un árbol de dependencias ligeramente diferente. Los tres leen el mismo package.json y descargan del mismo registro de npm. En lo que no se ponen de acuerdo es en qué escribir en disco.
Cómo npm, Yarn y pnpm organizan node_modules
La estructura de npm es plana. Cada paquete que necesitan tus dependencias, por muy profundo que esté en el árbol, se eleva a la node_modules de nivel superior siempre que no haya conflicto de versiones. Es el comportamiento por defecto desde npm 3. El efecto secundario: tu código puede hacer require() o import de un paquete que nunca listaste en package.json, siempre que algo más en el árbol dependa de él. Funciona hasta que ese otro paquete deja de depender de él en una minor release y tu build se rompe sin que hayas tocado tu propio código. Eso es una dependencia fantasma, y la estructura de npm no hace nada por evitarla.
Yarn Classic (v1) eleva las dependencias del mismo modo, así que está expuesto al mismo problema. Yarn Berry (v2 y posteriores) usa por defecto Plug’n’Play, que no tiene node_modules en absoluto. Las dependencias se quedan comprimidas en .yarn/cache, y un archivo .pnp.cjs generado automáticamente mapea cada import directamente al archivo correcto. Si haces commit de .yarn/cache junto al lockfile, un clon nuevo funciona sin necesidad de instalar nada: Yarn lo llama zero-install. El coste es la compatibilidad. Las herramientas que leen node_modules directamente, y los módulos nativos que esperan archivos reales en disco, pueden fallar bajo PnP. Poner nodeLinker: node-modules en .yarnrc.yml recupera una estructura plana convencional, renunciando al zero-install.
pnpm mantiene un único almacén por máquina, indexado por contenido. Cada versión de cada paquete vive en disco una sola vez, identificada por un hash de su contenido, sin importar cuántos proyectos la usen. Para cada proyecto, pnpm crea hard links de los archivos necesarios en un almacén virtual bajo node_modules/.pnpm, y luego crea symlinks solo para tus dependencias directas en la node_modules de nivel superior. Puedes resolver node_modules/react porque lo declaraste. No puedes resolver un paquete que solo existe dos niveles más abajo, en el árbol de otra dependencia, porque nada lo conecta con el nivel superior. El problema de las dependencias fantasma queda cerrado por la propia estructura, no por la disciplina de quien escribe el código.
Qué cambia en los comandos entre npm, pnpm y Yarn
En el día a día los comandos coinciden casi uno a uno. La diferencia que importa es el flag para CI: instala exactamente lo que dice el lockfile, no resuelvas nada, no escribas nada.
| Acción | npm | pnpm | Yarn Berry |
|---|---|---|---|
| Instalar todo | npm install | pnpm install | yarn install |
| Añadir una dependencia | npm install axios | pnpm add axios | yarn add axios |
| Añadir una dev dependency | npm install -D vitest | pnpm add -D vitest | yarn add -D vitest |
| Ejecutar un script | npm run build | pnpm run build | yarn run build |
| Instalación segura para CI | npm ci | pnpm install --frozen-lockfile | yarn install --immutable |
npm ci borra primero node_modules e instala estrictamente desde package-lock.json; falla si el lockfile y package.json no coinciden. --frozen-lockfile e --immutable se saltan el borrado pero imponen la misma regla: ninguna resolución nueva, ninguna escritura del lockfile, salida distinta de cero si el lockfile no se puede satisfacer tal cual está. Usa la forma segura para CI en cada pipeline y la instalación normal en tu propia máquina. Un lockfile desactualizado debe hacer fallar el build de forma ruidosa, no resolverse en silencio.
Si instalas las dependencias dentro de una imagen Docker, Buenas Prácticas de Dockerfile: Builds Rápidas y Ligeras explica cómo copiar el manifiesto y el lockfile y ejecutar la instalación antes de COPY . ., para que esa capa solo se reconstruya cuando cambie el lockfile.
Por qué un repositorio debería hacer commit de un solo lockfile
npm escribe package-lock.json, pnpm escribe pnpm-lock.yaml, y ambas generaciones de Yarn escriben yarn.lock (en formatos que difieren entre v1 y Berry). Cada uno registra la versión exacta de cada dependencia transitiva. Ninguno lee el de otro.
La rotura ocurre así. Alguien ejecuta yarn add lodash en un repositorio que hasta entonces solo había hecho commit de package-lock.json. Ahora yarn.lock también existe, los dos archivos no coinciden sobre qué está resuelto, y la herramienta que la CI ejecute por casualidad es la que gana. La siguiente instalación local con la otra herramienta vuelve a resolver algo distinto.
Elige un único gestor de paquetes por repositorio, haz commit solo de su lockfile, e ignora los demás antes de que alguien los genere por accidente:
| |
Cómo fijar el gestor de paquetes con corepack
Un .gitignore no impide que alguien ejecute la herramienta equivocada si la tiene en su PATH. corepack sí. Lee el campo packageManager en package.json y ejecuta exactamente esa versión de npm, Yarn o pnpm, descargándola en el primer uso:
| |
Ejecuta corepack enable una vez para poner en tu PATH los shims de Yarn y pnpm (el shim de npm solo se instala si lo pides explícitamente). A partir de ahí, pnpm install en este repositorio ejecuta la versión fijada 10.12.1 en cada máquina, y nadie tiene que acordarse de comprobarlo.
Corepack viene incluido con Node desde la versión 14.19.0 hasta la 25.0.0 (sin incluir esta última). En Node 25 y posteriores, instálalo con npm install -g corepack, y luego ejecuta corepack enable como antes. En CI, la opción cache de actions/setup-node ('npm', 'yarn' o 'pnpm') funciona junto a corepack; Caché de Dependencias en GitHub Actions explica las claves de caché que hay detrás.
Cómo difieren los workspaces en npm, Yarn y pnpm
Los tres gestionan un monorepo de paquetes locales enlazados entre sí. Difieren en dónde declaras los miembros y cómo apuntas a uno concreto.
npm y Yarn leen un array workspaces del package.json raíz:
| |
Instala una vez en la raíz y los paquetes locales quedan enlazados en la node_modules de cada uno. Para ejecutar un script en un paquete, npm pide una ruta:
| |
Yarn pide el nombre del paquete:
| |
pnpm ignora el campo workspaces y lee la pertenencia desde su propio pnpm-workspace.yaml:
| |
Apunta a los paquetes por nombre con --filter:
| |
Migrar un monorepo de npm o Yarn a pnpm significa escribir tú mismo ese pnpm-workspace.yaml a partir de los globs de workspaces que ya tenías. pnpm import puede convertir el lockfile anterior para que la primera instalación mantenga las versiones que ya tenías.
Cuándo la estructura estricta de pnpm rompe una dependencia
La estructura que bloquea las dependencias fantasma en tu código también expone a los paquetes que dependían de una. Un paquete que importa lodash sin listarlo en sus propias dependencies funciona bajo npm, porque lodash está en el nivel superior sin importar quién lo puso ahí. Bajo pnpm, el mismo require('lodash') lanza Cannot find module.
La solución real está en el package.json de ese paquete, así que abre un issue o un parche upstream. Mientras esperas, pnpm tiene vías de escape. Desde pnpm 10.6 en adelante, esas vías de escape están en pnpm-workspace.yaml; si tienes fijada una versión más antigua, pon las mismas claves, con guiones, en .npmrc (public-hoist-pattern, shamefully-hoist, node-linker). publicHoistPattern eleva solo los paquetes que coinciden con un glob, lo que quieres para uno o dos casos puntuales:
| |
shamefullyHoist: true eleva todo, muy parecido a la estructura por defecto de npm. Úsalo solo cuando varios paquetes no relacionados asuman un árbol plano, porque renuncias a la protección que es la razón de ser de pnpm. nodeLinker: hoisted va más allá y elimina los symlinks por completo, para herramientas que fallan por los symlinks y no por la falta de hoisting. Yarn Berry tiene el mismo tipo de problema bajo PnP, y su respuesta es nodeLinker: node-modules en .yarnrc.yml.
Qué gestor de paquetes usar en un proyecto nuevo
Para un proyecto nuevo, usa pnpm. Una sola copia de cada versión de paquete por máquina mantiene las instalaciones pequeñas en disco, y la estructura estricta hace que una dependencia que falta aparezca en tu portátil en vez de en una release rota meses después. Espera añadir una o dos entradas a publicHoistPattern en la primera semana.
Si heredas un proyecto de npm que se instala limpiamente y nada te obliga a cambiar, quédate en npm. Añade un campo packageManager, ejecuta corepack enable, y cada máquina ejecutará al menos la misma versión de npm.
Elige Yarn Berry cuando quieras zero-install con la caché incluida en git. Sin eso, el coste de compatibilidad de PnP no te compensa.