In this blog post, I'll explore JavaScript modules, covering everything from the basics to common pitfalls.

  1. What is a Module?
  2. History of modules
  3. Syntax
  4. Default vs. Named Exports
  5. Dynamic Module Loading
  6. Top-Level Await
  7. Common Mistakes: Cyclic imports

What is a Module?

A module is a piece of program that specifies which other pieces it relies on and which functionality it provides for other modules to use (its interface).

Eloquent JavaScript

Modules enhance code readability, reusability, maintainability, and manageability. By dividing the code into smaller chunks, each focusing on a specific feature, modules make the code easier to understand, reuse in different parts, and fix or update when issues arise.

History of modules
In the early days, JavaScript was relatively small, but as it grew larger and more complex, the need for separating code into files or modules evolved.

Syntax
Export
Add the keyword export in front of any item, such as a const or function, to make it available for use in other modules.

Example

export function sayGoodMorning(name) {...}

Import
Use the keyword import to bring in the items you need, specifying the file you want to import them from.

Example

import { sayGoodMorning } from "./greeting.js"

Default vs. Named Exports
Named Exports
Name exports allow multiple items to be exported from one file. This can be done by adding export to each item individually or exporting all items as a list.

Exporting Individually:
greeting.js

export function sayGoodMorning(name) {...}

export function sayGoodAfternoon(name) {...}

export function say GoodEvening(name) {...}

Exporting as a List:
greeting.js

function sayGoodMorning(name) {...}

function sayGoodAfternoon(name) {...}

function sayGoodEvening(name) {...}

export { sayGoodMorning, sayGoodAfternoon, sayGoodEvening };

Importing Named Exports:
index.js

import { sayGoodMorning, sayGoodAfternoon, sayGoodEvening } from "./greeting.js";

Default Exports
Default exports are used to export a single primary item from a file. This is achieved by adding the default keyword.

Exporting with default:
greeting.js

export default function sayHi(name) {...}

Or

function sayHi(name) {...}

export default sayHi;

Importing Default Exports:
Brackets are not needed when importing default exports.

index.js

import sayHi from "./greeting.js";

Renaming Imports
To avoid naming conflicts, imported items can be renamed using the as keyword.

index.js

import { sayGoodMorning as morning, sayGoodAfternoon as afternoon, sayGoodEvening as evening } from "./greeting.js";

Importing Everything as an Object:
All exports can be imported at once as an object using the asterisk.

index.js

import * as greeting from "./greeting.js";

greeting.sayGoodMorning("John");

Key Differences
Named Exports: Require the exact names of items to be used during import.
Default Exports: Allow the imported item to be renamed freely.

Note: The Modern JavaScript Tutorial recommends avoiding renaming imports unnecessarily to maintain consistency across codebases.

Dynamic Module Loading
The import statement, in its static form, must be at the top level of a file, with the file name specified as a string. It cannot be inside conditional statements like if because static imports are resolved before the module executes. Additionally, all modules are bundled into a single file during the build process, and unused exports are removed.

Dynamic module loading, introduced more recently, allows modules to be loaded only when needed. This is achieved by calling import() as a function, which returns a Promise.

Example:

import("./modules/myModule.js").then((module) => {
  // Do something with the module.
});

MDN

Top-Level Await
Top-level await enables modules to behave like asynchronous functions, letting code execute before use in parent modules, without blocking the loading of sibling modules. This is useful for situations where you want to await the resolution of promises at the top level of a module.

The example is from MDN:

colors.json

{
  "yellow": "#F4D03F",
  "green": "#52BE80",
  "blue": "#5499C7",
  "red": "#CD6155",
  "orange": "#F39C12"
}

getColors.js

// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());

export default await colors; // await keyword is used here

The module will wait for the colors data to be fetched before it’s exported.

main.js

import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";

const circleBtn = document.querySelector(".circle");

Common Mistakes: Cyclic imports
The relationship between imports and exports can be cyclic, meaning Module A imports Module B, and Module B also depends on Module A.
While this isn't wrong, it requires careful logic to avoid errors.

This doesn't work:

// -- a.js (entry module) --
import { b } from "./b.js"; (1)

export const a = 2;

// -- b.js --
import { a } from "./a.js";

console.log(a); // ReferenceError: Cannot access 'a' before initialization
export const b = 1;

This is synchronous code. When (1) b.js is evaluated, it tries to access a from a.js. However, a has not been initialized yet, causing a ReferenceError.

Making it asynchronous solves the issue:

// -- a.js --
import { b } from "./b.js";

setTimeout(() => {
  console.log(b); // 1
}, 10);

export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);

export const b = 1;

However, MDN advises avoiding cycling imports, as they can easily lead to unexpected errors.

Modules play a key role in both browsers and Node.js. I'll come back to this topic when exploring Node.js in more detail.