Antes dos ES Modules, o JavaScript não tinha sistema de módulos nativo. Havia tags script na ordem certa, padrões IIFE para simular encapsulamento, e então uma década de CommonJS no Node.js. Os ES Modules — padronizados no ES2015 e agora suportados em todos os lugares — são a solução real. Este guia cobre tudo, desde o básico de exportações nomeadas vs padrão até importações dinâmicas, tree-shaking e os padrões que mantêm grandes bases de código gerenciáveis.

Exportações Nomeadas vs Exportações Padrão

Há duas formas de exportar de um módulo. Entender a diferença — e os trade-offs — é a base para tudo mais:

js
// utils/currency.js

// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}

export function parseCents(cents) {
  return cents / 100;
}

export const DEFAULT_CURRENCY = 'USD';
js
// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';

// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';

// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works
js
// components/ProductCard.js

// Default export — one per file, no name required
export default function ProductCard({ product }) {
  return `<div class="card">${product.name}</div>`;
}
js
// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card        from './components/ProductCard.js'; // same thing, different name
Opinião: Exportações nomeadas são quase sempre melhores para módulos utilitários. São explícitas, amigáveis à IDE (autocompletar e "ir para definição" funcionam perfeitamente) e seguras para refatoração — renomear uma função é capturado pelas suas ferramentas, não pelos seus testes. Exportações padrão fazem sentido para componentes que são "a única coisa" que um arquivo exporta, como componentes React/Angular ou arquivos de handler de rotas.

Arquivos Barrel — Úteis e Perigosos

Um arquivo barrel é um index.js que re-exporta de múltiplos módulos, dando aos consumidores um caminho de importação mais limpo. Eles estão em todo lugar em projetos grandes:

js
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone }              from './validation.js';
export { debounce, throttle }                        from './timing.js';
export { fetchWithRetry, buildQueryString }          from './http.js';
js
// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';

O perigo: arquivos barrel podem destruir o tree-shaking. Se você importar uma função de um barrel que re-exporta 40 módulos, alguns bundlers puxam todos os 40 — mesmo os que você não usa. A correção é ser seletivo sobre o que você coloca no barrel, e garantir que seu bundler suporte sideEffects: false no package.json.

import() Dinâmico — Code Splitting Sob Demanda

Importações estáticas (import x from '...') são resolvidas no tempo de build. O import() dinâmico é uma função que carrega um módulo de forma lazy em tempo de execução, retornando uma Promise. É assim que você implementa code splitting baseado em rotas:

js
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
  const { Chart }   = await import('chart.js');
  const { buildDashboardData } = await import('./analytics/data.js');

  const data   = await buildDashboardData();
  const canvas = document.getElementById('dashboard-chart');
  new Chart(canvas, { type: 'bar', data });
}

// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);
js
// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
  const flags = await getFeatureFlags();

  if (flags[featureName]) {
    const module = await import(`./features/${featureName}.js`);
    module.init();
  }
}

Webpack, Rollup e Vite entendem o import() dinâmico e automaticamente dividem o módulo carregado em um chunk separado. O resultado: bundle inicial menor, primeiro carregamento mais rápido.

import.meta — Metadados do Módulo

import.meta é um objeto que fornece metadados sobre o módulo atual. A propriedade mais útil é import.meta.url — a URL do arquivo do módulo atual:

js
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join }  from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname  = dirname(__filename);

// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');
js
// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd  = import.meta.env.PROD; // boolean

// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
  const module = await modules[path]();
  console.log(path, module);
}

ES Modules vs CommonJS

Você ainda vai encontrar CommonJS (require()) em bases de código mais antigas do Node.js e alguns pacotes npm. Aqui está uma referência rápida das diferenças que confundem as pessoas:

js
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;

// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;

Principais diferenças: ES Modules são analisáveis estaticamente (importações são resolvidas antes do código ser executado), enquanto o CommonJS executa em tempo de execução. ESM usa carregamento async por padrão; CJS é síncrono. No Node.js, arquivos com extensão .mjs ou "type": "module" no package.json são tratados como ESM.

Armadilha de interoperabilidade: Você pode import um módulo CommonJS de um arquivo ESM (Node.js lida com isso), mas não pode usar require() em um módulo ESM a partir de um arquivo CommonJS. Se precisar, use uma chamada import() dinâmica — funciona em ambos os sistemas de módulos.

Tree-Shaking e Exportações Nomeadas

Tree-shaking é o processo do bundler de remover código que você importa mas nunca usa. Requer exportações nomeadas e instruções de importação estáticas — importações dinâmicas e CommonJS são muito mais difíceis de tree-shake. Veja como escrever módulos que fazem bom tree-shaking:

js
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str)   { /* ... */ }
export function addDays(date, n) { /* ... */ }

// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';
js
// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
  formatDate: (date) => { /* ... */ },
  parseDate:  (str)  => { /* ... */ },
  addDays:    (date, n) => { /* ... */ }
};
export default dateUtils;

// Even if you only use dateUtils.formatDate, the whole object may be bundled

Para bibliotecas publicadas no npm, o suporte a tree-shaking é a diferença entre uma biblioteca que as pessoas recomendam e uma que evitam. O guia de Módulos do MDN cobre as regras completas de análise estática que o habilitam.

ES Modules Nativos no Navegador

Você pode usar ES Modules diretamente no navegador — sem bundler necessário. Adicione type="module" à sua tag script:

js
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>
js
// app.js — can use import/export directly
import { initRouter }    from './router.js';
import { setupAuth }     from './auth.js';
import { loadDashboard } from './dashboard.js';

async function main() {
  await setupAuth();
  initRouter();
  await loadDashboard();
}

main();

Scripts de módulo são sempre diferidos e executam no modo estrito por padrão. Cada módulo é executado apenas uma vez, mesmo que seja importado por vários outros módulos — o navegador armazena em cache a instância do módulo. Vale a pena saber isso quando você tem estado compartilhado em módulos.

Um Exemplo Real de Refatoração

Veja como um arquivo utils típico cresce fora de controle e é dividido corretamente em módulos:

js
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }

// After: split by concern
// utils/
//   currency.js     → formatPrice, parseCents
//   validation.js   → validateEmail, validatePhone
//   timing.js       → debounce, throttle
//   http.js         → fetchWithRetry, buildQueryString
//   csv.js          → parseCSV, formatCSV
//   index.js        → re-exports all of the above (optional barrel)

Dividir por responsabilidade significa que cada arquivo tem uma única responsabilidade, os testes são mais fáceis de escrever e localizar, e os bundlers podem fazer tree-shaking no nível de arquivo. O arquivo barrel mantém as importações limpas para consumidores que preferem o caminho de importação único.

Ferramentas Úteis

Ao construir JavaScript modular, o Formatador JS mantém seus blocos de importação limpos e consistentes. Para entender o que está no seu bundle final, ferramentas como o Webpack Bundle Analyzer ou o visualizador do Rollup mostram quais módulos estão ocupando espaço. A especificação TC39 ECMAScript Modules tem a semântica de resolução exata se você precisar entender casos extremos.

Conclusão

ES Modules são a base da arquitetura JavaScript moderna. Prefira exportações nomeadas para funções utilitárias — são explícitas, suportam tree-shaking e são amigáveis à IDE. Use import() dinâmico para dividir código pesado do seu bundle inicial. Tenha cuidado com arquivos barrel em grandes bases de código. E se você ainda usa CommonJS em um novo projeto Node.js, é hora de migrar — o ecossistema migrou completamente para ESM.