The JavaScript Modules Handbook – Complete Guide to ES Modules and Module Bundlers

JavaScript modules allow you to break up your code into separate files. This lets you organize your code, encapsulate details, and manage dependencies between different parts of your program. A module is simply a reusable piece of code that has its own top-level scope, which avoids polluting the global namespace.

Modules are a critical piece of any non-trivial JavaScript program. In this handbook, you‘ll learn everything you need to know about using modules effectively in your code. We‘ll cover the different ways modules are defined, the syntax for importing and exporting code in modules, and the tools used to bundle modules together.

A Brief History of JavaScript Modules

The concept of modules is not new, but it took a while for an official module system to arrive in JavaScript. Back in the early days, developers used immediately invoked function expressions (IIFEs) and global objects to achieve a similar effect to modules:

var myModule = (function() {
  var privateThing = ‘secret‘;

  function publicThing() {
    console.log(‘hello!‘);
  }

  return {
    publicThing: publicThing
  };
})();

myModule.publicThing(); // works
myModule.privateThing; // undefined

The next iteration on this idea was CommonJS modules. This format uses the module.exports object to export public code from a file and the require function to import it in another file:

// myModule.js
var privateThing = ‘secret‘;

function publicThing() {
  console.log(‘hello!‘);
}

module.exports = {
  publicThing: publicThing
};

// otherFile.js
var myModule = require(‘./myModule.js‘);
myModule.publicThing();

CommonJS modules were popularized by Node.js and are still widely used in the ecosystem today. However, they were never officially adopted by browsers.

Around the same time, the Asynchronous Module Definition (AMD) format was created. AMD uses a define function to encapsulate modules and explicitly lists dependencies that are loaded asynchronously:

define([‘dep1‘, ‘dep2‘], function(dep1, dep2) {
  return function() {};
});

AMD was designed with browsers in mind and is used by projects like RequireJS.

The official module format shipped with ES2015 (ES6). Called ES modules, this format resembles CommonJS in many ways, but with a more declarative syntax:

// myModule.mjs
var privateThing = ‘secret‘;

export function publicThing() {
  console.log(‘hello!‘);
}

// otherFile.mjs
import { publicThing } from ‘./myModule.mjs‘;
publicThing();

ES modules are now supported by all major browsers and have become the recommended way to write modular JavaScript. Tools like Babel can transpile ES module syntax to other formats for broader browser support.

Understanding ES Modules

Let‘s take a closer look at the syntax and features of ES modules. To use this format, files need to be defined with a .mjs extension or set a type="module" attribute on the script tag.

Named Exports

There are two types of exports from an ES module: named exports and default exports. With named exports, you can export multiple items from a single module, each with its own name:

// myModule.mjs
export const myNumber = 42;
export function myFunc() {}
export class MyClass {}

These exports are then imported using the import keyword and placed within curly braces:

import { myNumber, myFunc, MyClass } from ‘./myModule.mjs‘;

You can also rename named imports and exports using the as keyword:

// myModule.mjs
export { myNumber as num, myFunc as func };

// otherFile.mjs
import { num, func } from ‘./myModule.mjs‘;

If you need to import everything from a module, you can use the * wildcard:

import * as myModule from ‘./myModule.mjs‘;
myModule.myNumber;
myModule.myFunc();

Default Exports

An ES module can have a single export default:

// myModule.mjs
export default function() {}

This default export can then be imported with any name:

import myDefaultThing from ‘./myModule.mjs‘;

You can combine a default export with additional named exports, but I recommend sticking with one or the other for simplicity.

Modules are evaluated only once, the first time they are imported. If the same module is imported into multiple other modules, they will all get the same instance.

Dynamic Imports

A recent addition to ES modules is dynamic imports, which allow you to conditionally load modules at runtime:

if (someCondition) {
  import(‘./myModule.mjs‘).then((module) => {
    // Do something with the module
  });
}

The import() syntax returns a promise that resolves to the module namespace object.

Bundling Modules with webpack

ES modules are great for breaking up your code into manageable pieces. However, you probably don‘t want to ship hundreds of small files to the browser. This is where a module bundler comes into play.

A module bundler analyzes your dependency graph and concatenates everything into optimized bundles. Some popular options are webpack, Rollup, and Parcel. For this guide, we‘ll focus on webpack.

webpack works by having you specify an entry point to your application and letting it recursively build your dependency graph. webpack has a configuration file where you specify your entry point, output file, and any additional processing steps:

// webpack.config.js
const path = require(‘path‘);

module.exports = {
  entry: ‘./src/index.js‘,
  output: {
    filename: ‘main.js‘,
    path: path.resolve(__dirname, ‘dist‘),
  },
};

When you run the webpack command, your modules will be bundled into the dist/main.js file.

One of the most powerful features of webpack is loaders. Loaders let you perform additional processing on files as they are imported:

module.exports = {
  module: {
    rules: [
      {
        test: /\.mjs$/,
        exclude: /node_modules/,
        use: {
          loader: ‘babel-loader‘,
        }
      }
    ]
  }
};

This configuration will run all .mjs files through the Babel loader, allowing you to use ES modules even in browsers that don‘t support them.

Another key feature is plugins. Plugins can be used to perform additional optimizations on your output bundle:

const TerserPlugin = require(‘terser-webpack-plugin‘);

module.exports = {
  optimization: {
    minimizer: [new TerserPlugin()]
  }
};

This will run the Terser minification library on your final bundle to reduce its size.

Advanced Tips and Tricks

Here are a few more things to keep in mind as you start using modules in your own projects.

Code Splitting

By default, webpack will bundle your entire application into a single file. For large apps, this can lead to long initial load times. One solution is code splitting – splitting your bundle into multiple files that can be loaded on demand.

The simplest way to do code splitting is with dynamic import():

button.addEventListener(‘click‘, () => {
  import(‘./myModule.mjs‘).then((module) => {
    module.default();
  });
});

webpack will automatically split myModule.mjs into a separate bundle that is only loaded when the button is clicked.

For more advanced code splitting, you can use webpack‘s SplitChunksPlugin to extract common dependencies or create separate vendor bundles.

Tree Shaking

Another way to optimize your final bundle size is a technique called tree shaking. Tree shaking means removing code that is never used in your application.

webpack will automatically perform tree shaking when used in production mode with an ES modules based project. For this to work effectively, avoid using the default export and always use explicit named imports:

// myModule.mjs
export const myFunc = () => {};
export const unused = () => {};

// otherFile.mjs
import { myFunc } from ‘./myModule.mjs‘;
myFunc();

In this example, unused will be removed from the final bundle since it is never imported.

Circular Dependencies

One potential gotcha with modules is circular dependencies. This happens when two modules depend on each other:

// a.mjs
import { b } from ‘./b.mjs‘;
export function a() {
  b();
}

// b.mjs
import { a } from ‘./a.mjs‘;
export function b() {
  a();
}

Circular dependencies like this can lead to unexpected behavior. In general, you should avoid them by restructuring your modules or using dependency injection.

Conclusion

JavaScript modules are an essential tool for creating maintainable and scalable code. The official ES module syntax has made it easier than ever to break your code into reusable pieces. Module bundlers like webpack also provide a ton of power and flexibility for optimizing your code for production.

I hope this guide has helped demystify modules and given you the knowledge you need to start using them effectively in your own projects. Go forth and modularize!

Similar Posts