Rode npm install em um projeto, depois pnpm install sobre o mesmo package.json, e você tem duas árvores node_modules diferentes e dois lockfiles diferentes. Acrescente um colega que usa Yarn e o repositório acaba com três lockfiles no histórico, cada um descrevendo uma árvore de dependências ligeiramente diferente. Os três leem o mesmo package.json e baixam do mesmo registro do npm. O que eles não combinam entre si é o que gravar no disco.

Como npm, Yarn e pnpm organizam o node_modules

A estrutura do npm é plana. Todo pacote que suas dependências precisam, não importa quão profundo esteja na árvore, sobe para a node_modules de nível superior sempre que não há conflito de versão. É o comportamento padrão desde o npm 3. O efeito colateral: seu código pode fazer require() ou import de um pacote que você nunca listou no package.json, desde que outra coisa na árvore dependa dele. Funciona até aquele outro pacote abandonar essa dependência em uma minor release, e seu build quebra sem você ter tocado no seu próprio código. Isso é uma dependência fantasma, e a estrutura do npm não faz nada para evitá-la.

O Yarn Classic (v1) sobe as dependências do mesmo jeito, então tem a mesma exposição. O Yarn Berry (v2 em diante) usa Plug’n’Play por padrão, que não tem node_modules nenhuma. As dependências ficam compactadas em .yarn/cache, e um arquivo .pnp.cjs gerado automaticamente mapeia cada import direto para o arquivo certo. Faça commit de .yarn/cache junto com o lockfile e um clone novo roda sem precisar instalar nada: o Yarn chama isso de zero-install. O custo é compatibilidade. Ferramentas que leem a node_modules diretamente, e módulos nativos que esperam arquivos reais no disco, podem falhar no PnP. Definir nodeLinker: node-modules no .yarnrc.yml traz de volta a estrutura plana convencional, abrindo mão do zero-install.

O pnpm mantém um único armazenamento por máquina, indexado por conteúdo. Cada versão de cada pacote mora no disco uma única vez, identificada por um hash do seu conteúdo, independente de quantos projetos a usem. Para cada projeto, o pnpm cria hard links dos arquivos necessários em um armazenamento virtual sob node_modules/.pnpm, e depois cria symlinks só para suas dependências diretas na node_modules de nível superior. Você consegue resolver node_modules/react porque declarou isso. Você não consegue resolver um pacote que só existe dois níveis abaixo, na árvore de outra dependência, porque nada o conecta ao nível superior. O problema da dependência fantasma é resolvido pela própria estrutura, não pela disciplina de quem escreve o código.

Como os comandos de npm, pnpm e Yarn se comparam

No dia a dia, os comandos se correspondem quase um a um. A diferença que importa é a flag para CI: instalar exatamente o que o lockfile diz, não resolver nada, não gravar nada.

AçãonpmpnpmYarn Berry
Instalar tudonpm installpnpm installyarn install
Adicionar uma dependêncianpm install axiospnpm add axiosyarn add axios
Adicionar uma dev dependencynpm install -D vitestpnpm add -D vitestyarn add -D vitest
Executar um scriptnpm run buildpnpm run buildyarn run build
Instalação segura para CInpm cipnpm install --frozen-lockfileyarn install --immutable

npm ci apaga a node_modules primeiro e instala rigorosamente a partir do package-lock.json, falhando se o lockfile e o package.json não baterem. --frozen-lockfile e --immutable pulam a exclusão mas impõem a mesma regra: nenhuma resolução nova, nenhuma gravação do lockfile, saída diferente de zero se o lockfile não puder ser satisfeito como está. Use a forma segura para CI em todo pipeline e a instalação normal na sua própria máquina. Um lockfile desatualizado deve fazer o build falhar ruidosamente, não ser resolvido em silêncio.

Se você instala dependências dentro de uma imagem Docker, Boas Práticas de Dockerfile: Builds Menores e Mais Rápidas explica como copiar o manifesto e o lockfile e rodar a instalação antes do COPY . ., para que essa camada só seja reconstruída quando o lockfile mudar.

Por que um repositório deveria fazer commit de apenas um lockfile

O npm grava package-lock.json, o pnpm grava pnpm-lock.yaml, e as duas gerações do Yarn gravam yarn.lock (em formatos que diferem entre v1 e Berry). Cada um registra a versão exata de cada dependência transitiva. Nenhum lê o do outro.

A quebra acontece assim. Alguém roda yarn add lodash em um repositório que até então só tinha commitado package-lock.json. Agora yarn.lock também existe, os dois arquivos discordam sobre o que foi resolvido, e a ferramenta que a CI roda por acaso é a que prevalece. A próxima instalação local com a outra ferramenta resolve algo diferente de novo.

Escolha um único gerenciador de pacotes por repositório, faça commit só do lockfile dele, e ignore os outros antes que alguém os gere por acidente:

1
2
3
# se este repositório usa npm
yarn.lock
pnpm-lock.yaml

Como fixar o gerenciador de pacotes com corepack

Um .gitignore não impede ninguém de rodar a ferramenta errada se ela estiver no PATH. O corepack impede. Ele lê o campo packageManager no package.json e roda exatamente aquela versão do npm, Yarn ou pnpm, baixando-a no primeiro uso:

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

Rode corepack enable uma vez para colocar os shims do Yarn e do pnpm no seu PATH (o shim do npm só é instalado se você pedir explicitamente). A partir daí, pnpm install neste repositório roda a versão fixada 10.12.1 em cada máquina, e ninguém precisa lembrar de checar.

O Corepack vem junto com o Node da versão 14.19.0 até a 25.0.0 (não incluída). No Node 25 em diante, instale-o com npm install -g corepack, e então rode corepack enable como antes. Na CI, a opção cache do actions/setup-node ('npm', 'yarn' ou 'pnpm') funciona junto com o corepack; Como Fazer Cache de Dependências no GitHub Actions explica as chaves de cache por trás disso.

Como os workspaces diferem entre npm, Yarn e pnpm

Os três lidam com um monorepo de pacotes locais ligados entre si. A diferença está em onde você declara os membros e como você aponta para um deles.

O npm e o Yarn leem um array workspaces do package.json na raiz:

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

Instale uma vez na raiz e os pacotes locais ficam ligados entre si dentro da node_modules. Para rodar um script em um pacote, o npm pede um caminho:

1
npm run build --workspace=packages/api

O Yarn pede o nome do pacote:

1
yarn workspace api run build

O pnpm ignora o campo workspaces e lê a lista de pacotes do seu próprio pnpm-workspace.yaml:

1
2
packages:
  - "packages/*"

Ele aponta para pacotes pelo nome com --filter:

1
pnpm --filter api build

Migrar um monorepo npm ou Yarn para o pnpm significa escrever esse pnpm-workspace.yaml você mesmo, a partir dos globs de workspaces já existentes. O pnpm import consegue converter o lockfile antigo para que a primeira instalação mantenha as versões que você já tinha.

Quando a estrutura rígida do pnpm quebra uma dependência

A estrutura que bloqueia dependências fantasma no seu código também expõe pacotes que contavam com uma delas. Um pacote que importa lodash sem listá-lo nas próprias dependencies funciona no npm, porque o lodash fica no nível superior, não importa quem o colocou lá. No pnpm, o mesmo require('lodash') lança Cannot find module.

A correção de verdade pertence ao package.json daquele pacote, então abra uma issue ou um patch upstream. Enquanto isso, o pnpm tem válvulas de escape. A partir do pnpm 10.6 elas moram em pnpm-workspace.yaml; em uma versão mais antiga fixada, defina as mesmas chaves, com hífen, no .npmrc (public-hoist-pattern, shamefully-hoist, node-linker). O publicHoistPattern sobe só os pacotes que correspondem a um glob, o que serve bem para um ou dois casos isolados:

1
2
publicHoistPattern:
  - "lodash"

shamefullyHoist: true sobe tudo, próximo do layout padrão do npm. Use isso só quando vários pacotes sem relação entre si assumirem uma árvore plana, porque você abre mão da proteção que é a razão de existir do pnpm. nodeLinker: hoisted vai além e remove os symlinks completamente, para ferramentas que falham nos symlinks e não na falta de hoisting. O Yarn Berry tem o mesmo tipo de problema no PnP, e sua resposta é nodeLinker: node-modules no .yarnrc.yml.

Qual gerenciador de pacotes usar em um projeto novo

Para um projeto novo, use pnpm. Uma única cópia de cada versão de pacote por máquina mantém as instalações pequenas no disco, e a estrutura rígida faz uma dependência faltando aparecer no seu notebook em vez de em um release quebrado meses depois. Espere adicionar uma ou duas entradas ao publicHoistPattern na primeira semana.

Se você herdou um projeto npm que instala sem problemas e nada está te forçando a mudar, continue no npm. Adicione um campo packageManager, rode corepack enable, e cada máquina vai rodar pelo menos a mesma versão do npm.

Escolha o Yarn Berry quando quiser zero-install com o cache commitado no git. Sem isso, o custo de compatibilidade do PnP não te traz nada.

Artigos relacionados