Module System
JavaScript's module system provides a way to split code into files and reuse it. Two major systems coexist: ESM (ES Modules) and CJS (CommonJS).
Why Modules Are Necessary
// Without modules, everything is in the global scope:
// file1.js
var name = 'Alice';
function greet() { return `Hello, ${name}!`; }
// file2.js
var name = 'Bob'; // overwrites file1.js's name!
console.log(greet()); // Hello, Bob! (unintended result)
// With modules:
// file1.mjs - has its own scope
const name = 'Alice';
export function greet() { return `Hello, ${name}!`; }
// file2.mjs - has its own scope
const name = 'Bob';
import { greet } from './file1.mjs';
console.log(greet()); // Hello, Alice! (intended result)
console.log(name); // Bob (this file's name)
ESM (ES Module): import/export
Named Export
// math.mjs
// export at the time of declaration
export const PI = 3.14159;
export let version = '1.0';
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;
}
add(other) {
return new Vector(this.x + other.x, this.y + other.y);
}
}
// Or export all at once after declaration
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
export { multiply, divide };
// Rename on export
export { multiply as mul, divide as div };
Named Import
// app.mjs
import { PI, add, Vector } from './math.mjs';
console.log(PI); // 3.14159
console.log(add(2, 3)); // 5
const v = new Vector(1, 2);
// Rename on import
import { add as sum, subtract as minus } from './math.mjs';
console.log(sum(1, 2)); // 3
// Import entire module as namespace
import * as math from './math.mjs';
console.log(math.PI);
console.log(math.add(2, 3));
math.version = '2.0'; // Note: can be changed since it's a live binding
Default Export
// user.mjs
// Only one per file
export default class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
toString() {
return `${this.name} <${this.email}>`;
}
}
// Anonymous functions are also allowed
export default function(data) {
return JSON.stringify(data, null, 2);
}
// Default import can be named freely
import User from './user.mjs';
import stringify from './user.mjs'; // same file, different name
const user = new User('Alice', 'alice@example.com');
console.log(stringify({ name: 'test' }));
// Using named and default together
export default class Component { }
export const version = '1.0';
export function helper() { }
import Component, { version, helper } from './component.mjs';
Re-export
// utils/index.mjs - export from multiple modules in one place (Barrel Export)
// named re-export
export { add, subtract } from './math.mjs';
export { User, createUser } from './user.mjs';
export { fetchData, postData } from './api.mjs';
// re-export with rename
export { default as utils } from './utils.mjs';
// default re-export
export { default } from './main.mjs';
// full re-export
export * from './math.mjs';
export * from './string-utils.mjs';
// Usage
import { add, User, fetchData } from './utils/index.mjs';
// No need to import from multiple files individually
Dynamic import(): Code Splitting
// Static import: only at file top level, always loaded
import { heavyModule } from './heavy.mjs'; // always loaded
// Dynamic import: load only when needed
async function loadFeature() {
const module = await import('./heavy-feature.mjs');
module.initialize();
}
// Conditional loading
async function loadLocale(lang) {
const locale = await import(`./locales/${lang}.mjs`);
return locale.default;
}
// Event-based loading (routing)
button.addEventListener('click', async () => {
const { Modal } = await import('./components/Modal.mjs');
const modal = new Modal({ title: 'Confirm', content: 'Do you want to continue?' });
modal.show();
});
// Conditional polyfill loading
if (!window.IntersectionObserver) {
await import('intersection-observer'); // only when needed
}
// Returns a Promise (can be used without await)
import('./analytics.mjs').then(({ track }) => {
track('page_view', { path: location.pathname });
});
// Parallel dynamic loading of multiple modules
const [chartLib, mapLib] = await Promise.all([
import('chart.js'),
import('leaflet')
]);
import.meta
// Access current module metadata
console.log(import.meta.url); // file:///path/to/current/module.mjs
// Resolve relative paths based on current file
const dataUrl = new URL('./data.json', import.meta.url);
const response = await fetch(dataUrl);
// Replacement for __dirname in Node.js
import { fileURLToPath, URL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// Environment variables in Vite, Webpack, and other bundlers
console.log(import.meta.env.MODE); // 'development' or 'production'
console.log(import.meta.env.VITE_API_URL);
// import.meta.resolve (Node.js 20.6+)
const resolvedPath = import.meta.resolve('./utils.mjs');
CJS (CommonJS): require/module.exports
Node.js's traditional module system.
// math.js (CJS)
const PI = 3.14159;
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
// Export all at once
module.exports = { PI, add, subtract };
// Or individually
module.exports.PI = PI;
module.exports.add = add;
// exports is an alias for module.exports
exports.multiply = (a, b) => a * b;
// Warning: exports = {...} changes the reference and won't work!
// app.js (CJS)
const { PI, add } = require('./math.js');
const math = require('./math.js');
console.log(PI);
console.log(add(1, 2));
console.log(math.subtract(5, 3));
// Default export pattern
// user.js
class User { }
module.exports = User; // export the class itself
// Usage
const User = require('./user.js');
const user = new User();
// Built-in modules
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
CJS Characteristics
// CJS is synchronous and evaluated at runtime
const moduleName = getModuleName(); // can determine path dynamically
const module = require(moduleName); // OK
// require is cached
const a = require('./counter.js');
const b = require('./counter.js');
console.log(a === b); // true (same instance)
// counter.js
let count = 0;
module.exports = {
increment() { count++; },
getCount() { return count; }
};
// Side effect: two files share the same counter instance
const c1 = require('./counter.js');
const c2 = require('./counter.js');
c1.increment();
console.log(c2.getCount()); // 1 (same instance!)
ESM vs CJS Differences
| Feature | ESM | CJS |
|---|---|---|
| Syntax | import/export | require/module.exports |
| Loading | Static (compile-time analysis) | Dynamic (runtime) |
| Execution | Can be async | Always synchronous |
| Scope | Module scope | Module scope |
this (top-level) | undefined | module.exports |
| Extension | .mjs or "type": "module" | .cjs or default |
| Browser | Native support | Requires bundler |
// Set type via package.json
// { "type": "module" } → .js files treated as ESM
// { "type": "commonjs" } → .js files treated as CJS (default)
// Can mix both
// file.mjs → always ESM
// file.cjs → always CJS
// file.js → follows package.json type field
Tree Shaking
An optimization technique where bundlers remove unused code.
// utils.mjs
export function usedFunction() { return 'used'; }
export function unusedFunction() { return 'unused'; } // this will be removed from the bundle
// app.mjs
import { usedFunction } from './utils.mjs';
// unusedFunction is not imported so it's removed from the bundle
// Why tree shaking works: ESM's static analysis
// import statements are always at the top of the file, no conditional/dynamic imports → bundlers can pre-analyze
// CJS is difficult to tree shake
const utils = require('./utils'); // evaluated at runtime, bundler can't pre-analyze
utils.used();
Side Effect Control
// package.json
{
"sideEffects": false, // all files are pure (no side effects)
"sideEffects": ["*.css", "./src/polyfills.js"] // only specific files have side effects
}
// Example of side-effect code (effect occurs just from importing)
// polyfills.js
Array.prototype.at ??= function(i) { /* polyfill */ };
// CSS module
import './global.css'; // applying CSS is a side effect
Using Modules in the Browser and Node.js
Browser
<!-- Enable ESM with type="module" -->
<script type="module" src="./app.mjs"></script>
<!-- Inline module -->
<script type="module">
import { Component } from './component.mjs';
const app = new Component(document.getElementById('app'));
</script>
<!-- Modules are deferred by default (run after DOM parsing) -->
<!-- Modules are always in strict mode -->
<!-- Modules are loaded only once (cached) -->
<!-- Import Maps: support bare imports in the browser -->
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.skypack.dev/lodash",
"react": "https://esm.sh/react@18"
}
}
</script>
<script type="module">
import _ from 'lodash'; // using import map
import React from 'react';
</script>
Node.js
// Three ways to use ESM in Node.js:
// 1. File extension .mjs
// my-module.mjs
export const greeting = 'Hello from ESM';
// 2. "type": "module" in package.json
// { "type": "module" }
// .js files are now treated as ESM
// 3. .mts (TypeScript)
// Node.js 22+: can import ESM from CJS
const { foo } = await import('./esm-module.mjs');
// Cannot use require in ESM (use createRequire instead)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cjsModule = require('./cjs-module.js');
Real-world: Module Structure Design
src/
├── features/
│ ├── user/
│ │ ├── user.model.mjs
│ │ ├── user.service.mjs
│ │ ├── user.api.mjs
│ │ └── index.mjs ← Barrel export
│ └── product/
│ ├── product.model.mjs
│ └── index.mjs
├── shared/
│ ├── utils/
│ │ ├── string.mjs
│ │ ├── date.mjs
│ │ └── index.mjs
│ └── constants.mjs
└── app.mjs
// features/user/index.mjs (Barrel)
export { User } from './user.model.mjs';
export { UserService } from './user.service.mjs';
export * from './user.api.mjs';
// app.mjs
import { User, UserService } from './features/user/index.mjs';
import { formatDate } from './shared/utils/index.mjs';
Expert Tips
1. Handling Circular Dependencies
// a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log(b); // undefined! (b.mjs tries to load a.mjs first)
// Solution: extract to a shared module or use lazy import
async function getB() {
const { b } = await import('./b.mjs');
return b;
}
2. Module Singleton Pattern
// singleton.mjs
let instance = null;
export function getInstance() {
if (!instance) {
instance = createExpensiveObject();
}
return instance;
}
// ESM runs only once automatically (caching)
// Simplified version:
export const config = await loadConfig(); // Top-level await
// Importing this module always returns the same config instance
3. Conditional Polyfill Loading
// Dynamically load only the polyfills you need
const polyfills = [];
if (!('structuredClone' in globalThis)) {
polyfills.push(import('core-js/stable/structured-clone'));
}
if (!('at' in Array.prototype)) {
polyfills.push(import('core-js/stable/array/at'));
}
await Promise.all(polyfills);
// All polyfills are now loaded