Why Modules?
Without modules, all JavaScript code shares a single global scope - variables from
one script collide with another. ES Modules solve this by giving each file its own
scope and making dependencies explicit via import/export.
<!-- Regular script: shared global scope -->
<script src="utils.js"></script>
<script src="app.js"></script>
<!-- ES Module: each file has its own scope -->
<script type="module" src="app.js"></script>
// Module scope: top-level variables are NOT global
const secret = "only in this file"; // not accessible from other scripts
// Modules are strict mode by default
// this === undefined at top level (not window)
// Modules are evaluated once and cached
// Importing the same module twice returns the same instance
Named Exports
Named exports can export multiple values from a file. Consumers import them by their exact name (with optional renaming).
// Named exports: export individual declarations
export const PI = 3.14159265358979;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Vector {
constructor(x, y) { this.x = x; this.y = y; }
length() { return Math.sqrt(this.x ** 2 + this.y ** 2); }
}
// Alternatively: export a list at the end of the file
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
export { multiply, divide };
// Export with rename
const internal_helper = () => {};
export { internal_helper as helper }; // exported name is 'helper'
Default Exports
Each module can have one default export. The importer can give it any name.
// Default export: one per module
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() { return `Hi, I am ${this.name}`; }
}
// Can also export a function, object, or value as default
// export default function greet() { ... }
// export default { name: "config" };
// export default 42;
// Can combine default and named exports
export const USER_ROLES = ["admin", "user", "guest"]; // named
// (default export above is the User class)
Import Syntax
// Named imports - must use exact exported name
import { add, subtract, PI } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159...
// Rename on import
import { add as sum, subtract as diff } from "./math.js";
console.log(sum(2, 3)); // 5
console.log(diff(5, 2)); // 3
// Import all named exports as a namespace object
import * as MathUtils from "./math.js";
console.log(MathUtils.add(2, 3)); // 5
console.log(MathUtils.PI); // 3.14159...
// Default import - any name works
import User from "./user.js";
import MyUser from "./user.js"; // same thing, different local name
// Default + named together
import User, { USER_ROLES } from "./user.js";
// Import with .js extension is required in native ESM
// Bundlers (Vite, Webpack) often allow omitting it
import { helper } from "./utils.js"; // browser/Node.js
import { helper } from "./utils"; // bundler only
Named exports are generally preferred over default exports because: (1) editors can auto-complete the exact name, (2) refactoring tools can rename them everywhere automatically, (3) they make the module's public API explicit. Default exports are common for React components (one component per file), but named exports are better for utility modules.
Re-exports and Barrel Files
Re-exports let you create a single entry-point file (barrel) that collects and re-exports from multiple modules. This simplifies import paths for consumers.
// src/utils/index.js - barrel file
export { add, subtract, PI } from "./math.js"; // re-export named
export { default as User } from "./user.js"; // re-export default as named
export * from "./string-utils.js"; // re-export everything
export * as dom from "./dom-utils.js"; // re-export as namespace
// Consumer can now import from one place
// Instead of:
import { add } from "./utils/math.js";
import User from "./utils/user.js";
// They can do:
import { add, User } from "./utils/index.js";
// or even:
import { add, User } from "./utils"; // if bundler resolves index.js
Dynamic import()
Dynamic import() loads a module asynchronously and returns a Promise.
Use it for code splitting and lazy loading.
// Dynamic import - returns a Promise
async function loadChartLibrary() {
const { Chart } = await import("./chart.js");
return new Chart(document.getElementById("canvas"));
}
// Conditional loading
async function loadLocale(lang) {
const locale = await import(`./locales/${lang}.js`);
return locale.default; // access default export
}
// Lazy load on user action
document.getElementById("btn").addEventListener("click", async () => {
const { openModal } = await import("./modal.js");
openModal();
});
// Load multiple in parallel
async function loadDashboard() {
const [charts, tables, maps] = await Promise.all([
import("./charts.js"),
import("./tables.js"),
import("./maps.js"),
]);
// use charts.default, tables.default, etc.
}
// Static imports are analyzed at build time for tree-shaking
// Dynamic imports are code-split - separate chunks in the bundle
Module Scope
// Module-level code runs once when first imported
console.log("module.js loaded"); // printed once, even if imported by 10 files
// Module state is shared across all importers
let count = 0;
export function increment() { count++; }
export function getCount() { return count; }
// In file A:
import { increment, getCount } from "./counter.js";
increment(); // count = 1
// In file B (same page):
import { getCount } from "./counter.js";
console.log(getCount()); // 1 (same module instance!)
// Module top-level is NOT the global object
const x = 1; // not window.x - stays local to module
// But you can explicitly set global if needed (rare)
window.myLib = { version: "1.0" }; // not recommended
// this at module top level is undefined (not window)
console.log(this); // undefined
CommonJS vs ESM
| Feature | CommonJS (require) | ES Modules (import) |
|---|---|---|
| Syntax | require() / module.exports | import / export |
| Loading | Synchronous | Asynchronous |
| Evaluation | Runtime | Parse time (static) |
| Conditional | Yes (if (x) require(...)) | No (use dynamic import()) |
| Tree-shaking | No | Yes |
| Default in | Node.js (legacy) | Browser, modern Node.js |
| File extension | .js, .cjs | .js (with "type":"module"), .mjs |
// CommonJS (Node.js legacy) - still widely used in npm packages
const fs = require("fs");
const { add, subtract } = require("./math");
module.exports = { myFunc };
module.exports.default = MyClass;
// ESM in Node.js - requires "type": "module" in package.json
// or .mjs file extension
import fs from "node:fs/promises";
import { add } from "./math.js"; // .js extension required