Modules
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Modularity is about hiding a piece of code’s private implementation details and giving it a small, explicit
public surface, so that code from many different authors can be assembled into one program without one file’s
variables, functions, or classes silently clobbering another’s. JavaScript went through three distinct eras of
modularity, and understanding all three helps when reading existing code: closures used as a do-it-yourself
substitute (superseded, see Legacy Features to Avoid), Node’s
require()-based CommonJS system, and the language’s own native ES module syntax (import/export), which is
the one to reach for in new code today.
ES Module Syntax
Each .js file loaded as a module has its own private top-level scope: constants, variables, functions, and
classes defined in it are invisible to every other module unless explicitly exported. Modules also run in
strict mode automatically, and this is undefined at their top level even outside any function.
export
Add the export keyword directly in front of a top-level declaration to make it part of a module’s public API:
export const PI = Math.PI;
export function degreesToRadians(d) {
return (d * PI) / 180;
}
export class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return PI * this.radius * this.radius;
}
}
As an alternative, everything can be declared without export and then listed once, typically at the bottom of
the file:
const PI = Math.PI;
function degreesToRadians(d) { /* ... */ }
class Circle { /* ... */ }
export { PI, degreesToRadians, Circle };
export (and import) may only appear at a module’s top level — never inside a function, loop, class body, or
conditional. This restriction is what lets tools statically determine a module’s exports without running any of
its code.
export default
A module that exports a single, primary value typically uses export default instead:
export default class BitSet {
// ...
}
A module may have at most one default export, but it is legal (if uncommon) to combine a default export with regular named exports in the same file. Unlike named exports, a default export has no name of its own — the importing module chooses whatever local name it wants when it imports it.
Named vs. default imports
Importing a default export needs only a local name and the module specifier:
import BitSet from './bitset.js';
Importing from a module with named (non-default) exports uses a destructuring-like syntax that names exactly the bindings wanted:
import { mean, stddev } from './stats.js';
Both forms can be combined for a module that exports a default plus named values:
import Histogram, { mean, stddev } from './histogram-stats.js';
If two modules export different values under the same name, or an imported name collides with something already
declared locally, rename it on the way in with as:
import { render as renderImage } from './imageutils.js';
import { render as renderUI } from './ui.js';
import * as ns
To pull in every named export of a module as properties of a single namespace object, rather than listing each one:
import * as stats from './stats.js';
stats.mean([1, 3, 5]);
stats.stddev([1, 3, 5]);
The imported bindings — whether individually named or gathered into a namespace object — behave like const:
they cannot be reassigned. Like export, import statements are hoisted to the top of the module and may only
appear at the top level, but by convention are still written at the very start of the file.
A module can also be imported purely for its side effects, with no bindings at all — useful for a module that registers event handlers or runs setup code but has nothing meaningful to export:
import './analytics.js';
Re-exports
A module that wants to forward another module’s exports without introducing a new name for them can combine an import and an export into one statement:
export { mean, stddev } from './stats-internal.js';
export * from './stats-internal.js'; // re-export every named export
Dynamic import()
Static import/export declarations are resolved before any module code runs, which is exactly what makes them
analyzable — but it also means an entire dependency graph must load before a program can start. import() is
an operator (not a function call, despite the syntax) that loads a module on demand and returns a Promise
resolving to the same kind of namespace object import * as produces:
import('./stats.js').then((stats) => {
console.log(stats.mean(data));
});
// or, inside an async function -- see xref:programming-languages/javascript/async-javascript.adoc[Asynchronous JavaScript]
async function analyze(data) {
const stats = await import('./stats.js');
return { average: stats.mean(data), stddev: stats.stddev(data) };
}
This is the mechanism behind code-splitting: loading only the JavaScript a page needs immediately, then pulling in the rest on demand (e.g. when a route is visited or a modal is opened), instead of shipping one large bundle upfront.
Module Scoping
Each module is its own private namespace — this is the direct, standardized replacement for the pre-ES6
IIFE/closure "namespace object" pattern described in
Function Parameters & Namespaces. Where that pattern
used a manually invoked function to fake a private scope and returned an object as its public API, a module gets
a private scope for free from the file system (or URL) boundary itself, with export/import as its explicit,
statically analyzable public API.
In the browser, a module is loaded with <script type="module">, is fetched and executed like a defer`red
script (parsed eagerly, executed only after HTML parsing completes, in document order), and can only import
other modules from the same origin unless the server sends the right CORS headers. `import.meta.url gives a
module its own loaded-from URL, handy for resolving relative resources such as new URL('./data.json',
import.meta.url).
CommonJS (require/module.exports)
Before ES6 modules existed, Node.js defined its own module system, still ubiquitous in older Node code: each
file is implicitly a module, imports another module by calling require(), and exports its API either by
assigning properties onto the built-in exports object or by replacing module.exports entirely:
// stats.js (CommonJS)
const sum = (x, y) => x + y;
exports.mean = (data) => data.reduce(sum) / data.length;
// or, to export a single value directly:
// module.exports = function mean(data) { /* ... */ };
// consumer.js (CommonJS)
const stats = require('./stats.js');
const { mean } = require('./stats.js'); // or destructure what's needed
stats.mean([1, 3, 5]);
The two systems differ in more than syntax: CommonJS require() calls are synchronous and can appear anywhere
in a file (even conditionally), while ES module import/export are static, hoisted, and load-order
deterministic — which is exactly what lets tools like bundlers and tree-shakers reason about a module graph
without executing it. Node supports both today (an ES module is identified by a .mjs extension, "type":
"module" in package.json, or import/export syntax itself), but new code should default to ES modules;
CommonJS is worth recognizing mainly to read existing libraries and legacy code.