If you're new to JavaScript, you may have noticed that there are two ways to include code from another file: the import
statement and the require()
function. These come from two separate module systems. How do you know which one you should use? Let's get into it!
Why are there two methods?
JavaScript is a language with humble beginnings. When it appeared in 1995, it provided a way to add dynamic behaviour to a web page for the first time. Before JavaScript, web pages had to be completely static. There was no alternative. But JavaScript has historically been viewed as a “plaything” by real programmers gatekeeping arseholes. They felt it lacked the features necessary for “serious” programming.
JavaScript has grown a lot in a short space of time! Fast forward to 2009, when Ryan Dahl realised he could use Chrome's V8 JavaScript engine to run JavaScript on the server side. I'm talking about Node.js. This opened up a world of possibilities for JavaScript developers! It meant we could use a single programming language on the client side and the server side, rather than having to learn another language like PHP.
The real programmers gatekeeping arseholes did have a point, though. Some aspects of server-side programming are arguably more complex than client-side programming, although the lines are blurred today. As our JavaScript programs got bigger and more complex, we needed a way to split our code into multiple files. This is a feature that JavaScript didn't support when Node.js came out, and that's why there are two module systems.
CommonJS modules
Let's start with CommonJS, the module system that uses the require()
function. We'll use an example from the Node.js documentation. Imagine we have a file called circle.js
which exports a couple of helper functions for working with circles.
Note that CommonJS modules are only supported in Node.js, not in the browser.
Named exports in CommonJS modules
To export multiple things from a CommonJS module, we can make them properties of the module.exports
object:
module.exports.area = function (radius) {
return Math.PI * radius ** 2;
};
module.exports.circumference = function (radius) {
return 2 * Math.PI * radius;
};
We could also write this more succinctly. Node.js provides a variable called exports
which is assigned the value of the module.exports
property. This means we can write exports.thing
instead of module.exports.thing
, since the exports
variable and the module.exports
property reference the same object:
exports.area = function (radius) {
return Math.PI * radius ** 2;
};
exports.circumference = function (radius) {
return 2 * Math.PI * radius;
};
We could also reassign the module.exports
property with a brand new object. It's good practice to reassign the exports
variable too, otherwise it will become out of sync with the module.exports
property. Note that this code makes use of ES6 shorthand method names:
module.exports = exports = {
area(radius) {
return Math.PI * radius ** 2;
},
circumference(radius) {
return 2 * Math.PI * radius;
},
};
I think reassigning the module.exports
property is “cleaner” when we're exporting things we've already declared. Note that this code makes use of ES6 shorthand property names:
function area(radius) {
return Math.PI * radius ** 2;
}
function circumference(radius) {
return 2 * Math.PI * radius;
}
module.exports = exports = { area, circumference };
Either way, we could import our code in another file in one of two ways. We could either import the entire object via a namespace, in this case circle
:
const circle = require("./circle.js");
const radius = 4;
const area = circle.area(radius); // 50.26548245743669
const circumference = circle.circumference(radius); // 25.132741228718345
Or we could import the named exports using destructuring assignment:
const { area, circumference } = require("./circle.js");
const radius = 4;
const myArea = area(radius); // 50.26548245743669
const myCircumference = circumference(radius); // 25.132741228718345
Demo: Named exports in CommonJS.
Default export in CommonJS modules
If we only need to export one thing from a CommonJS module, we can reassign the module.exports
property with that instead of an object literal. For example, let's export a Circle
class with the existing functions as instance methods:
module.exports = exports = class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
};
We could also declare the class first before exporting it:
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
}
module.exports = exports = Circle;
Either way, we could import it using whatever name we like. It makes sense to use the same name as the class itself, in this case Circle
:
const Circle = require("./circle.js");
const circle = new Circle(4);
const area = circle.area(); // 50.26548245743669
const circumference = circle.circumference(); // 25.132741228718345
Demo: Default export in CommonJS.
ES modules
ECMAScript (ES) modules are the official standard for working with modules in JavaScript code. They're supported in all modern browsers and also in Node.js with some extra configuration.
Named exports in ES modules
To export multiple things from an ES module, we put the export
keyword in front of each thing we want to export. These are known as named exports:
export function area(radius) {
return Math.PI * radius ** 2;
}
export function circumference(radius) {
return 2 * Math.PI * radius;
}
We could also put the named exports inside curly braces. Although it looks similar, this is not the same thing as an object literal. The functions are still exported individually, not as properties of an object see next week's post. This is different from CommonJS modules:
function area(radius) {
return Math.PI * radius ** 2;
}
function circumference(radius) {
return 2 * Math.PI * radius;
}
export { area, circumference };
To import everything via a namespace, we write it like import * as x from "./y.js"
, where x
is the namespace and y
is the filename:
import * as circle from "./circle.js";
const radius = 4;
const area = circle.area(radius); // 50.26548245743669
const circumference = circle.circumference(radius); // 25.132741228718345
To import the named exports individually, we use curly braces. This is not the same thing as destructuring assignment, even though it looks similar. This is because the named exports were exported individually, not as properties of an object see next week's post. This is different from CommonJS modules:
import { area, circumference } from "./circle.js";
const radius = 4;
const myArea = area(radius); // 50.26548245743669
const myCircumference = circumference(radius); // 25.132741228718345
Demo: Named exports in ES modules.
Default export in ES modules
If we only need to export one thing from an ES module, we can use a default export. This means we put export default
in front of the thing we want to export:
export default class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
}
We could also declare the class first before exporting it:
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
}
export default Circle;
We could even write it a third way, although beware that export default thing
is different to export { thing as default }
:
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
}
export { Circle as default };
We can import the default export like so, using whatever name we like. Again, it makes sense to use the same name as the class itself, in this case Circle
:
import Circle from "./circle.js";
const circle = new Circle(4);
const area = circle.area(); // 50.26548245743669
const circumference = circle.circumference(); // 25.132741228718345
Demo: Default export in ES modules.
Common issues
Reassigning the exports
variable in CommonJS modules
If you reassign the exports
variable without also reassigning the module.exports
property, it won't work:
// The `Circle` class will NOT be exported!
exports = class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
};
Before the module is evaluated, Node.js assigns the value of the module.exports
property to the exports
variable. At this point, they're referencing the same object. If you only reassign the exports
variable, then it will reference the thing you're trying to export, but the module.exports
property will still reference an empty object. This is why it's a good idea to reassign the module.exports
property and the exports
variable at the same time, to keep them in sync:
module.exports = exports = class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
circumference() {
return 2 * Math.PI * this.radius;
}
};
ES modules in the browser
If you want to use ES modules in the browser, you have to add the type="module"
attribute to your <script>
element:
<script type="module">
import Circle from "./circle.js";
const circle = new Circle(4);
const area = circle.area(); // 50.26548245743669
const circumference = circle.circumference(); // 25.132741228718345
</script>
You also have to run your code on a server when developing locally.
ES modules in Node.js
If you want to use ES modules in Node.js, you have a few options:
- Use the
.mjs
extension instead of.js
- Add a
"type": "module"
field to yourpackage.json
file - Use the
--input-type=module
flag via the command line interface (CLI)
The first option is great when you have a mix of CommonJS and ES modules in your project. This could be useful for an incremental adoption strategy. If you know that every file in your project uses ES modules, then the second option would be better, because you could keep using the regular .js
extension. The third option is less common, but it's useful to know it exists.
Module bundlers
A module bundler is a tool that takes a number of JavaScript modules and bundles them into a single file. This is useful when you're writing code that needs to run in older browsers that don't support ES modules, for example. Because they transform your code before it runs in the browser, module bundlers let you do things that are impossible to do natively.
For example, some module bundlers let you import code without specifying the file extension, e.g. import Circle from "./circle"
. This is invalid if you're writing code directly for the browser without running it through a module bundler. You can do this in CommonJS, however.
Some module bundlers also let you import other types of asset, such as images and CSS files. Here's an example from Create React App, which uses Webpack:
import logo from "./logo.svg";
import "./App.css";
This doesn't work natively. It only works because Webpack does some clever stuff. The logo
import works out the final path to the logo.svg
file after it's been bundled, while the CSS import expresses that this JavaScript file depends on the App.css
file. The contents of the SVG and CSS files don't actually get included inside the JavaScript file, which would obviously be invalid.
Which method should you use?
Use CommonJS modules for Node.js and ES modules for the browser. If you're working as part of a team, be consistent with whatever your team uses. One might argue that you should use ES modules for new Node.js code, but I've found that they don't always play nicely. For example, you might be using a package from npm that doesn't support ES modules. My stance on this might change in the future, though, especially as the number of projects using ES modules increases.
Next week
Next week, we'll learn about some of the more advanced things we can do with ES modules, such as importing them dynamically.