← Back to Chapters

JavaScript Modules

? JavaScript Modules

⚡ Quick Overview

JavaScript modules let you split your code into separate files so that each file has its own scope and responsibility. A module can export values (functions, variables, classes) and other modules can import them. Modern browsers support ES Modules directly using the type="module" attribute on <script>.

? View Basic Module Setup (HTML)
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Modules Demo</title>
</head>
<body>
  <h1>JavaScript Modules Demo</h1>
  <script type="module" src="./app.js"></script>
</body>
</html>

? Key Concepts

  • Module: A separate JavaScript file with its own scope.
  • Named exports: Export multiple named bindings from a module.
  • Default export: The “main” value a module exposes.
  • Live bindings: Imports always reflect the latest value of exported variables.
  • Module scope: Variables in a module do not leak to window.
  • Strict mode: ES modules are automatically in strict mode.
  • Dynamic import: Load modules on demand with import().
  • Import maps: Map bare specifiers (like "lodash") to URLs.

? Syntax and Theory

? Named and Default Exports

A module can have many named exports and at most one default export. Named exports must be imported using their names, while the default export can be imported with any name.

? View Exports (math.js)
// math.js
export const PI = 3.14159;

export function area(r) {
  return PI * r * r;
}

export default function sum(a, b) {
  return a + b;
}
? View Imports (app.js)
// app.js
import add, { PI, area } from "./math.js";

console.log(add(2, 3));    // 5
console.log(PI);           // 3.14159
console.log(area(10));     // 314.159

? Import Syntax Variants

ES Modules provide flexible syntax for importing exports: namespace imports, renamed imports, and imports for side effects only.

? View Import Variants
// Import everything into a namespace object
import * as utils from "./utils.js";
utils.doThing();

// Rename on import
import { connect as open, close } from "./db.js";

// Import for side effects only (no bindings)
import "./polyfills.js";

⚡ Dynamic Import

Use dynamic import to load modules on demand. It returns a promise that resolves to the module object. This is useful for code-splitting and loading heavy features only when needed.

? View Dynamic Import Example
// app.js
const btn = document.querySelector("#chart");

btn.addEventListener("click", async () => {
  const { renderChart } = await import("./charts.js");
  renderChart();
});

? Live Bindings

Imports are live bindings, not copies. If the exporting module updates an exported variable, all importers see the new value automatically.

? View Live Binding Example
// state.js
export let count = 0;

export function inc() {
  count++;
}
? View Consumer Module (app.js)
// app.js
import { count, inc } from "./state.js";

console.log(count); // 0
inc();
console.log(count); // 1 (live binding)

? Module Scope and Strict Mode

Each module has its own scope and runs in strict mode automatically. Top-level variables in a module do not become properties on the global window object.

? View Module Scope Example
// a.js
const secret = "scoped";

console.log(window.secret); // undefined

? Using Modules in the Browser

In the browser, you can import modules inline or via src. Module scripts are deferred by default, so they load and execute after HTML parsing.

? View Browser Module Example
<!-- index.html -->
<script type="module">
  import { hello } from "./hello.js";
  hello();
</script>

<!-- Defer is implicit for module scripts; they load asynchronously. -->

Note: Local module files should be served over HTTP(S). Opening an HTML file directly from the filesystem can block module loading due to CORS/security restrictions.

?️ Import Maps (Optional)

Import maps let browsers resolve bare specifiers (like "lodash") to actual URLs without bundlers.

? View Import Map Example
<script type="importmap">{
  "imports": {
    "lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js"
  }
}</script>

<script type="module">
  import { chunk } from "lodash";

  console.log(chunk([1, 2, 3, 4], 2));
</script>

?️ Use Cases and Project Structure

Modules shine in medium-to-large applications where keeping related logic together is crucial. Common patterns include organizing code by feature (auth, charts, dashboard) or by technical layer (API, UI, utils).

? View Typical Project Structure
src/
  app.js
  utils/
    dom.js
    net.js
  features/
    auth/
      index.js
      api.js
    charts/
      index.js

? Working with Build Tools and Node

In Node.js or older browsers, you might need a bundler or transpiler. For native ES Modules support in Node, either use the .mjs extension or set "type": "module" in package.json.

? View package.json Example
// package.json
{
  "name": "app",
  "type": "module"
}

? Live Output / Explanation

In the math.js / app.js example:

  • console.log(add(2, 3)); prints 5 because the default export sum is imported as add.
  • console.log(PI); prints 3.14159, the named export.
  • console.log(area(10)); prints 314.159, computing PI * r * r.

In the state.js example, calling inc() updates the exported variable count. Because imports are live bindings, the second console.log(count) shows 1 instead of 0.

For dynamic imports, when the user clicks the #chart button, the browser fetches charts.js only then, reducing the initial load time of your application.

? Tips & Best Practices

  • Prefer named exports for libraries or modules that expose many functions.
  • Use a default export for single-purpose modules (e.g., one main function or class).
  • Keep modules small and cohesive; aim for one responsibility per file.
  • Use dynamic imports for heavy, rarely used features to reduce initial bundle size.
  • Always run your code from a local server; modules often fail when opened directly from the filesystem.
  • Be consistent with relative paths like ./ and ../ to avoid confusing import errors.
  • Do not mix require (CommonJS) and import (ES Modules) in the same file unless tooling explicitly supports it.
  • Remember that imported bindings are read-only views; you can modify their internal state but cannot reassign the imported name.

? Try It Yourself

  • Create a greet.js module that exports a default function and a named constant. Import both into main.js and log them to the console.
  • Add a button to your page that uses a dynamic import to load a heavy module (for example, a charting library) only when the button is clicked.
  • Refactor a large script into two modules: api.js for data fetching and ui.js for DOM rendering. Then create an app.js that imports and wires them together.
  • Experiment with an import map that maps a bare specifier to a CDN URL and use it inside a small demo page.