How Javascript modules work

Wed Jan 10 2024

6 min read

How Javascript modules work?

In my recent Vue projects, I've been using vue composable, and while working with many composables (javascript modules), I found myself struggling with some concepts. This led me to get back to the basics to understand how modules works, and some other related concepts like modules scope and closure.

NOTE: This story doesn't explain the extensive details of how to use modules, It rather focuses on a few concepts that beneficial for people already familiar with modules basics.

What is a Javascript Modules

A Module is just a JavaScript file, contains a set of functions, variables, classes, and types that can be exported, imported and used in other files.

We use them to split large codebases into smaller pieces that can be reused in other files, Also we may package and publish them as a library later.

Modren Browser support

Javascript module is supported now in most of the modern browsers

<!--index.html-->
<script type="module" src="module1.js"></script>

Alternatively, you can import and use a module directly within a <script> tag:

<!--index.html-->
<script type="module">
  import message from "./message.js";
</script>

just make sure that script type is module, otherwise you will get this error

Uncaught SyntaxError: Unexpected token 'export'
Uncaught SyntaxError: Cannot use import statement outside a module

Older browsers support

But for most cases, we have to use tools like babel or vite to transpile our modular code and bundle it to one javascript file that all browsers can support. And if you are using any of the modern frameworks like Vue or React they will handle this for you.

Let's start with a simple example

Let's create a position module that contain some logic to manipulate a position object.

// position.ts

export interface Position {
    x: number;
    y: number;
}

const position: Position = {x: 0, y: 0}

export function getPosition() {
    return position;
}

export function setPosition(x, y) {
    position.x = x;
    position.y = y;
}

export function movePosition(deltaX, deltaY) {
    position.x += deltaX;
    position.y += deltaY;
}

Now, we can import this module in any other component or javascript file to set/get the current position, or reuse the Positon type.

// main.ts

import {type Position, getPosition, movePosition} from "./position";

const playerPosition: Position = getPosition();
const newPosition = movePosition(10, 20);

Module Scope

Each module has its own scope, so if we have two modules they won't be able to access each other's variables or functions.

// users.js
const user = "ahmed";
// posts.js
console.log(user); // ReferenceError: user is not defined

To access data from other modules we need to export them first.

export const user = "ahmed";
import { user } from "./users.js";

console.log(user); // ahmed

Modules can access the global scope

If we're inside a module, and we want to access a global variable, we can use the window object (inside the browser) or the global object (inside Node.js).

// users.js
const user = "ahmed";

window.user = user; // inside the browser
global.user = user; // inside Node.js
// posts.js
console.log(window.user); // ahmed

Module code is evaluated only once

This is one of the things that I was really confused about when I was using vue composable and what motivated me to write this story.

When you import a module in multiple files, the module code will be evaluated only once the first time you imported it, and the same references will be used in any subsequent imports to the same module.

This feature maybe super useful or buggy depending on your use case, so be careful. If you are expecting to get a fresh copy of the module's defined variables each time you import it in a new file, you will be disappointed. On the other side, if you want to share the same instance of the module between multiple files (a config file for example), this will be very useful.

//firebase.js
const configs = {}

export const initializeApp = (options) => {
  configs = options
}

// some other functions that have access to the firebase config object
export const signIn = (username, password) => {
  // use configs here
}
// main.js
import { initializeApp } from "./firebase.js";

initializeApp({ /* firebase options */ });
// auth.js
import { signIn } from "./firebase.js";

signIn("ahmed", "123");

Module functions retain closure over the module scope

As I mentioned before the module code is executed only once, but how the exported functions still have access to the other parts of the modules?

It's a feature of any javascript function that a function retain access to variables from containing (enclosing) scope even after the scope has finished executing.

So when you define and export a function inside a module, it will retain a closure over the module's lexical scope, event if the module code has finished executing.

// counter.js
let count = 0;

export const increment = () => {
  count++;
};

export const getCount = () => {
  return count;
};
// component1.js
import { increment, getCount } from "./counter.js";

increment();
console.log(getCount()); // 1
// component2.js
import { increment, getCount } from "./counter.js";

increment();
console.log(getCount()); // 2

Notice that the count value didn't reset when import the counter.js module again in component2.js.

Exports are immutables

While we can use a function to update a value inside a module, we can't mutate the exported value itself.

// counter.js
export let count = 0;
export const increment = () => {
  count++;
};
// main.js
import { count, increment } from "./counter.js";

console.log("count before ", count); // 0
increment();
console.log("count after ", count); // 1

count++; // TypeError: Assignment to constant variable.

But we can change a value inside an exported objects. As long as we don't change the pointer to the exported value we're fine.

// configs.js
const configs = {
  theme: "light",
};
// main.js
import { configs } from "./configs.js";

configs.theme = "dark";

Cycle dependencies & live bindings

Live binding = live connection/reference to a value.

In large codebase we may have multiple modules that depend on each other, and this is totally fine. This is possible due to modules live bindings feature.

This means that when the exporting module changes a value, the change will be visible from the importer side.

// a.js
import { bar } from 'b';

export function foo () {
  bar();
}
// b.js
import { foo } from 'a';

export function bar () {
  if (Math.random()) foo();
}

Let's track the execution of the above code:

  1. a.js imports bar from b.js
  2. Then the code at b.js will be executed first before a.js continue executing.
  3. b.js imports foo from a.js
  4. but now foo is not defined yet?

Here b.js imports a live binding to foo, so at first it will refer to an empty slot, but later when a.js continue executing and foo is defined, the bar function will be able to call foo.

References & more reading

Same javascript module imported in different files, sharing the same lexical scope

What does it mean by live bindings

What do ES6 modules export

Ecmascript Module live two-way bindings Example

Next

In the next story I'll talk about how these concepts helped be to create a shared composable that can be used to share a single useMouse instance between multiple Vue components.

Please share your thoughts in the comments, and let me know if some of these concepts helped you in previous or current projects.


Ahmed Ayman Blog @2024