Usando Pacotes npm Diretamente do Navegador
Use pacotes npm em HTML simples com import maps e URLs de CDN. Veja como escolher ESM ou CommonJS, fixar versões e evitar o build.
Você pode usar um pacote npm em uma página HTML simples, sem bundler, sem node_modules e sem arquivo de configuração, declarando um import map que aponta um bare specifier para uma URL de CDN que serve esse pacote como um módulo ES.
Uma página, uma biblioteca, uma interação muitas vezes não justifica um projeto Vite, com seu dev server, seu diretório de saída de build e toda a história de deploy. A parte que costuma dar errado raramente é a sintaxe do import map: pacotes npm são distribuídos em três formatos de módulo diferentes, e apenas dois deles rodam em um navegador. Este artigo cobre como identificar qual formato você tem, as duas maneiras de carregá-lo a partir de uma CDN, e por que uma URL sem versão fixada é um bug de correção, e não uma preferência de estilo.
Principais Conclusões
- Um import map é um bloco JSON dentro de uma tag
<script type="importmap">que informa ao navegador para qual URL um bare specifier comocanvas-confettideve ser resolvido — exatamente o mesmo trabalho que um bundler faz em tempo de build, agora movido para dentro da página. - Um import map não consegue salvar um pacote exclusivamente CommonJS, porque um map muda como um specifier é resolvido, e não o formato em que o arquivo foi escrito.
- O MDN classifica import maps como Baseline Widely available, com suporte nos navegadores desde março de 2023.
- Fixe uma versão exata em cada URL de CDN no map, ou o código que sua página executa pode mudar sem deploy e sem commit.
- Pular a etapa de build significa não ter tree shaking, então você entrega tudo o que o pacote contém, e não apenas as partes que você usa.
Quando Você Deve Pular a Etapa de Build?
Pule a etapa de build quando o custo de mantê-la sobreviver à coisa que ela constrói. Isso abrange uma demo no estilo CodePen, um único widget interativo inserido em um template WordPress ou em uma view Rails, um dashboard interno usado por duas pessoas, e qualquer protótipo cuja vida útil seja medida em dias. O critério não é tamanho, mas propriedade: se ninguém vai atualizar o toolchain daqui a seis meses, um toolchain é um passivo. Qualquer coisa que você espera que cresça, que vá para tráfego real ou que seja entregue a um time ainda pertence a um bundler.
Três Tipos de Arquivo, Dois dos Quais Rodam em um Navegador
Um pacote npm é distribuído em um de três formatos de módulo, e apenas dois deles rodam em um navegador, então descubra qual build o pacote entrega antes de escrever qualquer import map. Um arquivo clássico ou UMD funciona em um <script src> simples e atribui uma variável global. Um módulo ES precisa de type="module" e de instruções import. Um build CommonJS, escrito com require() e module.exports, não executa em um navegador de jeito nenhum.
A maneira mais rápida de descobrir é instalar o pacote e lê-lo:
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
Observe duas coisas nessa saída: as extensões dos arquivos no pacote e os campos de entry point. A documentação de packages do Node define main, exports e type; module é uma convenção do ecossistema que bundlers e CDNs leem, e não um campo especificado pelo Node. Alguns pacotes também trazem um campo jsdelivr ou unpkg indicando um build pronto para o navegador. Por exemplo, canvas-confetti@1.9.4 declara "main": "src/confetti.js", "module": "dist/confetti.module.mjs" e "jsdelivr": "dist/confetti.browser.js" em seu package.json, o que indica que existem tanto um build para navegador quanto um build em módulo ES.
| Formato | Como reconhecê-lo | O que o navegador precisa | Sem etapa de build |
|---|---|---|---|
| Clássico / UMD | .umd.js, um dist/*.browser.js, ou código-fonte atribuindo a window | Nada especial | <script src>, e então usar a variável global |
| Módulo ES | .mjs, import/export no código-fonte, "type": "module" | type="module" | Import map mais um script de módulo |
| CommonJS | .cjs, require(), module.exports, "type": "commonjs" | Conversão primeiro | Uma CDN que transpila para ESM, ou uma etapa de build |
Essa última linha é onde a maioria das tentativas falha silenciosamente. Um import map não consegue salvar um pacote exclusivamente CommonJS, porque um map muda como um specifier é resolvido, e não o formato em que o arquivo foi escrito.
A Abordagem Simples: Uma Tag Script a Partir de uma CDN
Se o pacote entrega um build clássico ou UMD, uma única tag script é toda a integração. O nome da variável global é escolhido pelo autor do pacote, não por você, então confira o README: o README do canvas-confetti diz que seu build de CDN coloca uma função confetti em window.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Se o pacote entrega ESM ou CommonJS, a CDN faz a conversão no lugar. Uma requisição ao endpoint /+esm do jsDelivr retorna um módulo ES pronto para o navegador, e o jsDelivr descreve isso como bem mais do que uma simples troca de sintaxe: ele descobre o entry point correto a partir dos próprios campos do pacote, converte CommonJS quando necessário, traz as dependências para dentro da resposta, e remove código desnecessário e minifica o que retorna. O esm.sh faz o trabalho equivalente sob a gramática de URL https://esm.sh/PKG[@SEMVER][/PATH]. Qualquer um dos dois te dá uma URL que você pode colocar diretamente em uma instrução import.
A Abordagem Melhor: Uma Tag Script do Tipo importmap
Um import map é um bloco JSON dentro de uma tag <script type="importmap"> que mapeia bare specifiers para URLs, de modo que seu código de módulo fica exatamente como ficaria dentro de um bundler.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
A diferença que isso traz é uma linha. Sem o map, cada arquivo que precisa da biblioteca repete a URL da CDN e a versão:
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
Com o map, a versão fica em exatamente um lugar e a instrução import é portável, sem alterações, para um projeto com bundler.
Quatro regras importam na prática. Primeira, a ordem decide se o map funciona ou não: o navegador precisa lê-lo antes de encontrar qualquer script de módulo que importe através dele, então o bloco <script type="importmap"> vai acima desse código. Segunda, o padrão HTML permite que um documento contenha mais de um map e especifica como eles são mesclados, mas o suporte dos engines a isso não é uniforme, então escreva um map por documento. Terceira, valores relativos devem começar com /, ./ ou ../. Quarta, uma barra final em ambos os lados de um mapeamento mapeia um diretório inteiro do pacote em vez de um único entry point:
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
O MDN classifica import maps como Baseline Widely available, presentes nos navegadores desde março de 2023, então um polyfill não faz mais parte de uma configuração normal. Se mesmo assim você quiser uma verificação em tempo de execução, HTMLScriptElement.supports() oferece uma, usada como HTMLScriptElement.supports?.("importmap").
Uma pegadinha sem mensagem de erro associada: módulos ES são buscados sob regras de CORS, então abrir o arquivo HTML direto do disco falha, mesmo que o arquivo idêntico funcione no momento em que um servidor local o entrega a você.
Fixe a Versão, Sempre
Fixe uma versão exata em cada URL de CDN no map. Uma URL sem versão fixada ou baseada em intervalo significa que o código que sua página executa pode mudar sem deploy, sem commit e sem nada no seu repositório que explique a diferença. O comportamento da página em produção passa a ser função do relógio da CDN em vez do seu histórico git, o que transforma um relatório de bug rotineiro em um exercício de arqueologia: o HTML está inalterado, os logs do servidor estão inalterados, e o JavaScript está diferente.
Esta é a única regra em que não há vantagem alguma em quebrar. canvas-confetti@1.9.4 é um fato sobre o qual você pode raciocinar; canvas-confetti@latest é uma promessa que outra pessoa mantém.
Do Que Você Abre Mão?
Carregar pacotes de uma CDN entrega a uma origem de terceiros a capacidade de executar script arbitrário no contexto da sua página. Você pode restringir isso com uma CSP e com subresource integrity: o MDN observa que o objeto JSON do import map aceita uma chave integrity ao lado de imports e scopes, mapeando URLs de módulos para hashes SRI como sha384-…. Se você preferir controlar inteiramente o caminho de entrega, servir seus próprios assets é uma configuração diferente, coberta em os papéis das CDNs na performance de frontend e em uma comparação de plataformas de CDN.
Outros três custos vêm junto com o território. Não há tree shaking, então você entrega tudo o que o pacote contém em vez das partes que você usa, o que é uma troca justa para uma demo e ruim para uma aplicação que você espera que cresça. Um grafo de dependências profundo resolvido em tempo de execução significa que o navegador descobre cada módulo apenas depois de buscar seu pai, e é por isso que as CDNs intervêm: o esm.sh agrupa os submódulos de um pacote na resposta por padrão, retendo apenas aqueles compartilhados pelos entry points que seu campo exports declara, e ?bundle=false desativa esse comportamento. E o modo de falha é silencioso: o documento é parseado, o layout está completo, e um módulo simplesmente nunca chega porque um proxy, uma extensão ou uma regra de CSP bloqueou a origem — que é a classe de bug que o session replay revela mais rápido do que um relatório de erro, já que nada chegou a ser lançado.
Para qualquer coisa substancial em produção, use um bundler. Esta técnica é para as coisas que não justificam um.
Comece lendo o pacote antes de escrever uma linha de HTML: liste os arquivos, leia main, module, exports e type, e decida a partir disso se você precisa de uma tag script, de um import map ou, afinal, de uma etapa de build.
Perguntas Frequentes
Posso manter o import map em um arquivo JSON separado em vez de inline no HTML?
Não. A especificação proíbe que um elemento script do tipo importmap tenha um atributo src, junto com async, nomodule, defer, crossorigin, integrity e referrerpolicy, então o JSON precisa ficar dentro do documento. Se o map for gerado, renderize-o na página no lado do servidor em vez de vinculá-lo, e mantenha-o acima do primeiro script de módulo.
Como carrego duas versões diferentes do mesmo pacote em uma página?
Use a chave scopes. Um scope associa um segundo mapa de specifiers a um caminho de URL, de modo que scripts carregados a partir desse caminho podem resolver um pacote para uma versão fixada enquanto o resto da página o resolve para outra. Quando dois scopes coincidem, o caminho mais longo é verificado primeiro, e o mapa imports é o fallback. A alternativa mais simples é dar a cada versão seu próprio bare specifier.
Import maps se aplicam a web workers ou ao atributo src de uma tag script?
Não. Um map só reescreve specifiers em instruções import e chamadas import() no próprio documento. A URL no atributo src de uma tag script nunca passa por ele, e tampouco qualquer coisa carregada dentro de um worker ou worklet. Um import dinâmico dentro de um módulo do documento é resolvido através do map, mas o script de entrada de um worker e seus próprios imports precisam de URLs completas.
O que acontece se um bare specifier não estiver no import map?
A resolução lança um TypeError antes de o módulo rodar, e os dois engines redigem a mensagem de forma diferente. O Chrome informa que falhou ao resolver o module specifier, nomeia o specifier e acrescenta que referências relativas devem começar com /, ./ ou ../ (cada um desses três aparece entre aspas na mensagem real). O Firefox informa: The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. Nada no código da sua aplicação lança exceção, então a página renderiza normalmente e apenas o recurso apoiado por aquele módulo fica morto.