JavaScript modules (ESM — ECMAScript Modules) are the native module system built into browsers. They let you split code into separate files with explicit imports/exports, enable tree-shaking, and work without a bundler in modern browsers. Firefox has full native ES module support.
The Basics: export and import
// utils/math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159265358979;
// Default export (one per file)
export default function calculateArea(radius) {
return PI * radius * radius;
}
// main.js
import calculateArea, { add, multiply, PI } from './utils/math.js';
// Rename on import
import { add as sum } from './utils/math.js';
// Import all as namespace
import * as Math from './utils/math.js';
Math.add(1, 2);
Math.PI;
Using Modules in HTML
<!-- type="module" enables ES module syntax -->
<script type="module" src="main.js"></script>
<!-- Inline module -->
<script type="module">
import { add } from './utils/math.js';
console.log(add(1, 2));
</script>
Key differences with type="module":
- Strict mode by default
-
thisat top level isundefined(notwindow) - Scripts are deferred automatically (no
deferneeded) - Each module executes only once
- Cross-origin requires proper CORS headers
- Works with local files only in Firefox via
about:configor a local server (CORS blocks file:// protocol for modules)
Dynamic Imports
Load modules on demand, not at startup:
// Import when needed (returns a Promise)
const { default: Chart } = await import('./chart.js');
new Chart(data);
// Conditional import
if (userPreference === 'advanced') {
const { AdvancedFeatures } = await import('./advanced.js');
AdvancedFeatures.init();
}
// Load multiple at once
const [{ utils }, { components }] = await Promise.all([
import('./utils.js'),
import('./components.js'),
]);
Dynamic imports enable code splitting — only load code when it's needed.
Import Maps
Tell the browser where to find module specifiers (like Node.js package names):
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js",
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client",
"components/": "/src/components/"
}
}
</script>
<script type="module">
import _ from 'lodash'; // Resolved by import map
import React from 'react';
import { Button } from 'components/Button.js';
</script>
Import maps let you use bare specifiers without a bundler. Firefox 108+ supports import maps.
Module Patterns
Singleton
// db.js
let instance = null;
export function getDB() {
if (!instance) {
instance = createDatabaseConnection();
}
return instance;
}
Modules are cached — getDB() always returns the same instance.
Re-exporting
// index.js (barrel file)
export { Button } from './Button.js';
export { Modal } from './Modal.js';
export { Form, Input, Select } from './Form.js';
// Consumer
import { Button, Modal } from './components/index.js';
Factory Pattern
// logger.js
export function createLogger(prefix) {
return {
log: (msg) => console.log(`[${prefix}] ${msg}`),
error: (msg) => console.error(`[${prefix}] ${msg}`),
warn: (msg) => console.warn(`[${prefix}] ${msg}`),
};
}
Circular Dependencies
ESM handles circular imports — but you need to be careful about initialization order:
// a.js
import { b } from './b.js';
export const a = 'A + ' + b; // b might not be initialized yet!
// b.js
import { a } from './a.js';
export const b = 'B + ' + a;
When circular, one module's exports might be undefined on first use. The fix: use functions that are called after all modules initialize:
// a.js
import { getB } from './b.js';
export function getA() { return 'A + ' + getB(); } // Called later, b is ready
// b.js
import { getA } from './a.js';
export function getB() { return 'B + ' + getA(); }
Modules vs Scripts
| Classic Scripts | ES Modules | |
|---|---|---|
| Execution | Immediately | Deferred |
Top-level this
|
window |
undefined |
| Strict mode | Opt-in | Always |
| Duplicate execution | Each <script> runs |
Cached, run once |
| CORS required | No | Yes (for cross-origin) |
import/export
|
No | Yes |
Using Modules Without a Bundler
For small projects, modules work natively in Firefox without any build step:
/my-app
index.html
main.js
utils/
math.js
dom.js
components/
button.js
modal.js
<!-- index.html -->
<script type="module" src="main.js"></script>
Serve with any local HTTP server:
npx serve .
# or
python3 -m http.server 8080
Firefox loads all modules via HTTP — no bundler needed for small apps.
Module Workers
// Main thread
const worker = new Worker('/workers/processor.js', { type: 'module' });
// workers/processor.js
import { processData } from '../utils/processing.js'; // ES imports work!
self.onmessage = async (event) => {
const result = await processData(event.data);
self.postMessage(result);
};
Module workers support import — no global scope pollution.
DevTools: Module Inspection
In Firefox Debugger:
- All loaded modules appear in the file tree
- Dynamic imports show up as new files when they load
- Breakpoints work in imported modules
- The "Sources" column shows which file a script came from, including the full module path
Top comments (0)