ES6 modules are a new module format designed for all JavaScript environments. While Node.js has always had a good module system, browser-side JavaScript has not. That meant the browser-side community had to use non-standardized solutions. The CommonJS module format was one of those non-standard solutions, which was borrowed for use in Node.js. Therefore, ES6 modules are a big improvement for the entire JavaScript world, by getting everyone on the same page with a common module format and mechanisms.
An issue we have to deal with is the file extension to use for ES6 modules. Node.js needs to know whether to parse using the CommonJS or ES6 module syntax. To distinguish between them, Node.js uses the file extension .mjs to denote ES6 modules, and .js to denote CommonJS modules. However, that’s not the entire story since Node.js can be configured to recognize the .js files as ES6 modules. We’ll give the exact particulars later in this chapter.
The ES6 and CommonJS modules are conceptually similar. Both support exporting data and functions from a module, and both support hiding implementation inside a module. But they are very different in many practical ways.
Let’s start with defining an ES6 module. Create a file named simple2.mjs in the same directory as the simple.js example that we looked at earlier:
let count = 0;
export function next() { return ++count; }
function squared() { return Math.pow(count, 2); }
export function hello() {
return “Hello, world!”;
}
export default function() { return count; }
export const meaning = 42;
export let nocount = -1;
export { squared };
This is similar to simple.js but with a few additions to demonstrate further features. As before count is a private variable that isn’t exported, and next is an exported function that increments count.
The export keyword declares what is being exported from an ES6 module. In this case, we have several exported functions and two exported variables. The export keyword can be put in front of any top-level declaration, such as variable, function, or class declarations:
export function next() { .. }
The effect of this is similar to the following:
module.exports.next = function() { .. }
The intent of both is essentially the same: to make a function or other object available to code outside the module. But instead of explicitly creating an object, module.exports, we’re simply declaring what is to be exported. A statement such as export function next() is a named export, meaning the exported function (as here) or object has a name, and that code outside the module uses that name to access the object. As we see here, named exports can be functions or objects, and they may also be class definitions.
The default export from a module, defined with export default, can be done once per module. The default export is what code outside the module accesses when using the module object itself, rather than when using one of the exports from the module.
You can also declare something, such as the squared function, and then export it later.
Now let’s see how to use the ES2015 module. Create a simpledemo.mjs file with the following:
import * as simple2 from ‘./simple2.mjs’;
console.log(simple2.hello());
console.log(‘${simple2.next()} ${simple2.squared()}’);
console.log(‘${simple2.next()} ${simple2.squared()}’);
console.log(‘${simple2.default()} ${simple2.squared()}’);
console.log(‘${simple2.next()} ${simple2.squared()}’);
console.log(‘${simple2.next()} ${simple2.squared()}’);
console.log(‘${simple2.next()} ${simple2.squared()}’);
console.log(simple2.meaning);
The import statement does what it says: it imports objects exported from a module. Because it uses the import * as foo syntax, it imports everything from the module, attaching everything to an object, in this case named simple2. This version of the import statement is most similar to a traditional Node.js require statement because it creates an object with fields containing the objects exported from the module.
This is how the code executes:
$ node simpledemo.mjs Hello, world!
1 1
2 4
2 4
3 9
4 16
5 25
42
In the past, the ES6 module format was hidden behind an option flag, — experimental-module, but as of Node.js 13.2 that flag is no longer required.
Accessing the default export is accomplished by accessing the field named default. Accessing an exported value, such as the meaning field, is done without parentheses because it is a value and not a function.
Now to see a different way to import objects from a module, create another file, named simpledemo2.mjs, containing the following:
import {
default as simple, hello, next, meaning
}
from ‘./simple2.mjs’;
console.log(hello());
console.log(next());
console.log(next());
console.log(simple());
console.log(next());
console.log(next());
console.log(next());
console.log(meaning);
In this case, the import is treated similarly to an ES2015 destructuring assignment. With this style of import, we specify exactly what is to be imported, rather than importing everything. Furthermore, instead of attaching the imported things to a common object, and therefore executing simple2.next(), the imported things are executed using their simple name, as in next().
The import for default as simple is the way to declare an alias of an imported thing. In this case, it is necessary so that the default export has a name other than default.
Node.js modules can be used from the ES2015 .mjs code. Create a file named ls.mjs containing the following:
import { promises as fs } from ‘fs’;
async function listFiles() {
const files = await fs.readdir(‘.’);
for (const file of files) {
console.log(file);
}
}
listFiles().catch(err => { console.error(err); });
This is a reimplementation of the ls.js example in Chapter 2, Setting Up Node.js. In both cases, we’re using the promises submodule of the fs package. To do this with the import statement, we access the promises export from the fs module, and use the as clause to rename fs.promises to fs. This way we can use an async function rather than deal with callbacks.
Otherwise, we have an async function, listFiles, that performs filesystem operations to read filenames from a directory. Because listFiles is async, it returns a Promise, and we must catch any errors using a .catch clause.
Executing the script gives the following:
$ node ls.mjs
ls.mjs
module1.js
module2.js
simple.js
simple2.mjs
simpledemo.mjs
simpledemo2.mjs
The last thing to note about ES2015 module code is that the import and export statements must be top-level code. Try putting an export inside a simple block like this:
{
export const meaning = 42;
}
That innocent bit of code results in an error:
$ node badexport.mjs
file:///home/david/Chapter03/badexport.mjs:2
export const meaning = 42;
^^^^^^
SyntaxError: Unexpected token ‘export’
at Loader.moduleStrategy
(internal/modules/esm/translators.js:83:18)
at async link (internal/modules/esm/module_job.js:36:21)
While there are a few more details about the ES2015 modules, these are their most important attributes.
Remember that the objects injected into CommonJS modules are not available to ES6 modules. The __dirname and filename objects are the most important, since there are many cases where we compute a filename relative to the currently executing module. Let us explore how to handle that issue.
1. Injected objects in ES6 modules
Just as for CommonJS modules, certain objects are injected into ES6 modules. Furthermore, ES6 modules do not receive the dirname, and filename objects or other objects that are injected into CommonJS modules.
The import.meta meta-property is the only value injected into ES6 modules. In Node.js it contains a single field, url. This is the URL from which the currently executing module was loaded.
Using import.meta.url, we can compute dirname and filename.
2. Computing the missing dirname variable in ES6 modules
If we make a duplicate of dirname.js as dirname.mjs, so it will be interpreted as an ES6 module, we get the following:
$ cp dirname.js dirname.mjs
$ node dirname.mjs console.log(‘dirname: ${ dirname}’);
^
ReferenceError: dirname is not defined
at file:///home/david/Chapter03/dirname.mjs:1:25
at ModuleJob.run (internal/modules/esm/module_job.js:109:37)
at async Loader.import (internal/modules/esm/loader.js:132:24)
Since dirname and filename are not part of the JavaScript specification, they are not available within ES6 modules. Enter the import.meta.url object, from which we can compute dirname and filename. To see it in action, create a dirname-fixed.mjs file containing the following:
import { fileURLToPath } from ‘url’;
import { dirname } from ‘path’;
console.log(‘import.meta.url: ${import.meta.url}’);
const filename = fileURLToPath(import.meta.url);
const dirname = dirname( filename);
console.log(‘dirname: ${ dirname}’);
console.log(‘filename: ${ filename}’);
We are importing a couple of useful functions from the url and path core packages. While we could take the import.meta.url object and do our own computations, these functions already exist. The computation is to extract the pathname portion of the module URL, to compute filename, and then use dirname to compute dirname.
$ node dirname-fixed.mjs
import.meta.url: file:///home/david/Chapter03/dirname-fixed.mjs
dirname: /home/david/Chapter03
filename: /home/david/Chapter03/dirname-fixed.mjs
And we see the file:// URL of the module, and the computed values for dirname and filename using the built-in core functions.
We’ve talked about both the CommonJS and ES6 module formats, and now it’s time to talk about using them together in an application.
Source: Herron David (2020), Node.js Web Development: Server-side web development made easy with Node 14 using practical examples, Packt Publishing.