⌨️ Web development

ES modules in JavaScript: import, export and browser use

How ES modules work: named and default exports, dynamic import, script type module, import maps and the differences from CommonJS, with examples.

For a long time JavaScript had no native module system. Scripts were included in order with several <script> tags, hoping global variables would not collide, or external solutions such as CommonJS in Node.js were used. Today the language has its own ES modules (ECMAScript modules), supported by every modern browser and by Node.js. Understanding them well helps you write tidy code and make better use of build tools.

What a module is

A module is a file with its own scope: variables declared inside it do not become global. The module explicitly decides what to expose with export, and other files decide what to use with import.

// maths.js
export const PI = 3.14159;

export function circleArea(radius) {
  return PI * radius * radius;
}

function internalHelper() {
  // not exported: invisible from outside
}
// main.js
import { circleArea } from './maths.js';

console.log(circleArea(2));

Named exports and default export

There are two forms of export.

Named exports: a module can have as many as it likes, and importers use the same names, in curly braces. They can be renamed with as.

import { circleArea as area, PI } from './maths.js';

Default export: at most one per module, imported without braces and under whatever name you prefer.

// formatDate.js
export default function formatDate(date) {
  return date.toLocaleDateString('en-GB');
}

// elsewhere
import format from './formatDate.js';

Many teams prefer to use named exports only: names stay consistent across the project, editors autocomplete imports better and code search is more reliable. It is a convention, not a rule of the language.

You can also import everything as an object, with import * as maths from './maths.js', or re-export from an index file: export { circleArea } from './maths.js'.

Important characteristics of modules

  • Automatic strict mode: every module runs in strict mode without needing to declare it.
  • Evaluated only once: if several files import the same module, its code runs once and they all share the same instance. This is useful for configuration and shared state, but it is worth keeping in mind.
  • Live bindings: an import is not a copy of the value but a reference to the export. If the source module updates an exported variable, importers see the new value. You cannot, however, reassign an import from outside.
  • Static structure: import and export statements sit at the top level of the file and cannot be inside conditions. This very staticness allows build tools to analyse dependencies and remove unused code (tree shaking).

Modules in the browser

To load a module directly in the browser, all you need is the type="module" attribute:

<script type="module" src="/js/main.js"></script>

Some differences compared with classic scripts:

  • modules are deferred by default: they run after the HTML has been parsed, as with defer;
  • import paths must be full or relative URLs, with the extension: ./utils.js, not utils;
  • modules are loaded following CORS rules, so opening the HTML file directly from disk (file://) usually does not work: you need a small local server;
  • this at the top level is undefined, not the global object.

For very old browsers there was the nomodule attribute, which loads a script only where modules are not supported; today it is rarely needed.

Import maps

In the browser an import such as import { format } from 'date-fns' does not work on its own, because the browser does not know where date-fns is. Import maps let you map short names to URLs:

<script type="importmap">
{
  "imports": {
    "utils/": "/js/utils/",
    "charts": "/vendor/charts.esm.js"
  }
}
</script>
<script type="module">
  import { draw } from 'charts';
  import { format } from 'utils/format.js';
</script>

For small projects, import maps and native modules can entirely replace a build tool.

Dynamic import

The import() function loads a module on demand and returns a promise. It is the tool for splitting code and downloading heavy features only when they are needed:

exportButton.addEventListener('click', async () => {
  const { exportPdf } = await import('./pdf-export.js');
  exportPdf(doc);
});

Anyone who never clicks the button never downloads that code. Build tools recognise import() and automatically create a separate file.

Modules also support top-level await, useful for example to load a configuration before continuing. Use it sparingly, because it blocks the execution of modules that depend on the current one.

ES modules and CommonJS

In Node.js two systems coexist: CommonJS (require and module.exports) and ES modules. The main differences:

Aspect CommonJS ES modules
Syntax require(), module.exports import, export
Loading synchronous asynchronous
Static analysis limited complete
In the browser no, without tools yes, natively

In Node.js a file is treated as an ES module if it has the .mjs extension or if package.json contains "type": "module". For new projects, ES modules are the choice to prefer: they are the language standard and work in both environments.

In short

  • A module has its own scope and exposes only what it declares with export.
  • Named exports keep names consistent; there is at most one default export per file.
  • In the browser they are used with type="module", are deferred and need paths with extensions.
  • Import maps map short names to URLs; import() loads code only when needed.
  • For new projects, prefer ES modules to CommonJS in Node.js too.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also