ข้ามไปที่เนื้อหา
Clinford Jordano Valencia
บทความทั้งหมด

JavaScript

How JavaScript Modules Work: Scope, Live Bindings and Cycles

ES modules and CommonJS load, share and evaluate code differently. Live bindings, singletons, circular imports and the rules that decide what you actually get.

  • อ่าน 7 นาที

หน้านี้ยังไม่มีฉบับภาษาไทย จึงแสดงต้นฉบับภาษาอังกฤษแทน

A module is a file with its own scope that decides what to share. JavaScript has two module systems in daily use, ES modules (import/export) and CommonJS (require/module.exports), and they differ in ways that cause real bugs: whether you get a live binding or a copied value, when code runs, and what happens when two modules import each other. This post explains both, with the loading steps drawn out and every example run in Node.js.

Why It Matters

Module behavior decides:

  • whether your feature flags update
  • whether two parts of an app share one cache or two
  • why a circular import crashes only in production builds
  • why “dual” packages sometimes load twice

Bundlers, test runners and frameworks all build on these rules, so understanding them makes their error messages readable.

Module Scope

Every module has its own top-level scope. Variables declared there are invisible to other modules unless exported, and ES modules are always in strict mode with a top-level this of undefined. Before modules, everything shared one global scope, which is why old code wrapped files in immediately invoked functions.

ES Modules Load in Three Phases

An ES module graph is loaded before any of it runs:

  1. Parse each file and find its import and export statements
  2. Link: connect every import to the exporting variable (no code runs)
  3. Evaluate each module once, dependencies first

Because imports are found by parsing, they must be static: import declarations sit at the top level with string literals. That is what lets bundlers tree-shake unused exports. For loading at runtime, there is import(), which returns a promise.

Live Bindings vs Copied Values

In ES modules, an import is a live, read-only binding to the exporter’s variable. In CommonJS, require returns an object, and destructuring copies the values out of it at that moment:

counter.mjs
export let count = 0;
export function increment() {
  count++;
}
main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1: the binding is live
 
count = 5; // TypeError: Assignment to constant variable.
counter.cjs + main.cjs
// counter.cjs
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
 
// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0: count was copied when module.exports was built
ES modulesmain.mjscounter.mjslet countlive bindingCommonJSmain.cjscounter.cjsmodule.exportscount: 0 (copy)let countcopied oncechanges here aren’t seen
ES module imports point at the exporter’s variable. CommonJS hands over a snapshot in the exports object.

Practical consequences:

  • In ES modules, export a function (getCount()) or an object if consumers must see updates, and say so in the API. Live bindings work, but they surprise readers.
  • In CommonJS, require('./counter.cjs').count reads the exports object each time, but that property still holds the copied 0; only an exported getter or object reflects changes.
  • Imports are read-only for the importer. Only the exporting module can change its variable.

Every Module Is a Singleton

A module runs once, and every importer receives the same instance:

import './analytics.mjs'; // runs analytics.mjs
import './analytics.mjs'; // cached: does not run again
 
const a = await import('./analytics.mjs');
const b = await import('./analytics.mjs');
a === b; // true
  • Module-level variables are shared state. A const cache = new Map() at the top of a module is one cache for the whole app. Convenient, and also how tests leak state into each other; most test runners isolate modules per test file for this reason.
  • The cache is keyed by the resolved URL or path. The same package installed twice (different versions in node_modules), or imported once as ESM and once as CommonJS, is two modules with two copies of its state. That is the root of “two copies of React” errors.

Circular Imports

A cycle (A imports B, and B imports A) is allowed in both systems, but evaluation still has to start somewhere, so one module runs while the other is only partly initialized.

ES modules link first, so the bindings exist but are uninitialized. Reading a let, const or class export too early throws:

// a.mjs
import { b } from './b.mjs';
export const a = 'A';
 
// b.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log(a); // ReferenceError: Cannot access 'a' before initialization

Running a.mjs first evaluates b.mjs (its dependency), and at that point a.mjs hasn’t reached export const a. Function declarations are hoisted and initialized during linking, so calling an exported function from the other side of a cycle works, as long as the function doesn’t itself read an uninitialized binding.

CommonJS returns the partially filled module.exports instead of throwing:

// a.cjs
exports.loaded = false;
const b = require('./b.cjs');
exports.loaded = true;
 
// b.cjs
exports.loaded = false;
const a = require('./a.cjs');
console.log(a.loaded); // false: a.cjs is still in progress
exports.loaded = true;

That doesn’t crash, but you silently get undefined or stale values. Both failure modes have the same fix:

Going Deeper: Packages, Two Module Systems and Top-Level Await

The rules above explain one program. Real applications pull in hundreds of packages, some written as ES modules, some as CommonJS, and that is where the subtle production bugs live.

The Dual Package Hazard

Many packages ship two builds, one ES module and one CommonJS, and pick between them with the exports field in package.json. If your code imports the package while a dependency requires it, Node.js can load both builds. You now have two copies of the same module, each with its own state:

The Dual Package Hazard

your app (ESM)a library (CommonJS)pkg/index.mjspkg/index.cjsstate: cache Astate: cache Bimportrequiretwo copies: two caches, two singletons, instanceof fails between them
The dual package hazard: one package, loaded once as an ES module and once as CommonJS, gives two separate copies with separate state.
  • Symptoms: a configured client that “forgets” its configuration, a plugin registry with missing plugins, or error instanceof SomeError returning false for an error that clearly is one.
  • Package authors avoid it by keeping state in only one build (for example, a CommonJS core with a thin ES module wrapper) or by shipping a single format.
  • As a consumer, check npm ls <package> and your bundler output for duplicate copies.

Mixing the Two Systems

  • An ES module can import CommonJS: it receives module.exports as the default export, plus named exports Node.js can detect statically.
  • CommonJS can load ES modules with await import(). Since Node.js 22.12 and 20.19, require() can also load an ES module directly, as long as its module graph has no top-level await.

Top-Level Await Pauses the Graph

An ES module can await at its top level, for example to load configuration.

  • While it waits, every module that imports it waits too, because evaluation follows the dependency graph.
  • One slow top-level await deep in a shared module delays startup for everything above it, and a rejected one fails the whole import.
  • Keep top-level await to entry points, and keep shared libraries free of it.

ES Modules vs CommonJS at a Glance

ES modulesCommonJS
Syntaximport / exportrequire / module.exports
Imports areLive, read-only bindingsCopies of the exported values
AnalysisStatic, so tree-shaking worksDynamic, harder to tree-shake
LoadingAsynchronous; supports top-level awaitSynchronous
Strict modeAlwaysOnly with 'use strict'
Top-level thisundefinedmodule.exports
Cycle, early readReferenceErrorPartially filled exports object
In browsersNative (<script type="module">)Needs a bundler

For new code, use ES modules. They are the standard, work natively in browsers and Node.js, and make dependencies visible to tools.

Test Yourself

Each challenge spans several files. Predict what node main.mjs (or main.cjs) prints, then open the answer. Every answer was checked by running the files with Node.js 24.

โจทย์ที่ 1

A Default Export

counter.mjs
export let count = 0;
export default count;
export function inc() {
  count++;
}
main.mjs
import current, { count, inc } from './counter.mjs';
inc();
console.log(count, current);
ดูคำตอบ

1 0

  • The named import count is a live binding, so it shows the update.
  • export default count exports the value of an expression, evaluated once when the module ran: a separate, hidden binding holding 0.
  • Only export default function, export default class and export { count as default } create a live default binding.

โจทย์ที่ 2

A Getter in CommonJS

counter.cjs
let count = 0;
module.exports = {
  get count() {
    return count;
  },
  inc() {
    count++;
  },
};
main.cjs
const counter = require('./counter.cjs');
const { count } = counter;
counter.inc();
console.log(counter.count, count);
ดูคำตอบ

1 0

  • The getter makes counter.count read the module’s variable each time, so it shows 1.
  • Destructuring calls the getter once and copies the result into a local constant, which stays 0.
  • In CommonJS, live values only work if you keep reading through the exports object.

โจทย์ที่ 3

Evaluated Once

log.mjs
console.log('loading log');
export const id = Math.random();
a.mjs
import { id } from './log.mjs';
export const a = id;
main.mjs
import { a } from './a.mjs';
import { id } from './log.mjs';
console.log(a === id);
ดูคำตอบ

loading log, then true

  • A module is evaluated once per program, however many modules import it; every importer shares the same instance.
  • That makes modules natural singletons, and is why module-level state leaks between tests unless the test runner resets the module registry.

โจทย์ที่ 4

Where Does the Import Run?

b.mjs
console.log('b body');
a.mjs
console.log('a body');
import './b.mjs';
main.mjs
import './a.mjs';
console.log('main body');
ดูคำตอบ

b body, a body, main body

  • import declarations are hoisted: the whole graph is loaded and linked before any module body runs.
  • Dependencies are evaluated before the modules that import them, depth first.
  • Writing the import below a statement doesn’t delay it.

โจทย์ที่ 5

A Circular Import

a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log('a sees', b);
b.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log('b sees', a);
main.mjs
import './a.mjs';
ดูคำตอบ

ReferenceError: Cannot access 'a' before initialization, thrown in b.mjs

  • main loads a, and a depends on b, so b is evaluated first.
  • At that moment a.mjs hasn’t run, so its const a is in the temporal dead zone (see scope, hoisting and the temporal dead zone).
  • Fix: break the cycle, or read the import only later, inside a function called after both modules have run.

Key Takeaways

  • ES module imports are live, read-only bindings linked before code runs; CommonJS require returns values as they were at that moment.
  • Each module is evaluated once and cached by its resolved path, so module-level variables are shared state.
  • In a cycle, ES modules throw when a binding is read too early, and CommonJS hands back a half-filled exports object.
  • Break cycles by moving shared code into a third module, or by reading values lazily.
  • Prefer ES modules for new code: static imports enable tree-shaking and work natively everywhere.

Related reading: the hidden cost of mutable state covers why shared module state needs care, and execution contexts and closures explains the temporal dead zone behind the cycle error.

Sources

แท็ก

คำถามที่พบบ่อย

Why do I get “Cannot access ‘x’ before initialization” only in some import orders?

That is a circular import reading a let, const or class export before the exporting module has run that line. Move the shared value into a third module, or read it lazily inside a function that runs later.

Can I mix ES modules and CommonJS?

YesAn ES module can import CommonJS; it gets module.exports as the default export. Since Node.js 22 (and later 20.x releases) require can also load synchronous ES modules. Mixing still has edge cases, so pick one format per package when you can.

Does a module run every time it is imported?

NoEach module is evaluated once per process (or per page), and every importer gets the same instance. That is why module-level state is shared.

What is the dual package hazard?

When a package ships both an ES module and a CommonJS build, an application can end up loading both, for example when your code imports it and a dependency requires it. Each copy has its own state, so singletons, caches and instanceof checks break between them.

What does top-level await do to module loading?

It pauses evaluation of that module, and every module that depends on it waits until the promise settles. A slow or failing top-level await in a shared module delays or breaks everything that imports it, so keep it to entry points.

Should I use default or named exports?

Named exports are usually easier to work with: names stay consistent across files, editors can auto-import and rename them reliably, and bundlers can tree-shake them. Default exports are fine for a module with one obvious main value, such as a React page component.

What is tree shaking, and why does it need ES modules?

Tree shaking removes unused exports from a bundle. It works with ES modules because imports and exports are static and known before the code runs; with CommonJS, require calls and exports objects can change at run time, so bundlers must keep everything.

How do I reset module state between tests?

Test runners such as Vitest and Jest can reset their module registry (vi.resetModules, jest.resetModules) so modules are evaluated again. Better still, avoid hidden module-level state: export a function that creates the state, and pass it where it is needed.