Módulos, npm e o bundler
Pule esta página se você já sabe…
- a diferença entre
import/export(ESM) erequire(CommonJS); - o que
dependencies,devDependenciesepeerDependenciessignificam; - o que tree-shaking consegue provar e o que ele não consegue;
- por que um
importde uma coisa só pode arrastar oito quilobytes.
O problema
Você importa uma função:
import { cn } from "tempest-react-sdk";
E o bundle cresce 8 KB. Você não usou componente nenhum, não montou provider nenhum, e mesmo assim pagou por coisas que nem sabe que existem.
O motivo não é o pacote ser grande — é o bundler não conseguir provar que o resto é descartável. Esta página é sobre o que ele precisa para provar.
Módulos ES
Um módulo é um arquivo com escopo próprio. O que sai é o que você exporta:
// math.ts
export function somar(a: number, b: number): number {
return a + b;
}
export const PI = 3.14159;
// app.ts
import { somar, PI } from "./math";
console.log(somar(PI, 1));
| Forma | O que faz |
|---|---|
export function x() |
Export nomeado. É o que o SDK usa, sempre. |
export default x |
Export padrão, um por arquivo. O nome na importação é livre. |
import { x } from "..." |
Importa um nomeado. |
import * as tudo from "..." |
Importa o namespace inteiro. Derruba o tree-shaking. |
import type { X } from "..." |
Importa só o tipo — some do bundle, não existe em runtime. |
await import("./x") |
Import dinâmico: vira um chunk separado, carregado sob demanda. |
ESM é estático, e é isso que permite a otimização
import só existe no topo do arquivo, com caminho literal. Isso deixa o
bundler ler o grafo de dependências sem executar nada — e é o que torna
tree-shaking possível. require() do CommonJS é uma chamada de função comum,
que pode estar dentro de um if; não dá para analisar da mesma forma.
npm: os três tipos de dependência
{
"dependencies": { "react-hook-form": "^7.76.0" },
"devDependencies": { "vitest": "^4.0.0" },
"peerDependencies": { "react": "^18 || ^19" }
}
| Campo | Quem instala | Para quê |
|---|---|---|
dependencies |
Vem junto quando alguém instala você | Código que roda em produção. |
devDependencies |
Só quem clona o repo | Teste, lint, build. Não vai para o consumidor. |
peerDependencies |
O app instala, você só declara | Biblioteca que precisa existir uma vez só no app inteiro. |
Peer dependency não é sobre bytes — é sobre correção
Duas cópias de zod custam kilobytes. Duas cópias de React ou de
react-router custam funcionamento: cada cópia tem o próprio contexto, e o
componente que consome o contexto da cópia B não enxerga o provider montado
pela cópia A. O sintoma clássico é
useNavigate() may be used only in the context of a <Router>.
Por isso o tempest-react-sdk declara react, react-dom e react-router
como peers e todo o resto como dependência direta: o critério é carregar
contexto React, não popularidade.
Tree-shaking, e o que ele precisa
Tree-shaking é o bundler removendo o que ninguém importou. Ele consegue provar isso quando três coisas valem:
- O módulo é ESM (grafo estático).
- O import é nomeado —
import { cn }, nãoimport * as sdk. - Não há efeito colateral no módulo — nada roda só por ele ter sido carregado.
O item 3 é o que o campo sideEffects do package.json declara:
{ "sideEffects": ["**/*.css"] }
Isso diz: "todo arquivo deste pacote é livre de efeito colateral, exceto os
.css" — e os .css precisam ficar de fora porque importar um arquivo de estilo
é o efeito colateral desejado. Sem essa exceção o bundler removeria os estilos.
O que quebra o tree-shaking no seu próprio código
import * as X from "..."— o bundler não sabe qual membro você usa.- Código de topo de módulo com efeito (
window.algo = ..., registrar listener). - Reexportar tudo por um barrel que executa algo ao ser importado.
Onde isso aparece no SDK
O tempest-react-sdk publica o dist/ com o grafo de módulos preservado: um
arquivo de saída por módulo de origem, em vez de um bundle por entrada.
A diferença é mensurável. Com bundle por entrada, importar só cn arrastava ~8,5 KB
gzip — o bundler não conseguia provar que o resto daquele arquivo único era livre de
efeito. Com preserveModules, o piso caiu para centenas de bytes.
O custo é que dist/ tem milhares de arquivos. Isso é esperado, não regressão — o
tarball tem o mesmo tamanho.
E é por isso que o orçamento de tamanho do CI é medido por fatia importada, não pelo barrel inteiro:
{
"name": "slice: one component (`Button`)",
"import": { "./dist/tempest-react-sdk.js": "{ Button }" },
"limit": "1.5 KB"
}
Medir o barrel diria só "o pacote cresceu", que cresce com toda feature e não informa nada sobre o que você paga. O barrel continua tendo um teto, mas como teto explícito de entrada inteira — não como orçamento de consumidor.
Import dinâmico é o outro lado da moeda
Rota que ninguém abriu não precisa estar no bundle inicial. O
defineRoutes([...]) do SDK aceita lazy: () => import("./Pagina"), e o
<AppRouter> já monta o <Suspense> — veja
Roteamento.
Recap
- ESM é estático:
importno topo, caminho literal — é o que permite analisar o grafo sem executar. ✅ dependenciesvem junto;devDependenciesfica no repo;peerDependencieso app instala.- Peer dep existe por correção, não por bytes: duas cópias de uma lib com contexto React quebram em runtime.
- Tree-shaking precisa de ESM + import nomeado + ausência de efeito colateral;
sideEffectsé como o pacote declara isso. import * as Xderruba o tree-shaking;import typesome do bundle.- No SDK,
dist/preserva o grafo de módulos e o orçamento do CI é medido por fatia importada.
📚 Referência canônica: MDN — Módulos JavaScript
➡️ Próxima página: TypeScript: o mínimo que o SDK usa