JavaScript Modules

With growing the application more extensive, there is a need to split it into multiple files: modules. As a rule, a module includes a class or a library of functions.

For some time, JavaScript existed without a language-level module syntax. That wasn’t a difficulty as the scripts were small and straightforward.

But, over time, the scripts became more and more complex; hence a variety of ways were invented to organize the code into libraries and modules.

To make things more obvious, let’s take a glance at the first module systems:

  • AMD: one of the oldest module systems, initially enforced by the library require.js.
  • CommonJS: the module system that is made for the server of Node.js.
  • UMS: another module system, suggested as a universal system, compatible with Common.JS and AMD.

Today, the modules systems above are part of history. But, at times, you can find them in old scripts.

The modern language-level system came out in 2015. Now, it is supported in Node.js and all major browsers.

Description of a Module

Modules are files. One script is equal to one module.

Just as authors divide their books into chapters, programmers divide the programs into modules. Hence, modules can be considered as clusters of words.

Modules are capable of loading each other and using particular directives, such as export and import for exchanging functionality, call functions of one module from another one. It is explained below:

  • export labels variables and functions that need to be reached from outside the current module.
  • import keyword enables the import of functionality from other modules.

Let’s consider having a file welcome.js , which exports a function, like this:

//  welcome.js
export function welcome(site) {
  console.log(`Welcome to ${site}`);
}

Another file can import and use it. The import directive will load the module by path import close to the current file, assigning the exported function import to the matching variable.

Here is an example:

// main.js
import {
  welcome
} from './welcome.js';
console.log(welcome); // function...
welcome('W3Docs'); // Welcome to W3Docs

Let’s try to run it in-browser. Since modules support particular features and keywords, it is necessary to tell the browser to treat the script as a module by applying the attribute <script type="module">.

It is demonstrated below:

Index.html

<!doctype html>
<script type="module">
  import {welcome} from './welcome.js';  
  document.body.innerHTML = welcome('W3Docs');
</script>

welcome.js

export function welcome(site) {
  return `Welcome to ${site}`;
}

The browser automatically fetches and evaluates the module, which is imported and only then runs the script.

Core Module Features

Now, let’s monitor the differences between modules and regular scripts. We can distinguish core features that are valid for browser and server-side JavaScript.

Always “use strict”

By default, modules use strict. Thus, in case you assign to an undeclared variable, an error will occur.

For a better understanding, please, look at this example:

<script type="module">
  x = 10; // error
</script>

Module-level Scope

Every module has a top-level scope. Otherwise speaking, you can’t find top-level variables and functions from a module in other scripts.

Let’s check out an example:

Index.html

<!doctype html>
<script type="module" src="welcome.js"></script>

site.js

export let site = "W3Docs";

welcome.js

import {
  site
} from './user.js';
document.body.innerHTML = site; // W3Docs

In the browser, for each <script type="module"> there is independent top-level scope. And, if you need to create a window-level global variable, you can explicitly assign it to window accessing as window.site .

Here is an example:

<script type="module">
  // The variable is only visible in this module script
  let site = "W3Docs";
</script>
<script type="module">
  console.log(site); // Error: site is not defined
</script>

That’s, of course, an exception that requires a strong case.

A Module Code Should be Evaluated Only the First Time When Imported

In case the same module is imported into different places, its code is implemented only the first time. Afterward, the exports are given to all the importers.

It brings significant consequences. Let’s observe them with the help of examples.

First of all, if implementing a module code leads to side-effects ( for example, showing a message), importing it multiple times will activate it only the first time, like here:

// consoleLog.js
console.log("Module is evaluated!");
// Import the same module from different files
// One.js
import `./consoleLog.js`; // Module is evaluated!
// Two.js
import `./consoleLog.js`; // (shows nothing)

In practice, programmers use top-level module code mostly for initialization, the formation of internal data structures. If you want something to become reusable, you can export it.

A module exports an object, in the example below:

// book.js
export let book = {
  bookTitle: "Javascript"
};

In case this module is exported from multiple files, the module will be evaluated only the first time, a book object will be created and passed to all the next importers.

For instance:

// One.js
import {
  book
} from './book.js';
book.bookTitle = "Javascript";

// Two.js
import {
  book
} from './book.js';
console.log(book.bookTitle); // Javascript
// Both One.js and Two.js imported the same object
// Changes made in One.js are visible in Two.js

To sum up, let’s assume that the module can be executed once. Exports are created and then shared between the importers.

In case something transforms the book object, other modules see that.

Behavior like that allows configuring modules on the first import. It is possible to set up its properties once, and it will be ready in the next imports, as well. For example:

// book.js
export let book = {};

export function welcome() {
  console(`Start learning ${book.bookTitle}`);
}

In the example above, the book.js module provides certain functionality but expects the credentials to come into the object of book from outside. In init.js, you can set book.bookTitle: anyone will see it, including the calls implemented inside book.js itself.

Here is an example:

// init.js
import {
  book
} from './book.js';
book.bookTitle = "Javascript";

The book.bookTitle can also be seen by another module, like here:

// nother.js
  import {
    book,
    welcome
  } from './book.js';

alert(book.bookTitle); // Javascript
welcome(); // Start learning Javascript

Import.meta

The import.meta object comprises information about the current module. Its content relies on the environment. In the browser, it includes the URL of the script, a current web page URL if inside HTML.

To make it clearer, let’s check out an example:

<script type="module">
  console.log(import.meta.url); // script url 
</script>

“this” is Undefined in a Module

In a module, the top-level this is undefined.

Let’s compare it to non-module scripts with this as a global object.

It will look as follows:

<script>
  console.log(this); // window
</script>
<script type="module">
  console.log(this); // undefined
</script>

Module Scripts are Deferred

Module scripts are deferred; it has the same effect as defer attribute for external and inline scripts.

Otherwise speaking:

  • if you download external module scripts <script type="module" src="...">, it will not block HTML processing: they load along with other resources.
  • the module scripts wait till the HTML document is ready, then run.
  • the relative sequence of the scripts is maintained. It means that scripts that go first in the document, execute first.

There is a side-effect, though: module scripts “see” the full-loaded HTML page, in particular, HTML elements below them.

To illustrate that, let’s consider the following example:

<script type="module">
  console.log(typeof button);//object: the button below can be “seen” by the script, as modules are deferred. 
//The script may run after the overall page is loaded
</script>

Compare to regular script below:

<script>
  console.log(typeof button); // Error: button is undefined
   // the script can't see elements below
  // regular scripts run immediately,
  // before the rest of the page is processed
</script>
<button id="button">Button</button>

Please, take into account that the second strip runs before the first. Hence, in the first place, you will see undefined, and then the object.

The reason is that modules are deferred: so you need to wait for the document to be processed. On the contrary, the regular script runs at once, so its output is noticed first.

While using modules, one should realize that the HTML page shows up as it loads. JavaScript modules run after that in a way, the user can see the page before the app is ready. But some functionality may not work yet. It is necessary to put “loading indicators”, ensuring that the visitor will not get confused.

Async Works on Inline Scripts

For non-module scripts, the async attribute operates only on external scripts. Async scripts run when ready regardless of the other scripts or the HTML document.

As for the module scripts, it operates on inline scripts, as well.

Let’s consider an example where the inline script has async. So, it will not wait for anything.

It will implement the import (fetches ./counts.js) and run when ready, even if the HTML document is not over yet.

Here is how it looks like:

<!-- all dependencies are fetched (counts.js), and the script runs -->
<!-- doesn't expect a document or other <script> tags -->
<script async type="module">
  import {counter} from './countss.js';  
  counter.count();
</script>

External Scripts

The external scripts, having type="module" differ in two aspects.

  1. The first one supposes that external scripts with the same src can run once, like here:
    <!-- the script script.js is fetched and executed only once →
    <script type="module" src="script.js"></script>
    <script type="module" src="script.js"></script>
  2. The second one assumes that the external scripts fetched from another origin require CORS headers. Otherwise speaking, in case a module script is fetched from another origin, the remote server has to supply a header AccessControlAllowOrigin enabling the fetch, like this:
    <!-- another-site.com must supply AccessControlAllowOrigin →
    <!-- otherwise, the script won't execute →
    <script type="module" src="http://anotherSite.com/this.js"></script>

You should do that to contribute to security.

No “bare” Modules Allowed

By default, import gets a relative or an absolute URL. The modules having no path are known as “bare”. Modules like this are not allowed in import.

For a better understanding, let’s see an example of an invalid import:

import {
  welcome
} from 'welcome'; // Error, "bare" module
// the module must have a path, e.g. './welcome.js' or wherever the module is

But, Node.js or bundle tools allow bare modules, without a path, because they have ways for finding modules and hooks to adjust them. However, browsers don’t support bare modules so far.

Compatibility, “nomodule”

The type="module" is not understood by old browsers. Hence, they ignore scripts of an unknown type. They can provide a fallback using the nomodule attribute, as follows:

<script type="module">
  console.log("Runs in browsers");
</script>
<script nomodule>
  console.log("Modern browsers know both type=module and nomodule, so skip this")
  console.log("Old browsers ignore script with unknown type=module, but execute this.");
</script>

Build Tools

In practice, browser modules are seldom used in their “plain” form. As a rule, developers mix them with a unique tool called Webpack and deploy to the production server.

One of the most significant assets of using bundlers is that they give more control over how modules are resolved, allowing bare modules and so on.

The build tools operate as follows:

  • they take the module, which is intended to be put in <script type="module"> in HTML.
  • analyze its dependencies, such as imports, imports of the imports and more.
  • they build a single file that includes overall modules and replace native import calls with bundler functions in a way that it works. Also, they support “special” module types, such as HTML and CSS.
  • other optimizations and transformations can also be applied in the course.
  • In the event of using bundle tools, as scripts are bundled together in a file, import/export statements within the scripts are replaced by specific bundle functions. Thus, the resulting script doesn’t involve any import/export; it doesn’t need type="module"". So, you can put it into a regular script.

    Here is an example:

    <!-- Assuming we got script.js from a tool like Webpack -->
    <script src="script.js"></script>

    Summary

    In JavaScript, a module is a file. For making import/export work, browsers require <script type="module">. The primary characteristics of the modules are the following:

    • they are deferred by default.
    • Async operates on inline scripts.
    • CORS headers are required for loading external scripts from another origin.
    • Duplicate external scripts are not allowed and can be ignored.

    Modules have local-top scope and interchange functionality through import/export.

    Also, note that modules always use strict. The module code should be executed once: they are created once and shared between importers. While using modules, each of them implements the functionality and exports it. Afterward, the import is used for importing it directly to where is necessary. The browser both loads and evaluates the scripts automatically.

Practice Your Knowledge

What are the benefits of using JavaScript modules?

Quiz Time: Test Your Skills!

Ready to challenge what you've learned? Dive into our interactive quizzes for a deeper understanding and a fun way to reinforce your knowledge.

Do you find this helpful?