. No Node, use a extensão .mjs ou coloque \"type\": \"module\" no seu package.json."}},{"@type":"Question","name":"Qual a diferença entre export default e export nomeado?","acceptedAnswer":{"@type":"Answer","text":"Um módulo pode ter vários exports nomeados (export function foo() {}), mas só um export default. Os nomeados precisam ser importados com o nome exato, entre chaves: import { foo } from './x.js'. Já o default pode ser importado com qualquer nome: import oQueQuiser from './x.js'."}},{"@type":"Question","name":"O que é o import() dinâmico no JavaScript?","acceptedAnswer":{"@type":"Answer","text":"O import() chamado como função devolve uma Promise que resolve com os exports do módulo. Diferente do import estático, ele executa na hora da chamada, então dá pra carregar código sob demanda ou condicionalmente. É assim que se faz code-splitting e lazy loading na prática."}},{"@type":"Question","name":"Preciso colocar a extensão do arquivo no caminho do import?","acceptedAnswer":{"@type":"Answer","text":"Em módulos ES nativos — seja no navegador ou no loader ESM do Node — sim. Tem que escrever ./utils.js, e não só ./utils. Bundlers como Vite e webpack são mais flexíveis e resolvem caminhos sem extensão, mas depender disso deixa seu código menos portável."}}]}
Menu

Módulos ES no JavaScript: import, export e import() dinâmico

Entenda como funcionam os módulos ES no JavaScript: exports nomeados e default, sintaxe do import, import() dinâmico e o que diferencia módulos de scripts comuns.

Um módulo é um arquivo com escopo próprio

Antes dos módulos ES existirem, cada <script> jogava suas variáveis no escopo global, e a ordem de carregamento decidia quem enxergava o quê. Os módulos ES JavaScript resolvem isso dando a cada arquivo seu próprio escopo. Nada vaza para fora sem um export explícito. E nada entra sem um import explícito.

Dois arquivos, um exportando e outro importando:

index.js
Output
Click Run to see the output here.

add e multiply ficam em math.js. Eles só aparecem em main.js por causa do import. Qualquer outra coisa dentro de math.js — funções auxiliares, constantes, o que for — fica inacessível de fora.

Daí saem duas regras que vale a pena gravar desde já:

  • Módulos rodam em strict mode automaticamente. Não precisa de 'use strict'.
  • O this no nível do topo é undefined, não o objeto global.

Export nomeado: exporte conforme for escrevendo

É a forma mais comum. Basta colocar export na frente de qualquer function, class, const ou let e aquilo já passa a fazer parte da API pública do módulo:

index.js
Output
Click Run to see the output here.

Os nomes dentro das chaves precisam bater exatamente com os nomes exportados — import { circlearea } daria erro. Se algum nome colidir com algo que você já tem no escopo, dá pra renomear na hora do import usando as:

index.js
Output
Click Run to see the output here.

Você também pode listar os export no final do arquivo em vez de marcá-los inline — muita gente prefere esse estilo, já que fica claro onde está a "API pública" do módulo:

index.js
Output
Click Run to see the output here.

Os dois estilos produzem o mesmo resultado. Escolha um e mantenha a consistência dentro do projeto.

Export default: um por módulo

Um módulo também pode ter um único export default. O export default do JavaScript é ideal para arquivos que realmente têm uma coisa principal — um componente, uma classe, um objeto de configuração:

index.js
Output
Click Run to see the output here.

Três coisas que vale a pena observar:

  • Não tem chaves em volta de log no import.
  • O nome que você dá na importação é livre. import shout from './logger.js' funcionaria exatamente igual.
  • Só pode ter um export default por arquivo. Se você tentar adicionar um segundo, o arquivo nem vai ser parseado.

Export nomeado e default podem conviver tranquilamente no mesmo arquivo:

index.js
Output
Click Run to see the output here.

Primeiro o default, depois os nomeados entre chaves. A ordem é fixa.

Qual usar? Exports nomeados são mais fáceis de refatorar — renomear em todo o projeto é um único find-and-replace, já que todo import usa o mesmo nome. O default é flexível, mas permite que cada chamador escolha um nome diferente, o que complica na hora de dar um grep. A maioria dos guias de estilo modernos prefere exports nomeados e deixa o default só pra módulos que realmente têm uma única responsabilidade.

Import de tudo, re-export e efeitos colaterais

Tem mais algumas formas de import que você vai encontrar pelo caminho.

Pra pegar todos os exports nomeados em um único objeto namespace:

index.js
Output
Click Run to see the output here.

Re-exporte a partir de outro módulo sem precisar trazer os nomes para o escopo atual:

index.js
Output
Click Run to see the output here.

É assim que os pontos de entrada de bibliotecas juntam pedaços de arquivos internos numa única superfície pública.

E, por fim, um import sem nenhuma ligação — útil para módulos cuja única função é causar efeitos colaterais (polyfills, CSS-in-JS, registrar handlers):

index.js
Output
Click Run to see the output here.

O arquivo executa uma vez; nada é importado pelo nome.

Imports são estáticos e ao vivo

Duas características do import que às vezes pegam as pessoas de surpresa.

Estático. As declarações import são resolvidas antes de qualquer código seu rodar. Você não pode colocar uma dentro de um if, de uma função ou de um try. O caminho precisa ser uma string literal, nunca uma variável. É justamente isso que permite que as ferramentas analisem os imports sem executar o código — bundlers, checadores de tipo e tree-shakers dependem desse comportamento.

// Não permitido — SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

Se você precisa carregar algo de forma condicional, use import() (veremos isso a seguir).

Ao vivo. Um binding importado é uma referência somente leitura apontando para o export original, não uma cópia estática. Se o módulo que exporta reatribuir o valor, quem importou enxerga o novo valor:

index.js
Output
Click Run to see the output here.

Também não dá pra reatribuir um import do lado de quem consome — fazer count = 5 no main.js lançaria erro. Imports são views somente leitura.

import() dinâmico para carregar sob demanda

Quando você precisa decidir em tempo de execução se vai carregar um módulo — funcionalidades pesadas, code splitting por rota, polyfills condicionais — use import() como função. Ele devolve uma promise que resolve para os exports do módulo:

index.js
Output
Click Run to see the output here.

Como é uma chamada de função comum, você pode:

  • Usar await dentro de uma função async.
  • Passar uma variável como caminho.
  • Colocar dentro de um if ou try/catch.

A desestruturação do objeto resolvido funciona igualzinho a um import estático:

index.js
Output
Click Run to see the output here.

default é a chave do export default quando você faz destructuring. Renomeie para o que quiser.

Os casos de uso práticos são code-splitting (só baixa a biblioteca de gráficos quando o usuário clica em "mostrar gráfico"), polyfills por feature detection e plugins descobertos em tempo de execução.

Executando ES Modules: navegadores e Node

A sintaxe é a mesma em todo lugar; o que muda é como o runtime encontra e carrega o arquivo.

No navegador, marque o script de entrada como módulo:

<script type="module" src="./main.js"></script>

Com type="module", o navegador passa a respeitar import/export, executa o código em modo estrito e adia a execução até o HTML ser todo parseado. Os caminhos precisam ser relativos (./, ../) ou URLs absolutas — specifiers "pelados" como import 'lodash' não funcionam sem um import map ou um bundler.

No Node, dá pra ativar de duas formas:

  • Nomeie o arquivo com extensão .mjs, ou
  • Defina "type": "module" no package.json mais próximo, o que transforma todo arquivo .js em módulo.

O Node também exige o caminho completo, com extensão: import './utils.js', e não import './utils'.

// package.json
{
  "type": "module",
  "main": "./index.js"
}

Os dois ambientes exigem extensões explícitas no ESM nativo. Bundlers (Vite, webpack, esbuild) resolvem caminhos sem extensão durante o desenvolvimento — é prático, mas depender disso significa que seu código-fonte não roda sem o passo de build.

Pegadinhas comuns

Alguns detalhes costumam derrubar quem está começando:

  • Esquecer o type="module" no navegador. Sem isso, o <script> roda como script clássico e import vira erro de sintaxe.
  • Omitir a extensão do arquivo no Node. import './utils' quebra; import './utils.js' funciona. Bundlers escondem isso, runtimes nativos não.
  • Esperar __dirname ou require dentro de um módulo ES. Essas coisas são exclusivas do CommonJS. No ESM, use import.meta.url e converta para caminho quando precisar.
  • Imports circulares que acessam valores antes da hora. Dois módulos importando um ao outro é permitido, mas ler um export que ainda não foi atribuído devolve undefined. Organize o código para que o ciclo não seja executado durante a inicialização, ou quebre a dependência.
  • Tentar fazer import condicional. A instrução import estática não aceita isso. Para qualquer coisa que dependa de runtime, use o import() dinâmico.

A seguir: CommonJS vs ESM

Os módulos ES são o padrão, mas boa parte do código Node por aí ainda usa CommonJS — require, module.exports e um conjunto diferente de regras sobre quando o código executa. Conhecer os dois, e como eles conversam entre si, é o assunto da próxima página.

Perguntas frequentes

Como usar módulos ES no JavaScript?

Exporte valores de um arquivo usando export ou export default e traga eles para outro arquivo com import. No navegador, carregue o arquivo de entrada com <script type="module" src="main.js"></script>. No Node, use a extensão .mjs ou coloque "type": "module" no seu package.json.

Qual a diferença entre export default e export nomeado?

Um módulo pode ter vários exports nomeados (export function foo() {}), mas só um export default. Os nomeados precisam ser importados com o nome exato, entre chaves: import { foo } from './x.js'. Já o default pode ser importado com qualquer nome: import oQueQuiser from './x.js'.

O que é o import() dinâmico no JavaScript?

O import() chamado como função devolve uma Promise que resolve com os exports do módulo. Diferente do import estático, ele executa na hora da chamada, então dá pra carregar código sob demanda ou condicionalmente. É assim que se faz code-splitting e lazy loading na prática.

Preciso colocar a extensão do arquivo no caminho do import?

Em módulos ES nativos — seja no navegador ou no loader ESM do Node — sim. Tem que escrever ./utils.js, e não só ./utils. Bundlers como Vite e webpack são mais flexíveis e resolvem caminhos sem extensão, mas depender disso deixa seu código menos portável.

Aprenda a programar com o Coddy

COMEÇAR