Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Módulos em JavaScript: import e export (ES Modules)

Named e default export, live bindings, escopo de módulo e por que ES Modules não abrem em file:// — com os dois erros que travam todo mundo reproduzidos.

Rodolfo Mori9 min de leitura

Módulo é um arquivo que decide o que mostra para fora. Tudo que você declara nele fica privado por padrão; só o que leva export fica visível, e só quem escreve import enxerga. É assim que um projeto para de ser um punhado de <script> disputando as mesmas variáveis globais.

A resposta curta: export marca o que sai, import traz o que entra, o caminho relativo precisa da extensão (./frete.mjs, não ./frete), e no navegador a tag tem que ser <script type="module"> servida por http, porque abrir o HTML direto do disco não funciona. O resto deste texto mostra cada regra rodando, incluindo as duas em que todo mundo tropeça.

Os exemplos são de uma loja virtual dividida em três arquivos: frete, carrinho e cupons.

Portas e balcões: o modelo mental de um módulo

Imagine que cada arquivo é uma sala de uma loja. O que fica dentro da sala não atrapalha ninguém do lado de fora. Quando algo precisa ser usado no caixa, a sala coloca esse item no balcão com export; o caixa o recolhe com import. Sem balcão, o item continua existindo, mas só naquela sala.

Essa imagem volta diretamente ao mecanismo técnico: módulos têm escopo próprio, export define a interface pública e import cria uma ligação com o valor exportado. Na microprática abaixo, retire mentalmente um export antes de rodar e preveja a mensagem; depois confira no terminal qual nome o motor diz que o módulo não oferece.

O arquivo frete.mjs declara três coisas e exporta as três:

js
export const TABELA = { sudeste: 12.5, sul: 18.9, nordeste: 27.4, norte: 34.9 };

export function calcularFrete(peso, regiao = 'sudeste') {
  return (TABELA[regiao] ?? 39.9) + peso * 1.5;
}

export const FRETE_GRATIS_ACIMA_DE = 199;

E app.mjs traz só o que precisa:

js
import { calcularFrete, FRETE_GRATIS_ACIMA_DE } from './frete.mjs';

console.log('frete sudeste:', calcularFrete(2));
console.log('frete norte  :', calcularFrete(2, 'norte'));
console.log('grátis acima de R$', FRETE_GRATIS_ACIMA_DE);
frete sudeste: 15.5 frete norte : 37.9 grátis acima de R$ 199

Três detalhes que economizam meia hora de confusão:

  • As chaves em import { ... } não são desestruturação de objeto. É sintaxe própria de módulo, e o nome dentro delas tem que bater exatamente com o nome exportado.
  • O ./ é obrigatório. Sem ele, import { x } from 'frete.mjs' procura um pacote instalado, não o arquivo ao lado.
  • A extensão também é obrigatória no Node e no navegador. O especificador é uma URL, e URL não tenta .js, .mjs, /index.js por conta própria.

Default, renomeação e namespace

Um arquivo pode ter um export default — o valor principal dele. Aqui, a classe Carrinho:

js
export default class Carrinho {
  constructor(cliente) {
    this.cliente = cliente;
    this.itens = [];
  }
  adicionar(item) {
    this.itens.push(item);
    return this;
  }
  get subtotal() {
    return this.itens.reduce((soma, i) => soma + i.preco, 0);
  }
}

export const LIMITE_DE_ITENS = 20;

Quem importa escolhe o nome do default — e pode misturar com os nomeados, trocar o nome de um deles com as ou trazer o módulo inteiro como objeto:

js
import Carrinho, { LIMITE_DE_ITENS } from './carrinho.mjs';
import * as frete from './frete.mjs';
import { calcularFrete as freteDe } from './frete.mjs';

const c = new Carrinho('Ana')
  .adicionar({ nome: 'Teclado mecânico', preco: 289.9 })
  .adicionar({ nome: 'Mouse sem fio', preco: 149.9 });

console.log('cliente :', c.cliente);
console.log('subtotal:', c.subtotal.toFixed(2));
console.log('limite  :', LIMITE_DE_ITENS);
console.log('namespace:', Object.keys(frete));
console.log('renomeado:', freteDe(1, 'sul'));
cliente : Ana subtotal: 439.80 limite : 20 namespace: [ 'FRETE_GRATIS_ACIMA_DE', 'TABELA', 'calcularFrete' ] renomeado: 20.4

Repare que Object.keys do namespace veio em ordem alfabética, não na ordem em que os export aparecem no arquivo — a especificação exige isso.

O import é içado e roda antes do seu código

Um import não é executado na linha em que está escrito. O motor lê todos os import do arquivo, carrega e avalia cada dependência antes de rodar a primeira linha do módulo que importa. Dá para provar:

js
console.log('[app] primeira linha do arquivo');

import { NOME } from './banner.mjs';

console.log('[app] loja:', NOME);

Com banner.mjs sendo:

js
console.log('[banner] módulo avaliado');
export const NOME = 'Club Store';
[banner] módulo avaliado [app] primeira linha do arquivo [app] loja: Club Store

A mensagem do banner apareceu antes da “primeira linha do arquivo”, mesmo com o import escrito depois dela. É um içamento parecido com o de declaração de função, descrito em variáveis em JavaScript, só que valendo para o arquivo inteiro.

Consequência prática: import só existe no topo do módulo, nunca dentro de if, de função ou de bloco. Para carregar condicionalmente existe o import() dinâmico, mais abaixo.

Live bindings: o import não é uma cópia

Esta é a diferença mais importante entre import e o antigo require, e a que quase ninguém sabe. O que você importa é uma ligação viva com a variável do outro módulo, não uma fotografia do valor dela:

js
export let unidades = 3;

export function vender(quantidade) {
  unidades -= quantidade;
}
js
import { unidades, vender } from './estoque.mjs';

console.log('antes :', unidades);
vender(2);
console.log('depois:', unidades);

try {
  unidades = 10;
} catch (erro) {
  console.log(erro.name, '|', erro.message);
}
antes : 3 depois: 1 TypeError | Assignment to constant variable.

Duas coisas aconteceram. unidades mudou de 3 para 1 do lado de fora, sem reimportar nada — a ligação acompanha a variável original. E a tentativa de atribuir de fora falhou com Assignment to constant variable, mesmo o módulo tendo declarado com let: do lado de quem importa, todo binding é somente leitura.

Quem muda o valor é o módulo dono dele, por meio de uma função exportada. É um modelo bem mais previsível do que passar o objeto inteiro e deixar qualquer arquivo escrever nele.

Escopo próprio e modo estrito de graça

Módulo não polui o global e é sempre strict mode, sem precisar do 'use strict':

js
const precoBase = 289.9;

console.log('globalThis.precoBase:', globalThis.precoBase);
console.log('this no topo        :', this);

totalDoCarrinho = 999;
globalThis.precoBase: undefined this no topo : undefined file:///private/tmp/loja/mod/app5.mjs:6 totalDoCarrinho = 999; ^ ReferenceError: totalDoCarrinho is not defined at file:///private/tmp/loja/mod/app5.mjs:6:17 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.16.0

Três fatos numa saída. A const do topo não virou propriedade global. O this no topo de um módulo é undefined (num script clássico seria window). E atribuir a um nome não declarado lança ReferenceError em vez de criar uma global silenciosa — comportamento do modo estrito, que aqui vem ligado por padrão.

Isso muda o que você pode esperar de código copiado da internet: qualquer tutorial que dependa de “a função fica disponível no window” não funciona dentro de um módulo.

No navegador: type=module e o bloqueio do file://

No HTML, um módulo entra assim:

html
<script type="module" src="./app.js"></script>

O type="module" muda quatro comportamentos de uma vez: o arquivo pode usar import, ele é adiado automaticamente (não precisa de defer), roda em modo estrito e ganha escopo próprio.

E aí vem a pedra no caminho de todo iniciante: abrir o index.html com dois cliques não funciona. O endereço vira file:///Users/ana/loja/index.html, cuja origem é null, e o navegador trata cada import como uma requisição entre origens. O Chrome responde no console:

Access to script at 'file:///Users/ana/loja/frete.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted. GET file:///Users/ana/loja/frete.js net::ERR_FAILED

Não é bug e não tem --flag que valha a pena. A solução é servir por HTTP, e qualquer uma destas resolve em dez segundos:

bash
npx serve .
python3 -m http.server 8000

Com o site em http://localhost:8000, o mesmo arquivo carrega sem tocar em uma linha de código. No VS Code, a extensão Live Server faz o mesmo com um clique.

O erro que aparece no Node: import statement outside a module

O Node decide se um arquivo é ESM ou CommonJS pela extensão e pelo package.json. .mjs é sempre ESM, .cjs é sempre CommonJS, e .js depende do campo type do package.json mais próximo.

Desde o Node 22.7 existe detecção automática de sintaxe: um .js com import é tratado como ESM mesmo sem "type": "module". Por isso, para reproduzir o erro clássico, é preciso um contexto CommonJS explícito — um package.json com "type": "commonjs", que é o caso de milhares de projetos antigos:

js
import { calcularFrete } from './frete.mjs';

console.log(calcularFrete(2));
(node:13274) Warning: Failed to load the ES module: /private/tmp/loja/mod/cjs/app.js. Make sure to set "type": "module" in the nearest package.json file or use the .mjs extension. (Use `node --trace-warnings ...` to show where the warning was created) /private/tmp/loja/mod/cjs/app.js:1 import { calcularFrete } from './frete.mjs'; ^^^^^^ SyntaxError: Cannot use import statement outside a module at wrapSafe (node:internal/modules/cjs/loader:1787:18) at Module._compile (node:internal/modules/cjs/loader:1828:20) at Object..js (node:internal/modules/cjs/loader:1985:10) at Module.load (node:internal/modules/cjs/loader:1577:32) at Module._load (node:internal/modules/cjs/loader:1379:12) at wrapModuleLoad (node:internal/modules/cjs/loader:255:19) at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5) at node:internal/main/run_main_module:33:47 Node.js v24.16.0

O próprio aviso entrega as duas saídas: trocar o type para module no package.json, ou renomear o arquivo para .mjs. Repare também que a stack inteira vem de node:internal/modules/cjs/loader — a pista de que o arquivo foi carregado pelo caminho CommonJS, e não pelo de módulo.

O erro espelhado acontece quando você escreve require dentro de um ESM:

js
const { calcularFrete } = require('./frete.mjs');
console.log(calcularFrete(2));
file:///private/tmp/loja/mod/app6.mjs:1 const { calcularFrete } = require('./frete.mjs'); ^ ReferenceError: require is not defined in ES module scope, you can use import instead at file:///private/tmp/loja/mod/app6.mjs:1:27 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.16.0

E há um terceiro, bem mais amigável do que parece: importar um nome que o outro arquivo não exporta.

js
import { calcularFreteGratis } from './frete.mjs';

console.log(calcularFreteGratis(2));
file:///private/tmp/loja/mod/app7.mjs:1 import { calcularFreteGratis } from './frete.mjs'; ^^^^^^^^^^^^^^^^^^^ SyntaxError: The requested module './frete.mjs' does not provide an export named 'calcularFreteGratis' at #asyncInstantiate (node:internal/modules/esm/module_job:327:21) at async ModuleJob.run (node:internal/modules/esm/module_job:431:5) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) Node.js v24.16.0

É SyntaxError e acontece antes de qualquer linha rodar, porque o motor liga todos os módulos na fase de instanciação. Esse é o ganho real de escrever import em vez de require: o typo aparece no carregamento, não na hora em que o usuário clica no botão. Vale reler o que cada família de erro significa em tipos de erro em JavaScript.

ESM contra CommonJS

ES Modules CommonJS
sintaxe import / export require / module.exports
quando resolve na análise, antes de rodar na execução, na linha do require
o que você recebe ligação viva, somente leitura cópia do valor no momento
carregamento assíncrono síncrono
roda no navegador sim não, sem bundler
import condicional só com import() require dentro de if funciona

Na prática você vai conviver com os dois por anos. Um ESM consegue importar um arquivo CommonJS: o module.exports chega como default, e o Node ainda tenta adivinhar os nomeados:

js
const CUPONS = { PRIMEIRACOMPRA: 10, BLACKFRIDAY: 30 };

function aplicar(valor, codigo) {
  return valor - valor * ((CUPONS[codigo] ?? 0) / 100);
}

module.exports = { CUPONS, aplicar };
js
import cupons from './cupons.cjs';
import { aplicar } from './cupons.cjs';

console.log('default   :', Object.keys(cupons));
console.log('nomeado   :', aplicar(200, 'BLACKFRIDAY'));
default : [ 'CUPONS', 'aplicar' ] nomeado : 140

O caminho inverso — CommonJS importando ESM — não funciona com require porque ESM é assíncrono. Ali só o import() dinâmico resolve.

import() dinâmico

import() é uma função que devolve promessa. Serve para carregar um módulo em tempo de execução, com o caminho decidido na hora, e é a base do code splitting de qualquer front-end moderno:

js
const REGIAO = process.env.REGIAO ?? 'sudeste';

const { calcularFrete } = await import('./frete.mjs');
console.log('dinâmico:', calcularFrete(2, REGIAO));

const carrinho = await import('./carrinho.mjs');
console.log('chaves do módulo:', Object.keys(carrinho));
const Carrinho = carrinho.default;
console.log('instância:', new Carrinho('Bruno').cliente);
dinâmico: 15.5 chaves do módulo: [ 'LIMITE_DE_ITENS', 'default' ] instância: Bruno

Repare que o default vem como uma propriedade normal do objeto resolvido. E que o await está no topo do arquivo, sem função async em volta: top-level await é exclusividade de módulo ESM.

Como dividir um projeto de verdade

Módulo bem desenhado não é módulo pequeno; é módulo com uma responsabilidade que cabe no nome do arquivo. Quatro hábitos que sustentam projeto grande:

  • Um assunto por arquivo, nomeado pelo assunto. frete.js, carrinho.js, cupons.js. Se o nome precisa de “e”, são dois arquivos.
  • Named export por padrão. Você ganha autocomplete, renomeação segura e erro de import na hora do carregamento em vez de undefined no meio da tela.
  • Nada de efeito colateral no topo. Um fetch ou um document.querySelector solto no corpo do módulo roda no instante em que alguém o importa — e você perde o controle da ordem. Exporte uma função iniciar() e chame-a de um lugar só.
  • Evite ciclos. a.js importando b.js que importa a.js não quebra na hora, mas entrega undefined num dos lados durante a avaliação. Quando acontecer, o debugger no topo de cada módulo mostra a ordem real — e o caminho para investigar isso está em debugar JavaScript no DevTools.

Com o projeto em módulos, a próxima parada da trilha de JavaScript é o tratamento de texto com expressões regulares. E se quiser rever a ordem de estudo inteira, o guia completo de JavaScript tem o mapa.

  • modulos
  • import
  • export
  • esm
  • commonjs

Perguntas frequentes

Devo usar export default ou named export?
Named export por padrão. Ele obriga quem importa a usar o nome certo, o editor autocompleta e o renomear em massa funciona. Guarde o default para quando o arquivo tem um valor principal óbvio — uma classe, um componente — e mesmo aí muita equipe prefere não usar.
Preciso escrever a extensão .js no import?
No Node e no navegador, sim: o especificador relativo é uma URL e URL não adivinha extensão. Em projetos com bundler (Vite, webpack) ou em TypeScript a extensão costuma ser resolvida para você, e é por isso que o mesmo import quebra quando o código sai do bundler.
Por que meu HTML não carrega o módulo abrindo o arquivo direto?
Porque o protocolo file:// tem origem null, e o navegador trata cada import como uma requisição entre origens diferentes. Módulo exige http ou https. Suba um servidor local — npx serve, a extensão Live Server, ou python3 -m http.server — e o mesmo arquivo funciona.
Posso misturar require e import no mesmo projeto?
No mesmo arquivo, não. No mesmo projeto, sim, com cuidado: um módulo ESM consegue importar um arquivo CommonJS, e o module.exports dele chega como o export default. O caminho inverso só funciona com import() dinâmico.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Módulos JavaScript — developer.mozilla.org
  2. Node.js — Modules: ECMAScript modules — nodejs.org
  3. ECMAScript 2026 Language Specification — Modules — tc39.es

Continue por aqui