ECMAScript Modules and how to use them!
ECMAScript Modules, which I will refer to as ESM throughout the rest of this article, are a stepping stone for JavaScript from the world of crafted context to having a framework to completely separate concerns.
The ESM syntax and supporting functions provide a great benefit to both browser and the Node.js ecosystems.
This article will try to discuss ESM in a platform agnostic way, however there will be examples of both browser usage and Node.js usage.
In the context of browsers, with the rise of HTTP/2 we can afford to use the platform to provide our module concerns, as we're able to load our resources effectively while still having them in their published format.
ESM allows us to effectively separate concerns throughout our codebase and allows us to write code in a platform agnostic way without the need for a build step (when ESM is supported by a large majority of environments).
Why they are important
ESM is an important step in the JavaScript ecosystem because it firstly allows static analysis of our depency tree, for bundlers this is a huge deal because it allows files to be crafted that only contain code that is required at runtime, which means smaller downloads for environments that require dependencies to be downloaded over the network or nondeterministically (for example a browser or Yarn's Plug'n'Play).
ESM makes true encapsulation the default for modules, which means no pollution of a global scope (unless that scope is explicitly written to by way of global
or window
). This encapsulation allows developers to take control of their module and define their own best practice in module publication. By using static analysis we can now also apply restrictions around these best practices without ever needing to run the code to check what it does.
Because of this encapsulation the only thing consuming modules require is the module signature, which again can be analysed by tools statically, this allows for both type-ahead completion within development environments, but also ahead of time checking that a process will run correctly given the dependencies available.
Differences between older style and new (ESM)
Before ESM came to the world of JavaScript we had a few options available, some environments (like Node.js and commonjs) provided their own standard on how modules should work. These worked great and definitely had their best interests in mind.
Immediately Invoked Function Expression (IIFE)
An IIFE was a way to reduce pollution of the global scope, and allowed developers to encapsulate their code into a scope where they could do as they please, it also provided a way to bring a dependency into scope.
An example of this would be to map jQuery
to the common $
.
As a side note it was also typical to include an argument that would never be provided as
undefined
so the developer was sure any usage ofundefined
truly wasundefined
, the value was defined as unwritable as of ES5. It also appears that browsers no longer allow variables to be defined with the nameundefined
, and throws no errors if this is done throughout code.
js(function(window, $, undefined) { })(window, jQuery)
IIFE didn't provide much of a way to define modules, except for being able to assign the returned value from the function to a variable in the global scope, which wasn't the most ideal solution as the variable could simply be overwritten by a developer at a later stage if naming conventions weren't in place etc.
CommonJS and Node.js
CommonJS was utilised extensively for modules compatible with Node.js, the Node.js implementation would provide a scoped function named require
to modules, which could be used to import additional dependencies, which was done in a synchronous fashion. It expected that each dependency was already stored on disk in a pre-defined format.
A module was provided an object that it could either assign properties to (by way of exports
or module.exports
, or replace the entire object by assigning a value directly to module.exports
.
CommonJS however didn't work well for the web, as it didn't include any way to fetch dependencies in an asynchronous way. This led to a mismatch between a browser environment and Node.js, where you would either need to utilise require.ensure
or bundle all code and dependencies into a set of files that can be loaded together (by way of bundling and code splitting).
CommonJS also never progressed to being a standard adopted by the JavaScript language, meaning it was never going to be truly a universal way to create modules across all the possible environments that we have today.
A great discussion on the trade offs of CommonJS can be found here.
Asynchronous Module Definition (AMD)
A popular in-browser module format was AMD, which was implemented by RequireJS, this module loader took CommonJS and supercharged it to support much more.
Because these modules are asynchronous the module developer can ignore if their dependencies needs to be fetched across a network, or a file loaded from disk in an asynchronous manner. The developer is only concerned about defining what dependency they require, and don't want to run anything until they receive their dependencies.
RequireJS introduced additional syntax to define modules and ensure dependencies are available. However in the context of browsers, this was a negligible detail to developers looking for a better way to build their projects.
Example time
ESM allows common code across environments by default, so along with this article I have included both an example of ESM usage for Node.js and for the browser.
For previous generation module definitions we only had the ability to export a single set of values. For example either a single function, or a set of functions, by way of assigning to a single object. ESM gives us more flexibility by providing the ability to define both named exports as well as a default export.
Named Exports
A named export allows developers (depending on the module) to specify exactly the value they want to use. For example if we define:
jsexport function addTask(task) { }
Then the dependening module could import only this function using a named import:
jsimport { addTask } from "./tasks.js";
When the depending module is loaded, the environment will check to see if the tasks.js
module exports a named value called addTask
. If it does then we are good to go, else we will get an error. This can be checked ahead of time using static analysis.
The export
syntax allows us to export a named value in a couple more ways separate from the one described above. If you're defining a value instead of a function you can use export const
:
jsexport const helpfulValue = 42; export let someLetValue = 43; someLetValue -= 1; export let someVarValue = 44; someVarValue -= 1;
This works the same as a standard const
declaration, and can be used in the scope of the defining module as if it was never exported. This is helpful for modules that are defining a variable that would be handy for external dependent modules to be able to utilise. Both let
and var
are also exportable, however any value that is exported is considered read only to dependent modules. This allows you to define a value as let
or var
, while still being essentially const
to dependent modules.
If you're generating a value at runtime to export, or you have imported a value from another module, this third way of defining named exports will come in handy:
jsimport someDefaultValue from "./some-value.js"; import { addTask } from "./tasks.js"; let somethingCalculated = 42; if (someDefaultValue) { somethingCalculated *= someDefaultValue; } export { addTask, someDefaultValue as someValue, somethingCalculated }
When using export { }
you can also map a variable to another name using as
, dependent modules will see someDefaultValue
as someValue
for example.
The final way of exporting named exports is to export directly from another module. This also allows for the as
syntax directly in the export statement:
jsexport { addTask, someValue as newValue } from "./example-before";
Exporting values in this way allows you to map from a dependent module without polluting the modules scope.
Default Exports
Along with named exports, ESM also allows a default value to be exported. This isn't required, however only one default
value can be exported. This can be done in one of three ways, either by using export default
, export { variable as default }
, or export { default } from ...
jsexport default function() { return 42; }
jsexport { default } from "./example-1";
jsimport example1 from "./example-1"; import example2 from "./example-2"; export { example1, example2, example2 as default }
This gives us great flexibility when defining our modules. The language effectively making no assumptions around the design decisions of the developer.
Named Imports
Now we have a way of defining named exports, we also want a way to bring these values into the scope of another module. When importing from another module, the values defined are considered read only, so treat them as if they are const
variables.
The most basic way to import a value is to define the name of the value you want, and from where. In the context of the browser you should always include the file extension for the imported module. However in the context of Node.js the loader assumes the extension. As a best practice for multi platform code, define the extension each time.
Like when exporting a value, we can also map the value to a new name, meaning dependent modules aren't stuck with the naming decisions of the dependency:
jsimport { getTasks, addTask as addTaskBase } from "./tasks.js"
If you're not sure exactly what named export you want, or want a single object to reference any exported name value for a module, you can use the star syntax:
jsimport * as Tasks from "./tasks.js"
You can then treat the Tasks object as a read only object where your named exports will be available.
Default Imports
A value that was exported as the default export from another module can be imported into a module using the following syntax:
jsimport example3 from "./example-3";
The name you assign to the imported value doesn't need to match what the module declared the value is, leaving the decision of this to the developer.
Module only
A module may have some code to run within the body of the module definition. This is great for modules that may provide some isolated functionality without needing any setup. These modules may or may not export any values. When using this syntax you're declaring a dependency on the module body, rather than on a specific named export:
jsimport "./some-module"
Because the dependent module doesn't care what is exported, no mapping needs to happen.
All Imports Styles
You can combine the different kinds of import syntax into one. Notice how default
can be imported as two seperate values:
jsimport example3, { example2, example2 as newExample, default as defaultExample } from "./example-3"; import example3Another, * as Example3Object from "./example-3";
The import syntax gives great flexibility for module developers depending on other modules, leaving all decisions around how they want to utilise export
and import
.
Availability
Node.js
You can use ESM now in Node.js by utilising the ESM module. Examples on usage is described by the documentation there.
Node.js currently has native support for ESM as of pull request 26745. The documentation for ESM can be found here. The experimental feature can be enabled by using the --experimental-modules
flag when starting node (TODO currently waiting for release to be made).
Browsers
You can view what browsers and versions that support ESM here.
Detecting availability
If you're in a browser context you can check or the availability for ESM by checking if the attribute noModule
is supported for a script
element. To do this we create a script
element using document.createElement
and then check if the property exists using the in
syntax.
jsfunction supportsModule() { if (typeof document === "undefined") { // Not in a DOM based environment return false; } const script = document.createElement("script"); return "noModule" in script; } console.log({ supported: supportsModule() });
Backwards compatibility
The noModule
attribute described above allows us to define a script
element to run when type="module"
isn't supported. This is great because any browser that has not yet implemented ESM will ignore the noModule
attribute and treat it as any other user defined attribute. This allows us to provide a pre-bundled alternative only when it is required:
html<script type="module" src="./js/client.js"></script> <script type="application/javascript" src="./bundles/client.js" nomodule></script>