import.meta.url
import.meta.url
on Node.jsThe current landscape of JavaScript modules is quite diverse: ES6 brought built-in modules, but the source code formats that came before them, are still around, too. Understanding the latter helps understand the former, so let’s investigate. The next sections describe the following ways of delivering JavaScript source code:
Tbl. 17 gives an overview of these code formats. Note that, for CommonJS modules and ECMAScript modules, two filename extensions are commonly used. Which one is appropriate depends on how you want to use a file. Details are given later in this chapter.
Runs on | Loaded | Filename ext. | |
---|---|---|---|
Script | browsers | async | .js |
CommonJS module | servers | sync | .js .cjs |
AMD module | browsers | async | .js |
ECMAScript module | browsers and servers | async | .js .mjs |
Before we get to built-in modules (which were introduced with ES6), all code that you’ll see, will be written in ES5. Among other things:
const
and let
, only var
.Initially, browsers only had scripts – pieces of code that were executed in global scope. As an example, consider an HTML file that loads script files via the following HTML:
<script src="other-module1.js"></script>
<script src="other-module2.js"></script>
<script src="my-module.js"></script>
The main file is my-module.js
, where we simulate a module:
var myModule = (function () { // Open IIFE
// Imports (via global variables)
var importedFunc1 = otherModule1.importedFunc1;
var importedFunc2 = otherModule2.importedFunc2;
// Body
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
// Exports (assigned to global variable `myModule`)
return {
exportedFunc: exportedFunc,
};
})(); // Close IIFE
myModule
is a global variable that is assigned the result of immediately invoking a function expression. The function expression starts in the first line. It is invoked in the last line.
This way of wrapping a code fragment is called immediately invoked function expression (IIFE, coined by Ben Alman). What do we gain from an IIFE? var
is not block-scoped (like const
and let
), it is function-scoped: The only way to create new scopes for var
-declared variables is via functions or methods (with const
and let
, you can use either functions, methods or blocks {}
). Therefore, the IIFE in the example hides all of the following variables from global scope and minimizes name clashes: importedFunc1
, importedFunc2
, internalFunc
, exportedFunc
.
Note that we are using an IIFE in a particular manner: At the end, we pick what we want to export and return it via an object literal. That is called the revealing module pattern (coined by Christian Heilmann).
This way of simulating modules, has several issues:
Prior to ECMAScript 6, JavaScript did not have built-in modules. Therefore, the flexible syntax of the language was used to implement custom module systems within the language. Two popular ones are:
The original CommonJS standard for modules was mainly created for server and desktop platforms. It was the foundation of the Node.js module system, where it achieved enormous popularity. Contributing to that popularity were the npm package manager for Node and tools that enabled using Node modules on the client side (browserify, webpack and others).
From now on, I use the terms CommonJS module and Node.js module interchangeably, even though Node.js has a few additional features. The following is an example of a Node.js module.
// Imports
var importedFunc1 = require('./other-module1.js').importedFunc1;
var importedFunc2 = require('./other-module2.js').importedFunc2;
// Body
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
// Exports
module.exports = {
exportedFunc: exportedFunc,
};
CommonJS can be characterized as follows:
The AMD module format was created to be easier to use in browsers than the CommonJS format. Its most popular implementation is RequireJS. The following is an example of an AMD module.
define(['./other-module1.js', './other-module2.js'],
function (otherModule1, otherModule2) {
var importedFunc1 = otherModule1.importedFunc1;
var importedFunc2 = otherModule2.importedFunc2;
function internalFunc() {
// ···
}
function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
return {
exportedFunc: exportedFunc,
};
});
AMD can be characterized as follows:
On the plus side, AMD modules can be executed directly. In contrast, CommonJS modules must either be compiled before deployment or custom source code must be generated and evaluated dynamically (think eval()
). That isn’t always permitted on the web.
Looking at CommonJS and AMD, similarities between JavaScript module systems emerge:
ECMAScript modules (short: ES modules, ESM) were introduced with ES6. They continue the tradition of JavaScript modules and have all of their aforementioned characteristics. Additionally:
ES modules also have new benefits:
This is an example of ES module syntax:
import {importedFunc1} from './other-module1.mjs';
import {importedFunc2} from './other-module2.mjs';
function internalFunc() {
···
}
export function exportedFunc() {
importedFunc1();
importedFunc2();
internalFunc();
}
From now on, “module” means “ECMAScript module”.
The full standard of ES modules comprises the following parts:
Parts 1 and 2 were introduced with ES6. Work on part 3 is ongoing.
Each module can have zero or more named exports.
As an example, consider the following three files:
lib/my-math.mjs
main1.mjs
main2.mjs
Module my-math.mjs
has two named exports: square
and LIGHTSPEED
.
// Not exported, private to module
function times(a, b) {
return a * b;
}
export function square(x) {
return times(x, x);
}
export const LIGHTSPEED = 299792458;
Module main1.mjs
has a single named import, square
:
Module main2.mjs
has a so-called namespace import – all named exports of my-math.mjs
can be accessed as properties of the object myMath
:
import * as myMath from './lib/my-math.mjs';
assert.equal(myMath.square(3), 9);
assert.deepEqual(
Object.keys(myMath), ['LIGHTSPEED', 'square']);
Exercise: Named exports
exercises/modules/export_named_test.mjs
Each module can have at most one default export. The idea is that the module is the default-exported value.
Avoid mixing named exports and default exports
A module can have both named exports and a default export, but it’s usually better to stick to one export style per module.
As an example for default exports, consider the following two files:
my-func.mjs
main.mjs
Module my-func.mjs
has a default export:
Module main.mjs
default-imports the exported function:
Note the syntactic difference: The curly braces around named imports indicate that we are reaching into the module, while a default import is the module.
The most common use case for a default export is a module that contains a single function or a single class.
There are two styles of doing default exports.
First, you can label existing declarations with export default
:
Second, you can directly default-export values. In that style, export default
is itself much like a declaration.
export default 'abc';
export default foo();
export default /^xyz$/;
export default 5 * 7;
export default { no: false, yes: true };
Why are there two default export styles? The reason is that export default
can’t be used to label const
: const
may define multiple values, but export default
needs exactly one value. Consider the following hypothetical code:
With this code, you don’t know which one of the three values is the default export.
Exercise: Default exports
exercises/modules/export_default_test.mjs
So far, we have used imports and exports intuitively and everything seems to have worked as expected. But now it is time to take a closer look at how imports and exports are really related.
Consider the following two modules:
counter.mjs
main.mjs
counter.mjs
exports a (mutable!) variable and a function:
main.mjs
name-imports both exports. When we use incCounter()
, we discover that the connection to counter
is live – we can always access the live state of that variable:
import { counter, incCounter } from './counter.mjs';
// The imported value `counter` is live
assert.equal(counter, 3);
incCounter();
assert.equal(counter, 4);
Note that, while the connection is live and we can read counter
, we cannot change this variable (e.g. via counter++
).
There are two benefits to handling imports this way:
Both importing and destructuring look similar:
But they are quite different:
Imports remain connected with their exports.
You can destructure again inside a destructuring pattern, but the {}
in an import statement can’t be nested.
The syntax for renaming is different:
import {foo as f} from './bar.mjs'; // importing
const {foo: f} = require('./bar.mjs'); // destructuring
Rationale: Destructuring is reminiscent of an object literal (incl. nesting), while importing evokes the idea of renaming.
ESM supports cyclic imports transparently. To understand how that is achieved, consider the following example: Fig. 7 shows a directed graph of modules importing other modules. P importing M is the cycle in this case.
After parsing, these modules are set up in two phases:
This approach handles cyclic imports correctly, due to two features of ES modules:
Due to the static structure of ES modules, the exports are already known after parsing. That makes it possible to instantiate P before its child M: P can already look up M’s exports.
When P is evaluated, M hasn’t been evaluated, yet. However, entities in P can already mention imports from M. They just can’t use them, yet, because the imported values are filled in later. For example, a function in P can access an import from M. The only limitation is that we must wait until after the evaluation of M, before calling that function.
Imports being filled in later is enabled by them being “live immutable views” on exports.
The npm software registry is the dominant way of distributing JavaScript libraries and apps for Node.js and web browsers. It is managed via the npm package manager (short: npm). Software is distributed as so-called packages. A package is a directory containing arbitrary files and a file package.json
at the top level that describes the package. For example, when npm creates an empty package inside a directory foo/
, you get this package.json
:
{
"name": "foo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Some of these properties contain simple metadata:
name
specifies the name of this package. Once it is uploaded to the npm registry, it can be installed via npm install foo
.version
is used for version management and follows semantic versioning, with three numbers:
description
, keywords
, author
make it easier to find packages.license
clarifies how you can use this package.Other properties enable advanced configuration:
main
: specifies the module that “is” the package (explained later in this chapter).scripts
: are commands that you can execute via npm run
. For example, the script test
can be executed via npm run test
.For more information on package.json
, consult the npm documentation.
node_modules/
npm always installs packages inside a directory node_modules
. There are usually many of these directories. Which one npm uses, depends on the directory where one currently is. For example, if we are inside a directory /tmp/a/b/
, npm tries to find a node_modules
in the current directory, its parent directory, the parent directory of the parent, etc. In other words, it searches the following chain of locations:
/tmp/a/b/node_modules
/tmp/a/node_modules
/tmp/node_modules
When installing a package foo
, npm uses the closest node_modules
. If, for example, we are inside /tmp/a/b/
and there is a node_modules
in that directory, then npm puts the package inside the directory
/tmp/a/b/node_modules/foo/
When importing a module, we can use a special module specifier to tell Node.js that we want to import it from an installed package. How exactly that works, is explained later. For now, consider the following example:
To find the-module.mjs
(Node.js prefers the filename extension .mjs
for ES modules), Node.js walks up the node_module
chain and searches the following locations:
/home/jane/proj/node_modules/the-package/the-module.mjs
/home/jane/node_modules/the-package/the-module.mjs
/home/node_modules/the-package/the-module.mjs
Finding installed modules in node_modules
directories is only supported on Node.js. So how come, we can also use npm to install libraries for browsers?
That is enabled via bundling tools, such as webpack, that compile and optimize code before it is deployed online. During this compilation process, the code in npm packages is adapted so that it works in browsers.
There are no established best practices for naming module files and the variables they are imported into.
In this chapter, I’m using the following naming style:
The names of module files are dash-cased and start with lowercase letters:
./my-module.mjs
./some-func.mjs
The names of namespace imports are lowercased and camel-cased:
The names of default imports are lowercased and camel-cased:
What are the rationales behind this style?
npm doesn’t allow uppercase letters in package names (source). Thus, we avoid camel case, so that “local” files have names that are consistent with those of npm packages.
There are clear rules for translating dash-cased file names to camel-cased JavaScript variable names. Due to how we name namespace imports, these rules work for both namespace imports and default imports.
I also like underscore-cased module file names, because you can directly use these names for namespace imports (without any translation):
But that style does not work for default imports: I like underscore-casing for namespace objects, but it is not a good choice for functions etc.
Module specifiers are the strings that identify modules. They work slightly differently in browsers and Node.js. Before we can look at the differences, we need to learn about the different categories of module specifiers.
In ES modules, we distinguish the following categories of specifiers. These categories originated with CommonJS modules.
Relative path: starts with a dot. Examples:
'./some/other/module.mjs'
'../../lib/counter.mjs'
Absolute path: starts with a slash. Example:
'/home/jane/file-tools.mjs'
URL: includes a protocol (technically, paths are URLs, too). Examples:
'https://example.com/some-module.mjs'
'file:///home/john/tmp/main.mjs'
Bare path: does not start with a dot, a slash or a protocol, and consists of a single filename without an extension. Examples:
'lodash'
'the-package'
Deep import path: starts with a bare path and has at least one slash. Example:
'the-package/dist/the-module.mjs'
Browsers handle module specifiers as follows:
text/javascript
.Note that bundling tools such as webpack, which combine modules into fewer files, are often less strict with specifiers than browsers. That’s because they operate at build/compile time (not at runtime) and can search for files by traversing the file system.
Support for ES modules on Node.js is still new
You may have to switch it on via a command line flag. See the Node.js documentation for details.
Node.js handles module specifiers as follows:
Relative paths are resolved as they are in web browsers – relative to the path of the current module.
Absolute paths are currently not supported. As a work-around, you can use URLs that start with file:///
.
Only file:
is supported as a protocol for URL specifiers.
A bare path is interpreted as a package name and resolved relative to the closest node_modules
directory. What module should be loaded, is determined by looking at property "main"
of the package’s package.json
(similarly to CommonJS).
Deep import paths are also resolved relatively to the closest node_modules
directory. They contain file names, so it is always clear which module is meant.
All specifiers, except bare paths, must refer to actual files. That is, ESM does not support the following CommonJS features:
CommonJS automatically adds missing filename extensions.
CommonJS can import a directory foo
if there is a foo/package.json
with a "main"
property.
CommonJS can import a directory foo
if there is a module foo/index.js
.
All built-in Node.js modules are available via bare paths and have named ESM exports. For example:
import * as path from 'path';
import {strict as assert} from 'assert';
assert.equal(
path.join('a/b/c', '../d'), 'a/b/d');
Node.js supports the following default filename extensions:
.mjs
for ES modules.cjs
for CommonJS modulesThe filename extension .js
stands for either ESM or CommonJS. Which one it is, is configured via the “closest” package.json
(in the current directory, the parent directory, etc.). Using package.json
in this manner is independent of packages.
In that package.json
, there is a property "type"
, which has two settings:
"commonjs"
(the default): files with the extension .js
or without an extension are interpreted as CommonJS modules.
"module"
: files with the extension .js
or without an extension are interpreted as ESM modules.
Not all source code that is executed by Node.js, comes from files. You can also send it code via stdin, --eval
and --print
. The command line option --input-type
lets you specify how such code is interpreted:
--input-type=commonjs
--input-type=module
So far, the only way to import a module has been via an import
statement. That statement has several limitations:
An upcoming JavaScript feature changes that: The import()
operator. Let’s look at an example of it being used. Consider the following files:
lib/my-math.mjs
main1.mjs
main2.mjs
We have already seen module my-math.mjs
:
// Not exported, private to module
function times(a, b) {
return a * b;
}
export function square(x) {
return times(x, x);
}
export const LIGHTSPEED = 299792458;
This is what using import()
looks like in main1.mjs
:
const dir = './lib/';
const moduleSpecifier = dir + 'my-math.mjs';
function loadConstant() {
return import(moduleSpecifier)
.then(myMath => {
const result = myMath.LIGHTSPEED;
assert.equal(result, 299792458);
return result;
});
}
Method .then()
is part of Promises, a mechanism for handling asynchronous results, which is covered later in this book.
Two things in this code weren’t possible before:
Next, we’ll implement the exact same functionality in main2.mjs
, but via a so-called async function, which provides nicer syntax for Promises.
const dir = './lib/';
const moduleSpecifier = dir + 'my-math.mjs';
async function loadConstant() {
const myMath = await import(moduleSpecifier);
const result = myMath.LIGHTSPEED;
assert.equal(result, 299792458);
return result;
}
Alas, import()
isn’t a standard part of JavaScript yet, but probably will be, relatively soon. Be sure to check if it is supported where you need it.
import.meta.url
“import.meta
” is an ECMAScript feature proposal by Domenic Denicola. The object import.meta
contains metadata for the current module.
Its most important property is import.meta.url
, which contains a string with the URL of the current module file. It can, for example, be used to access a sibling file data.txt
:
import.meta.url
on Node.jsOn Node.js, import.meta.url
is always a string with a file:
URL. For example:
'file:///Users/rauschma/my-module.mjs'
Important: Use url.fileURLToPath()
to extract the path – new URL().pathname
doesn’t always work properly:
const urlStr = 'file:///tmp/with%20space.txt';
assert.equal(
new URL(urlStr).pathname, '/tmp/with%20space.txt');
assert.equal(
fileURLToPath(urlStr), '/tmp/with space.txt'); // Unix
The inverse of url.fileURLToPath()
is url.pathToFileURL()
: it converts a path to a file URL.
The following code reads the text contained in a file data.txt
, which sits next to the current module file.
import {fileURLToPath} from 'url';
import {promises as fs} from 'fs';
async function main() {
// The path of the current module
const urlOfData = new URL('data.txt', import.meta.url);
const pathOfData = fileURLToPath(urlOfData);
const str = await fs.readFile(pathOfData, {encoding: 'UTF-8'});
assert.equal(str, 'This is textual data.\n');
}
main();
main()
is an async function. What that is, is explained in §38 “Async functions”.
fs.promises
contains a Promise-based version of the fs
API that can be used with async functions.
// Re-exporting from another module
export * from './some-module.mjs';
export {foo, b as bar} from './some-module.mjs';
// Named exports
export {foo, b as bar};
export function f() {}
export const one = 1;
// Default exports
export default function f() {} // declaration with optional name
// Replacement for `const` (there must be exactly one value)
export default 123;
// Empty import (for modules with side effects)
import './some-module.mjs';
// Default import
import someModule from './some-module.mjs';
// Namespace import
import * as someModule from './some-module.mjs';
// Named imports
import {foo, bar as b} from './some-module.mjs';
// Combinations:
import someModule, * as someModule from './some-module.mjs';
import someModule, {foo, bar as b} from './some-module.mjs';
Quiz
See quiz app.