In case you havenât heard, ECMAScript modules (ESM) are now supported everywhere!
While I do have some gripes with them, itâs too late for any of these things to change, so Iâm embracing the good parts and have cautiously started using them in new projects. I do quite like that I can just use import
statements and dynamic import()
for dependencies with URLs right from my JS, without module loaders, extra <script>
tags in my HTML, or hacks with dynamic <script>
tags and load
events (in fact, Bliss has had a helper for this very thing that Iâve used extensively in older projects). I love that I donât need any libraries for this, and I can use it client-side, anywhere, even in my codepens.
Once you start using ESM, you realize that most libraries out there are not written in ESM, nor do they include ESM builds. Many are still using globals, and those that target Node.js use CommonJS (CJS). What can we do in that case? Unfortunately, ES Modules are not really designed with any import (pun intended) mechanism for these syntaxes, but, there are some strategies we could employ.
Technically, a JS file can be parsed as a module even with no imports or exports. Therefore, almost any library that uses globals can be fair game, it can just be imported as a module with no exports! How do we do that?
While you may not see this syntax a lot, you donât actually need to name anything in the import
statement. There is a syntax to import a module entirely for its side effects:
import "url/to/library.js";
This syntax works fine for libraries that use globals, since declaring a global is essentially a side effect, and all modules share the same global scope. For this to work, the imported library needs to satisfy the following conditions:
- It should declare the global as a property on
window
(or self
), not via var Foo
or this
. In modules top-level variables are local to the module scope, and this
is undefined
, so the last two ways would not work.
- Its code should not violate strict mode
- The URL is either same-origin or CORS-enabled. While
<script>
can run cross-origin resources, import
sadly cannot.
Basically, you are running a library as a module that was never written with the intention to be run as a module. Many are written in a way that also works in a module context, but not all. ExploringJS has an excellent summary of the differences between the two. For example, here is a trivial codepen loading jQuery via this method.
I dealt with this today, and itâs what prompted this post. I was trying to play around with Rework CSS, a CSS parser used by the HTTPArchive for analyzing CSS in the wild. However, all its code and documentation assumes Node.js. If I could avoid it, Iâd really rather not have to make a Node.js app to try this out, or have to dive in module loaders to be able to require CJS modules in the browser. Was there anything I could do to just run this in a codepen, no strings attached?
After a little googling, I found this issue. So there was a JS file I could import and get all the parser functionality. Except âŚthere was one little problem. When you look at the source, it uses module.exports
. If you just import
that file, you predictably get an error that module
is not defined, not to mention there are no ESM exports.
My first thought was to stub module
as a global variable, import this as a module, and then read module.exports
and give it a proper name:
window.module = {};
import "https://cdn.jsdelivr.net/gh/reworkcss/css@latest/lib/parse/index.js";
console.log(module.exports);
However, I was still getting the error that module
was not defined. How was that possible?! They all share the same global context!! *pulls hair out* After some debugging, it dawned on me: static import
statements are hoisted; the âmoduleâ was getting executed before the code that imports it and stubs module
.
Dynamic imports to the rescue! import()
is executed exactly where itâs called, and returns a promise. So this actually works:
window.module = {};
import("https://cdn.jsdelivr.net/gh/reworkcss/css@latest/lib/parse/index.js").then(_ => {
console.log(module.exports);
});
We could even turn it into a wee function, which I cheekily called require()
:
async function require(path) {
let _module = window.module;
window.module = {};
await import(path);
let exports = module.exports;
window.module = _module; // restore global
return exports;
}
(async () => { // top-level await cannot come soon enoughâŚ
let parse = await require("https://cdn.jsdelivr.net/gh/reworkcss/css@latest/lib/parse/index.js");
console.log(parse("body { color: red }"));
})();
You can fiddle with this code in a live pen here.
Do note that this technique will only work if the module youâre importing doesnât import other CJS modules. If it does, youâd need a more elaborate require()
function, which is left as an exercise for the reader. Also, just like the previous technique, the code needs to comply with strict mode and not be cross-origin.
A similar technique can be used to load AMD modules via import()
, just stub define()
and youâre good to go.
So, with this technique I was able to quickly whip up a ReworkCSS playground. You just edit the CSS in CodePen and see the resulting AST, and you can even fork it to share a specific AST with others! :)
https://codepen.io/leaverou/pen/qBbQdGG
After this article was posted, a clever hack was pointed out to me on Twitter:
While this works great if you can have multiple separate files, it doesnât work when youâre e.g. quickly trying out a pen. Data URIs to the rescue! Turns out you can import
a module from a data URI!
So letâs adapt our Rework example to use this:
https://codepen.io/leaverou/pen/xxZmWvx
Since I was bound to get questions about what my gripes are with ESM, I figured I should mention them pre-emptively.
First off, a little context. Nearly all of the JS I write is for libraries. I write libraries as a hobby, I write libraries as my job, and sometimes I write libraries to help me do my job. My job is usability (HCI) research (and specifically making programming easier), so Iâm very sensitive to developer experience issues. I want my libraries to be usable not just by seasoned developers, but by novices too.
ESM has not been designed with novices in mind. It evolved from the CJS/UMD/AMD ecosystem, in which most voices are seasoned developers.
My main gripe with them, is how they expect full adoption, and settle for nothing less. There is no way to create a bundle of a library that can be used both traditionally, with a global, or as an ES module. There is also no standard way to import older libraries, or libraries using other module patterns (yes, this very post is about doing that, but essentially these are hacks, and there should be a better way). I understand the benefits of static analysis for imports and exports, but I wish there was a dynamic alternative to export
, analogous to the dynamic import()
.
In terms of migrating to ESM, I also dislike how opinionated they are: strict mode is great, but forcing it doesnât help people trying to migrate older codebases. Restricting them to cross-origin is also a pain, using <script>
s from other domains made it possible to quickly experiment with various libraries, and I would love for that to be true for modules too.
But overall, Iâm excited that JS now natively supports a module mechanism, and I expect any library I release in the future to utilize it.