JavaScript Module System
This article focuses on the importance of modules in JavaScript, the two most popular implementations, and demonstrates how to apply them.
The module is just another JavaScript file, handling some logic, that can be imported into other JavaScript files. The modules can be used both in web and server-side JavaScript applications.
The modules can share data between them and you can have as many modules in your application as you desire.
Before single-page applications became a thing, websites were built on multiple HTML files, each importing a set of scripts needed to run that file. Those usually included:
- Bootstrap
- Jquery (because Jquery was required for Bootstrap)
- Another third-party script (e.g. Font Awesome)
- Your custom JavaScript script
These were included in all files.
Besides obvious code duplication, another issue was with managing dependencies.
Let’s say your site imports two custom script tags.
<body>
<script src="./script1.js"></script>
<script src="./script2.js"></script>
</body>
Every variable set in the script lives in the global scope. That means if one script has a variable that shares a name with a variable in the other script file, one will override the other.
What made modules thrive?
Simplicity of use
You can create one file as your base file that runs the app and then create a set of smaller files (modules) that do particular functionality and are imported into your main file. The modules can contain other modules and the tree structure can be as big as you like.
Here is a quick example:
// math.js (module)
function sum (a, b) {
return a+b;
}
// export a function sum so that is available out of this file
export sum;
// main.js
// import a function sum by providing file path
import { sum } = from './math';
// using a function from math.js
console.log('Sum is: ', sum(2, 3)); // 5
One Registry
Instead of importing scripts from multiple sources (usually CDN), now you rely on packages from a unified registry (NPM) that allows you to install, update, and uninstall packages per your needs. All your dependencies and their versions are listed in the package.json file.
Server-Side Use
There are no script tags on the backend and managing all of your APIs, database queries, infrastructure, etc. in one file is difficult and bad practice. So splitting dependencies per file base isn’t just nice to have, but a necessity.
Performance
Without modules, all of the JavaScript code in your web app/server is loaded into the app memory at once. By using modules, you load a module a particular module only when you need it, e.g. when accessing a specific route or clicking on a UI element.
This results in a faster load time for the application.
Characteristics of a JavaScript module
In Object-Oriented terms, a module is a singleton that persists data during the runtime of the application.
Any code snippet that is exported out of the module is to be considered a public class member that can be used anywhere. Anything that isn’t exported is a private member available only in that file.
- To import a module in the file, point to its location on your machine using a relative path.
- Built-in and third-party modules (NPM) are imported using just module name, e.g.
import react from ‘react’;
- Node.js imports work on CommonJS modules by default,
e.g.const express = require(‘express’)
- In the later versions, built-in Node.js modules can be imported using a
node
prefix, e.g.const fetch = require(‘node:fetch’);
- Custom modules are imported with the
./
prefix, e.g.const { sum } = require(‘./math’);
- The two modules cannot import each other. This will cause a circular dependency exception.
What can you export?
- Variables
- Functions
- Objects
- Classes,
- Promises, etc.
Export Variations:
- Default export
- Single export
- Named (multiple) exports
Importing Practices
It’s a common practice to use Destructuring when importing a few items from the module:
// CommonJS modules
const { foo, bar } = require('./stuff');
// ES modules
import { foo, bar } from './stuff';
If you’re not familiar with Destructuring, I suggest you check out my blog:
However, if you’re importing a default export or everything it’s better to use the main word and access each item via the main word:
// CommonJS modules
const stuff = require('./stuff');
// ES modules
import * as stuff from './stuff';
stuff.bar();
stuff.foo();
Two commonly used module systems:
- CommonJS Modules
- ECMAScript (ES) Modules
There are others like UMD & AMD, but are not used nearly as much.
The CommonJS modules are used in server-side JavaScript and have arrived in Node.js early on. This is the default way to import modules in Node.js.
Fair warning, CommonJS modules do not work in the browser.
There are three keywords:
- require
- exports
- module-exports
Require
The require
keyword is a global namespace used when importing a module into your file:
const file = require('path/to/file');
- Require lets you import JavaScript and JSON files, although for the latter you need to specify the extension, e.g.:
require(‘config.json’);
- Require can be used alone or can store the return data into a variable, e.g.
require(‘config.json’);
vsconst { sum } = require(‘./math’);
. - Require cache file imports.
- Require imports are synchronous
Exports
The exports
keyword is used when you want to export a single value, be it a variable, object, function, or class:
// my-module.js
function sum(a, b) {
return a + b;
}
const language = 'JavaScript';
const appConfig = {
port: 3000,
}
class User {
static name = 'Mirza'
}
// exporting members one by one
exports.sum = sum;
exports.language = language;
exports.config = appConfig;
exports.User = User;
// other module.js
// importing all exported members
const { sum, language, config, User } = require('./my-module');
console.log('User.name :>> ', User.name); // Mirza
console.log('config :>> ', config); // { port: 3000 }
console.log('language :>> ', language); // JavaScript
console.log('Sum is: ', sum(2, 3)); // 5
Module Exports
The module.exports
is used when using default exports or exporting multiple values at once (wrapped into an object).
Named Exports
// math.js
function sum(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
// export multiple items by wrapping them into an object
module.exports = {
sum,
subtract
}
Each export is imported by its name:
const { sum, subtract } = require('./math');
console.log('Sum is: ', sum(2, 3)); // 5
console.log('Subtraction is: ', subtract(5, 3)); // 2
Default Export
You can have only one default export per module.
// module.js
const getUsername = () => 'Mirza';
// this time around we assign module.exports to the item we're exporting
module.exports = getUsername;
// main.js
const myExport = require('./get-user-data');
console.log(myExport()); // Mirza
Notice here that I did not import a getUsername()
function by its name, but rather using a made-up name. This is working fine because whatever you’re importing, it’s always referring to the default export you set in your module.
Basically, myExport()
is an alias for getUsername()
.
Other characteristics of Common.js modules:
- In CommonJS, modules are loaded synchronously where each module has its scope.
- When importing a module using
require()
keyword, you get a reference to the module'sexports
object, which contains the public API of the module. - The values exported by a CommonJS module are typically static. Once exported, they cannot be changed from outside the module.
Another common practice is to use the ECMAScript (ES) modules. This module system follows the latest ECMAScript standard and it’s now a preferred way of using modules.
The ECMAScript standards make server-side module writing compatible with writing modules in the browser. It’s used in runtimes like Deno & Bun by default.
ES Modules can also behave synchronously and asynchronously.
Set up ES modules in the Web Browser (Natively)
As you already know, JavaScript is added to an HTML file using the script tags. e.g.:
<body>
<!-- some HTML code -->
</body>
<script src="path-fo-file.js"></script>
To enable ES module import add the type=”module”
tag to the script element.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1 id="title"></h1>
<button id="year-btn">Get Current Year</button>
</body>
<!-- type="module" enables ES Modules-->
<script type="module" src="./index.js"></script>
</html>
From this point onward you can use the import in the index.js
file.
Caveats
There are some things to take into account:
- If you’re importing a custom module, you need to add
.js
suffix to the file import path - You can no longer call JavaScript functions from HTML
- You can no longer run the app natively in the browser
If you face any of these challenges learn how to overcome them in the article below:
Set up ES modules in the Web Browser (Module Bundlers)
The support for ES modules in web browsers is far better when using the web frameworks, such as React, Vue, or Angular. That’s because these frameworks use module bundlers under the hood.
To learn how to do that yourself, check out my blog below:
Set up ES modules in Node.js
The ES modules received native support in Node.js version 13.
ES modules without NPM
Change the extension of your file from .js
to .mjs
.
// math.mjs
const sum = (a, b) => a + b;
const subtract = (a, b) => a - b;
export { sum, subtract };
// app.mjs
import { sum, subtract } from './math';
Run the file:
$ node app.mjs
Sum is: 5
Subtraction is: 2
ES modules in the NPM project
Update the package.json
file and add “type”: “module”
,
// package.json file
"main": "app.js",
"type": "module", // <--- add this line
"scripts": {...},
// the rest of package.json file
Then you’ll be able to use ES Module Imports within Node.js (without .mjs
files).
import inquirer from 'inquirer';
import { sum, subtract } from './math';
Using ES modules
Once again we have a few keywords:
- import
- export
- export default
- as
- * (All)
Import
In the ES modules world, the import
keyword is used to import modules:
import fs from 'fs';
fs.readFile(...);
fs.write(...);
- ES modules follow the latest ECMAScript standards and are inline on the front and the back
- Due to import syntax working on both ends, the same module can be shared on both the client and the server
- Imports behave synchronously and asynchronously.
Asynchronous Import
Promises-based import using dynamic modules:
const language = 'en-gb';
import(`./locale/${language}.json`).then((module) => {
// do something with the translations
});
Default Export
Similar to what we did before, you can set a default export in your module file. With ES modules this is done using the default
keyword.
// user.js
export default class User {
#name = ''; // private variable
get getName() {
return this.#name;
}
set setName(newName) {
this.#name = newName;
}
}
// app.js
// Once again the default export can be imported using any name you like
import User from './user';
const user = new User();
user.setName = 'Mirza';
console.log(user.getName); // Mirza
Named Export
These are used to export multiple items from the same file.
// math.js
export const sum = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// app.js
import { sum, subtract } from './math';
console.log(sum(3, 7)); // 10
console.log(subtract(10, 9)) // 1
Alternatively
You can export items after declarations:
const sum = (a, b) => a + b;
const subtract = (a, b) => a - b;
export { sum, subtract };
As
The as
keyword is used to set an alias for an import.
import { sum, subtract as sub } from './math';
console.log(sum(3, 7)); // 10
console.log(sub(10, 9)) // 1
As well as for export:
const sum = (a, b) => a + b;
const subtract = (a, b) => a - b;
export { sum, subtract as sub };
Import All
You can import all named exports under the same namespace using the *
(all) symbol.
import * as mathOperations from './math';
console.log(mathOperations.sum(3, 7)); // 10
console.log(mathOperations.subtract(10, 9)) // 1
Other characteristics of ES modules:
- ES Modules have a more dynamic scoping behavior compared to CommonJS. Each
import
statement creates a live binding to the exported values, meaning that changes to the exported values in the module are reflected in the importing module. - When importing a module using the
import
keyword, JavaScript doesn’t get direct access to the module's exports. Instead, it has a binding to the values exported by the module. - CommonJS snapshots the value at the time of import, while ES Modules maintain a live binding to the exported variable, allowing changes to be reflected dynamically.
Wrapping up
As you can tell, the invention of the module system was a big step forward in the JavaScript ecosystem. The modules are used today across web frameworks as well as on server-side technologies.
That’s all from me today. If you found this article useful give it a clap.
Also, follow me on Medium, Twitter, and The Practical Dev to stay up to date with my content.
And I’ll see you in the next one 👋