The Significance of Modules in JavaScript Applications

It is very important to structure clean code in a modular way. In the next few sections of this tutorial, we'll introduce you to the concept of modular design, before explaining the different module formats. But first, let's remind ourselves why modular design is important. Without it, the following apply:

  • Logic from one business domain can easily be interwoven with that of another
  • When debugging, it's hard to identify where the bug is
  • There'll likely be duplicate code

Instead, writing modular code means the following:

  • Modules are logical separations of domains—for example, for a simple social network, you might have a module for user accounts, one for user profiles, one for posts, one for comments, and so on. This ensures a clear separation of concerns.
  • Each module should have a very specific purpose—that is, it should be granular. This ensures that there is as much code reusability as possible. A side effect of code reusability is consistency, because changes to the code in one location will be applied everywhere.
  • Each module provides an API for other modules to interact with—for example, the comments module might provide methods that allow for creating, editing, or deleting a comment. It should also hide internal properties and methods. This turns the module into a black box, encapsulating internal logic to ensure that the API is as minimal as is practical.

By writing our code in a modular way, we'll end up with many small and manageable modules, instead of one uncontrollable mess.

The dawn of modules

Modules were not supported in JavaScript until ECMAScript 2015, because JavaScript was initially designed to add small bits of interactivity to web pages, not to cater for building full-fledged applications. When developers wanted to use a library or framework, they'd simply add <script> tags somewhere inside the HTML, and that library would be loaded when the page was loaded. However, this is not ideal as the scripts must be loaded in the correct order. For example, Bootstrap (a UI framework) depends on jQuery (a utility library), so we must manually check that the jQuery script is added first:

<!-- jQuery - this must come first -->

<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>

 

<!-- Bootstrap's JavaScript -->

<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>

This is fine as the dependency tree is relatively small and shallow. However, as single-page applications (SPAs) and Node.js applications become more popular, applications inevitably become more complex. Having to manually arrange hundreds of modules in the correct order is impractical and error-prone:

Figure 1: The dependency tree for the Cordova npm package, where each node represents a discrete module

Furthermore, many of these scripts add variables to the global namespace, or extend the prototypes of existing objects (for example, Object.prototype or Array.prototype). Because they are usually not namespaced, the scripts can clash/interfere with each other, or with our code.

Because of the increasing complexity of modern applications, developers started creating package managers to organize their modules. Moreover, standard formats began to appear so that modules could be shared with the wider community.

Currently, there are three major package managers—npmBower, and yarn—and four major standards in defining JavaScript modules—CommonJSAMDUMD, and ES6 modules. Each format also has accompanying tools to enable them to work on browsers, such as RequireJSBrowserifyWebpackRollup, and SystemJS.

In the following section, we'll give a quick overview of different types of package managers, modules, and their tools. At the end of this section, we'll look more specifically at ES6 modules.

The birth of Node.js modules

Using modules on the client was infeasible because an application can have hundreds of dependencies and sub-dependencies; having to download them all when someone visits the page is going to increase the time-to-first-render(TTFR), drastically impacting the user experience (UX). Therefore, JavaScript modules, as we know them today, began their ascent on servers with Node.js modules.

In Node.js, a single file corresponds to a single module:

$ tree

.

├── greeter.js

└── main.js

0 directories, 2 files

For instance, both of the preceding files—greeter.js and main.js—are each their own module.

Adoption of the CommonJS standard

In Node.js, modules are written in the CommonJS format, which provides two global objects, require and exports, that developers can use to keep their modules encapsulated. require is a function that allows the current module to import and use variables defined in other modules. exports is an object that allows a module to make certain variables publicly available to other modules that require it.

For example, we can define two functions, helloWorld and internal, in greeter.js:

// greeter.js

const helloWorld = function (name) {

  process.stdout.write(`hello ${name}!\n`)

};

const internal = function (name) {

  process.stdout.write('This is a private function')

};

exports.sayHello = helloWorld;

By default, these two functions can only be used within the file (within the module). But, when we assign the helloWorld function to the sayHello property of exports, it makes the helloWorld function accessible to other modules that require the greeter module.

To demonstrate this, we can require the greeter module in main.js and use its sayHello export to print a message to the console:

// main.js

const greeter = require('./greeter.js');

greeter.sayHello("Daniel");

Note

To require a module, you can either specify its name or its file path.

Now, when we run main.js, we get a message printed in the Terminal:

$ node main.js 

hello Daniel!

Fulfilling the encapsulation requirement

You can export multiple constructs from a single module by adding them as properties to the exports object. Constructs that are not exported are not available outside the module because Node.js wraps its modules inside a module wrapper, which is simply a function that contains the module code:

(function(exports, require, module, __filename, __dirname) {

  // Module code

});

This fulfills the encapsulation requirement of modules; in other words, the module restricts direct access to certain properties and methods of the module. Note that this is a feature of Node.js, not CommonJS.

Standardizing module formats

Since CommonJS, multiple module formats have emerged for client-side applications, such as AMD and UMD. AMD, or Asynchronous Module Definition, is an early fork of the CommonJS format, and supports asynchronous module loading. This means modules that do not depend on each other can be loaded in parallel, partially alleviating the slow startup time that clients face if they use CommonJS on the browser.

Whenever there are multiple unofficial standards, someone will usually come up with a new standard that's supposed to unify them all:

Figure 2: From the XKCD comic titled "Standards" (https://xkcd.com/927/); used with permission under a Creative Commons Attribution-NonCommercial 2.5 License (http://creativecommons.org/licenses/by-nc/2.5/

This is what happened with UMD, or Universal Module Definition. UMD modules are compatible with both AMD and CommonJS, and this also exposes a global variable if you want to include it on your web page as a <script> tag. But because it tries to be compatible with all the formats, there's a lot of boilerplate.

Eventually, the task of unifying JavaScript module formats was taken on by the Ecma International, which standardized modules in the ECMAScript 2015 (ES6) version of JavaScript. This module format uses two keywords: import and export. The same greeter example would look like this with ES6 modules:

// greeter.js

 

const helloWorld = function (name) {

  process.stdout.write(`hello ${name}!\n`)

};

const privateHellowWorld = function (name) {

  process.stdout.write('This is a private function')

};

export default helloWorld;

 

// main.js

 

import greeter from "./greeter.js";

greeter.sayHello("Daniel");

You'd still have two files—greeter.js and main.js; the only difference here is that exports.sayHello = helloWorld; is replaced by export default helloWorld;, and const greeter = require('./greeter.js'); is replaced by import greeter from "./greeter.js";.

Furthermore, ES6 modules are static, meaning they cannot be changed at runtime. In other words, you cannot decide during runtime whether a module should be imported. The reason for this is to allow the modules to be analyzed and the dependency graph to be built beforehand.

Node.js and popular browsers are quickly adding support for ECMAScript 2015 features, but currently none of them fully support modules.

Note

You can view the full compatibility table for ECMAScript features in the Kangax Compatibility Table at kangax.github.io/compat-table/.

Luckily, there are tools that can convert ECMAScript 2015 modules into the universally supported CommonJS format. The most popular are Babel and Traceur.

If you found this article helpful and want to learn more about JavaScript, you can check out Building Enterprise JavaScript Applications, a step-by-step guide for building maintainable and scalable applications. Written by Daniel Li, a full stack Java developer, Building Enterprise JavaScript Applications takes an in-depth look at the underlying concepts, enabling you to build and deploy robust JavaScript applications using Cucumber, Mocha, Jenkins, Docker, and Kubernetes.

Please sign in to leave a comment.