13 Modules and bundlers
JavaScript modules evolved from a messy era of global variables, script ordering, and competing conventions into a more disciplined system built around explicit dependencies. The chapter explains how CommonJS became the practical server-side standard, why browser-oriented workarounds like AMD and UMD were limited, and how ECMAScript Modules (ESM) finally provided a unified, statically analyzable format. ESM makes code easier to reason about, supports tree-shaking, and gives tooling enough structure to detect unused code and dependency mistakes before runtime.
The chapter then walks through the core ESM syntax and behavior: named exports, named imports, default exports, namespace imports, and the export list form. It emphasizes that imports are live bindings rather than ordinary variables, meaning they reflect changes in the exporting module but cannot be reassigned by importers. It also covers how modules are resolved in practice, including Node.js package entry points, TypeScript’s module settings and extension rules, and browser import maps for mapping bare specifiers to URLs. Along the way, it highlights common pitfalls such as extension confusion, barrel files, and overusing namespace imports when only a few exports are needed.
The chapter also tackles interoperability and build tooling. CommonJS and ESM do not line up perfectly: CommonJS treats the module as an object, which creates the “double-default” issue when ESM default exports are consumed through CommonJS, and top-level await complicates matters because CommonJS is inherently synchronous. Finally, the chapter explains why bundlers remain essential even in an ESM world: they reduce loading waterfalls, split code into efficient chunks, support lazy loading, transform TypeScript and newer syntax for older browsers, and manage non-JavaScript assets. The overall message is that modern JavaScript delivery is about balancing compatibility, performance, and maintainability with the right mix of ESM, CommonJS interop, and bundling strategies.
Summary
- ECMAScript Modules (ESM) are the standard way to express dependencies across JavaScript files.
- ESM supports both default exports and named exports, and can mix the two freely.
- ESM uses live binding: Exported values can be reassigned by the module that exported them, while importers can only read the exported value.
- The static import keyword is resolved when the module is parsed, while the async import() is executed at runtime.
- CommonJS (the old Node.js standard) is still used in many projects, though the trend is to move to ESM.
- As a general rule, CommonJS can be imported from ESM without any trouble. Importing ESM from CommonJS is trickier: The module’s exports are collapsed into a single object, which means that consumers have to reference the default export with .default.
- Top-level await is supported in ESM, but will cause an error if that module is loaded in CommonJS, directly or indirectly.
- Using a bundler to combine modules into “chunks” of JavaScript is essential for web performance. Tools like Vite incorporate a bundler with other build steps like TypeScript compilation and minification.
- JavaScript can be transpiled to target a specific set of browsers using Babel and Browserslist.
FAQ
What problem did JavaScript modules solve in the early ecosystem?
JavaScript modules provided an explicit way to define dependencies and exports, avoiding global name collisions, script-order bugs, hidden dependency chains, and unnecessary loading of unused scripts. They also made it possible for tools to analyze module graphs at build time.What is the difference between CommonJS and ESM?
CommonJS uses runtime constructs likerequire() and module.exports, while ESM uses static import and export syntax. ESM is easier to analyze ahead of time, works well with tree-shaking, and is the modern standard for JavaScript modules.How do named exports work in ESM?
Named exports are created with theexport keyword, such as export const homeIsland = "Tsushima"; or export function getOccupation() { ... }. Other modules can import those values by name, and the names must match unless you rename them with as.What is the purpose of the as keyword in imports and exports?
The as keyword lets you rename an import or export. It is useful for avoiding naming conflicts or giving a value a more convenient local name, such as importing getOccupation as getTitle.What is a default export in ESM?
A default export is a module’s primary export, declared withexport default. It has no required name from the importer’s perspective, so consumers can choose any local identifier when importing it, such as import hero from "./module.js";.What is a namespace import, and when should you use it?
A namespace import usesimport * as name from "./module.js"; to collect all exports into a sealed object-like namespace. It is useful when you need access to many exports or want to inspect them dynamically, but it can reduce tree-shaking efficiency if you import more than you need.What are live bindings in ESM?
Live bindings mean that an imported value always reflects the current value of the original export. If the exporting module reassigns alet export, importers see the updated value, but they cannot reassign the imported binding themselves.Why do file extensions matter in ESM imports?
In Node.js ESM, relative imports generally require explicit file extensions, unlike CommonJSrequire(). TypeScript’s behavior depends on module resolution settings, and newer setups may require or rewrite extensions so imports work both during development and after compilation.What is the “double-default problem” when mixing ESM and CommonJS?
When ESM imports CommonJS, the CommonJS module often appears as a single object, and its original exports may be available under adefault property. This can make default imports behave unexpectedly, especially when TypeScript compiles ESM into CommonJS output.
Secrets of the JavaScript Ninja, Third Edition ebook for free