๐ฐ Prologue - The Kingdoms of ESM & CJS
Once upon a time, in the vast universe of JavaScript, there existed two magical realms - ESM, the kingdom of ECMAScript modules, and CJS, the land of CommonJS. Over the past decade, due to the absence of a unified magical script (module system) of JavaScript, CJS (signified by the incantation require('xxx')
and module.exports
syntax) was the universal spell used in Node.js and NPM packages.
However, in the fateful year of 2015, ESM's powerful runes finally emerged as the standard incantation, prompting a gradual migration towards native ESM. This enabled named exports, better static analysis, tree-shaking, and native browser support. ๐ณ๐
// CJS
const circle = require('./circle.js')
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`)
// ESM
import { area } from './circle.mjs'
console.log(`The area of a circle of radius 4 is ${area(4)}`)
ESM was like the prophecy of a new dawn, offering a promising future for JavaScript. Node.js v12 introduced experimental support for native ESM, which was later stabilized in v12.22.0 and v14.17.0. As we approached the end of 2021, many packages began to be shipped in pure-ESM format or as a hybrid of CJS and ESM; meta-frameworks like Nuxt 3 and SvelteKit recommended users to adopt an ESM-first environment. ๐
However, this migration was akin to a grand quest, full of challenges and adventures. For most library authors, shipping dual formats became a safer, smoother path, enabling them to reap the benefits of both realms.
๐ The Challenge of Compatibility
Despite ESM's promising future, the road was fraught with obstacles. One such hurdle was the inability to use ESM packages in CJS. Node.js was smart enough to allow CJS and ESM packages to co-exist, but attempting to use ESM packages in CJS would result in an error. ๐ง
// in CJS
const pkg = require('esm-only-package') // will throw an error
The underlying issue was that ESM is asynchronous by nature, which means it couldn't be imported into the synchronous context of require
. This generally meant that to use ESM packages, you also needed to use ESM. There was one exception though - ESM packages could be used in CJS using dynamic import()
.
// in CJS
const { default: pkg } = await import('esm-only-package')
Meanwhile, if you were able to use ESM directly, it was significantly easier as import
supports both ESM and CJS.
// in ESM
import { named } from 'esm-package'
import cjs from 'cjs-package'
Some packages started shipping pure-ESM packages, advocating for the ecosystem to transition from CJS to ESM. While this might be the "right thing to do", considering the majority of the ecosystem was still on CJS and the migration wasn't a walk in the park, I found myself more inclined to ship both CJS and ESM formats for a smoother transition.
๐งช The Alchemical Recipe - package.json
Fortunately, Node allowed us to hold these two realms in balance, marking the scripts with "type": "module"
in package.json
. This made .js
files to be interpreted as ESM by default. However, if we wanted to use both ESM and CJS in the same codebase, we needed to use .cjs
and .mjs
extensions for CJS and ESM respectively. This was akin to placing a magical seal to ensure harmony.
// package.json
{
"type": "module",
"main": "./index.cjs",
"module": "./index.mjs",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
In the above example, "main"
and "module"
entries pointed to our CJS and ESM entries respectively. The "exports"
field was especially powerful. It allowed us to map different entry points for require
and import
, thus ensuring compatibility with both.
// index.mjs
import { named } from 'esm-package'
import cjs from 'cjs-package'
export default named + cjs
// index.cjs
const { named } = require('esm-package')
const cjs = require('cjs-package')
module.exports = named + cjs
While this was a good start, it wasn't perfect. To fully support both ESM and CJS, you needed to ensure that your code and its dependencies were compatible with both module systems. This could be a significant challenge, but with careful planning and a thorough understanding of both systems, it was achievable.
๐งญ The Journey Ahead
The migration from CJS to ESM may seem like a long and winding journey, but it's a path that the JavaScript community is steadily traversing. With every challenge, there are new learnings, and with every step forward, we're making the ecosystem more future-ready.
As with any quest, patience and persistence are the keys to success. The future of JavaScript is bright with ESM, and the potential it holds is truly magical. So, let's brace ourselves for the exciting journey ahead and be a part of this evolution. ๐๐ก
// The future is here, and it's written in ESM!
import { future } from 'javascript'
console.log(`Welcome to the ${future} of JavaScript!`)
๐ Epilogue
This tale is an ongoing saga in the world of JavaScript. It serves as a testament to the dynamism of this language and the relentless pursuit of its community for better, more efficient coding practices.
Remember, whether you're an ESM enthusiast or a CJS loyalist, in the end, we're all JavaScript wizards weaving our own magic. Let's continue to evolve, innovate, and inspire each other in this enchanted tale of JavaScript's magical evolution. ๐งโโ๏ธ๐๐
๐ฃ The Oracles Speak - The Tools of Prophecy
In the quest of magical JavaScript transformation, there are two legendary tools that JavaScript wizards have been using โ tsup
and buildkarium
. These tools help in creating a balance between the ESM and CJS realms, ensuring a smoother transition.
โก tsup
The first magical tool, tsup
by @egoist, offers a zero-config building experience for TypeScript projects, bringing harmony between CJS and ESM.
$ tsup src/index.ts --format cjs,esm
The command above will create two files: dist/index.js
(for CJS) and dist/index.mjs
(for ESM), thus preserving the balance between the two realms.
๐ buildkarium
The second tool in our arsenal, buildkarium
by the @nyxblabs organization, is a more generalized, customizable, and powerful tool. It offers a unique feature called Stubbing, which eliminates the need for a watcher process. It's akin to a magical charm that ensures your code is always up-to-date.
$ buildkarium --stub
Running the command above once is all you need to keep your library code up-to-date!
The buildkarium
tool also enables Bundleless Build, which allows you to maintain the structure of your source code, enabling on-demand importing of submodules, thus optimizing performance.
// karium.config.ts
import { defineBuildConfig } from 'buildkarium'
export default defineBuildConfig({
entries: [
// bundling
'src/index',
// bundleless, or just copy assets
{ input: 'src/components/', outDir: 'dist/components' },
],
declaration: true,
})
๐ Bridging Context Misalignment
Despite these powerful tools, wizards must remain cautious of context misalignment between ESM and CJS. ESM does not recognize __dirname
, __filename
, require
, require.resolve
, instead it uses import.meta.url
. However, with a little extra spellcasting, we can conjure up a solution:
import { dirname } from 'path'
import { fileURLToPath } from 'url'
const _dirname = typeof __dirname !== 'undefined'
? __dirname
: dirname(fileURLToPath(import.meta.url))
The code snippet above provides an isomorphic __dirname
, working in both ESM and CJS contexts.
๐ The Magical Epilogue
This tale of ESM and CJS is far from over, the JavaScript community continues its quest for the most efficient coding practices. As we venture forth, let's embrace the magical evolution of JavaScript, making the most of both the CJS past and the ESM future.
Remember, whether you're working in ESM or CJS, we're all JavaScript wizards, conjuring up our own magical code in this ever-evolving landscape. ๐งโโ๏ธ๐๐
2023-PRESENT ยฉ Dennis Ollhoff
Top comments (0)