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:
// 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';// 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// components/ProductCard.js
// Default export — one per file, no name required
export default function ProductCard({ product }) {
return `<div class="card">${product.name}</div>`;
}// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card from './components/ProductCard.js'; // same thing, different nameArquivos 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:
// 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';// 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:
// 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);// 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:
// 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');// 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:
// 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.
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:
// ✅ 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';// ❌ 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 bundledPara 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:
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>// 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:
// 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.