Javascript: modules

Javascript: modules

8th article in my exploratory work on javascript and this time I sched light on javascript modules system.

Javascript modules let you to break up your code into separate files.

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

By default, the code written in a js file is not exported.
Explicit export keywords should be used to export variables or functions and transform your javascript file into a javascript module.

// The following consts are visible to all other javascript code since 
// they're declared in the global space
export const name = "Ben";
export const age = 12;

It is also possible to declare a default export as shown below:

function foo() {
    ...
}
// Only 1 default export is allowed per file
export default foo;

The difference is in how the module is used in other js files:

import { name, age } from "person.js"; // several exports
import foo from "person.js"; // default export

Note: import is only allowed for scripts declared with type="module"

Legacy module systems

This is where it gets confusing.

Since javascript did not initially have any standardized module system, the community created many of them :

Name Description Code example
CJS CommonJS, defined for back-end code. Export: module.exports = function sendEmail{ ... } Import: const myModule = require('./sendEmail.js'). When you import a module, you synchronously get a copy of the module
AMD Asynchronous Module Definition, defined for front-end code. Import: define(function(require) { var sendEmail = require('./sendEmail.js); return function() { /* do something */ }}). The syntax is less intuitive. Here, the import is done asynchronously.
UMD Universal Module Definition, works on front and back ends. UMD is a pattern used to configure several module systems. It is actually some code that'll check which module system is in use and adapt the import. if(typeof define === "function" && define.amd) { /* AMD */} else if(typeof exports == "object") { /* UMD */} ...

Luckily, ES6 introduced a standard called ESM (for ES Modules) and this is what we will focus on. The code examples given in the first section are leveraging ESM :

import { units, convertUnit } from './physics';
export const units = ["Meter", "Centimeter", "Inch"];
export function convertUnit(input {
    // ...
});

Note: Node.js uses CJS by default but there several ways of enabling ESM as described here

npm

npm is the module management utility in Node.js. You can use it to install 3rd party modules with a very simple syntax:

npm i <module_name>

Tree-shaking

There are several ways of importing code with ESM:

// Import a default export called 'alpha' in module.js
import alpha from "./module.js";

// Import all exports from a module
import * as services from "./services.js";

// Import some exports from a module (with or without an alias)
import { getVersion, getWindowHeight as getHeight } from "./services.js";

// Import none but execute the global code of a js file
import "./setContext.js";

// Use import as an async function
let module = await import("./hotcoffee.js");
// or
import("./hotcoffee.js").then((module) => {
    // Do something
});

// Usages
console.log(alpha());
console.log(services.printVersion());
console.log(getVersion());
console.log(getHeight());

One thing that you should keep in mind: javascript is an expensive resource. It has to be decompressed, parsed, compiled and executed. As a consequence a common technique applied by developers, called code splitting, consists in splitting your code into small chunks which only contain the required code for a route or a component. It reduces the overall memory footprint and the execution time. Although efficient, it is complicated to apply in big codebases and this is where tree shaking helps.

Tree shaking is a good practice forcing you to only pick the exports you need instead of importing the whole file. It basically says:

  • Do this: import {a, b, c} from "./devices.js;
  • Instead of this: import * as devices from "./devices.js

Module bundlers do that out of the box.

Module bundlers

Nowadays, you don't manually include your js dependencies in your html files. Instead, you bundle them into a static file that you can load with a single line of code.

<!-- No more this -->
<script type='text/javascript' src='devices.js'></script>
<script type='text/javascript' src='serial.js'></script>
<script type='text/javascript' src='shared.js'></script>
<script type='text/javascript' src='main.js'></script>

<!-- but this -->
<script type='text/javascript' src='bundle.js'></script>

A bundler is a tool that combines multiple javascript files into a single one for production.

In the dependency resolution phase, the bundler scans all of your dependencies (and their dependencies) to build a dependency graph. This graph is then used to get rid of unused files, prevent naming conflicts, etc ...

Then the bundling starts in a phase known as packing where the dependency graph is leveraged to integrate the code files, inject required functions and return a single executable bundle that can be loaded by your browser. Things like code splitting or tree-shaking are often applied during the process.

There are several bundlers out there such as Webpack, parcel or rollup. They usually require a config file where you specify the main js file (i.e. root of your dependency graph) and the name of the output file (ex: bundle.js).

Ex: Rollup

To use Rollup in your app, start by adding the package

npm i rollup

Then add some configuration:

export default {
    input: 'src/main.js',
    output: {
        file: 'bundle.js',
        format: 'cjs'
    }
}

Relative vs Absolute paths

As a closing note, I'd like to emphasize that you should, as much as possible, avoid relative paths when importing your modules. Relative paths can quickly become difficult to read when reaching a distant js file (i.e. ..\..\components\myModule.js). You should prefer using absolute paths to improve the overall readability (i.e. components\myModule.js).

You will start by configuring your environment to define the root path. Here are the common options:

  • Webpack:
// webpack.config.json
resolve: {
  alias: {
    components: path.resolve(__dirname, 'src/')
  }
}
  • React:
// jsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src"
  }
}
  • Typescript
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src/",
  }
}

then you can use remove the root path from your import paths:

import { engine } from "components/services.js";