Still not clear to me what ESM exactly fixes. CJS works pretty well for me. The amount of pain ESM gives in unit tests is enough to ignore ESM. Maybe Bun will make a difference here.
- The dependencies of a module can be scraped without running the code
- The subset of declarations used in an import can be known
This allows modules to be loaded in parallel, and also allows for things like tree-shaking. The biggest benefits are for web browsers, but all around it's a more constrained way to express dependencies, which allows the bundler/runtime to take more liberties.
CJS does not work in browsers. Bundlers fake it well enough you might not feel that pain regularly, but ESM was designed for use in browsers and CJS wasn't.
You could get those benefits through special syntax that chose to not deviate from commonjs semantics.
We are in this mess because tc39 explicitly chose to not give a damn about nodejs ecosystem.
None of the secondary benefits of ESM to commonjs semantics incompatibility (like recursive imports, top level awaits etc) are IMHO worth the ecosystem disruption.
CJS makes an assumption that all of the main body of a module is synchronous including its imports and that the main body of all of its imports also ran synchronously prior to the main body of that module. A lot of CJS modules are built around side-effects during module loading, built on assumptions from the way that Node especially synchronously loads everything, that break in any attempt to make the process asynchronous. It's not just a matter of syntax sugar, it's a trouble of bad assumptions and presumptions in the CJS format itself that browsers have no way to paper over (but bundlers can fake by putting everything in the same file). Loading a new file is always an asynchronous operation in a browser. CJS has never supported that. Those problems were known at the time when Node picked CJS. (AMD was the competing format that worked in browsers. AMD had its own problems and was a pain to work in without tools like Typescript, but it was compatible with browsers. CJS never was. ESM imports are backward compatible with AMD imports but because AMD is mostly a dead format in 2023 no browser today actually implements the shims to support that, but they were on the table for a long time and can still be ponyfilled if anyone is crazy enough to have an AMD codebase in 2023 that didn't switch to a CJS-based hodge podge in Node at some point in the last decade and that they can't all-at-once migrate to ESM. Which in my experience mostly just describes what's left of Esri's over-reliance on Dojo's AMD loader for ArcGIS JS and just about nothing else in the wild in 2023.) This mess is all Node's fault and it should have never picked CJS in the first place, that was always the losing horse.
(Hypothetically there might have been a place and time in CJS history to force CJS require() to always return a promise and module.exports to always take a function that returns a promise and that hypothetical version might have been compatible enough to import directly from ESM. Nobody actually wanted that hypothetical "ACJS" so it never existed. ACJS could have at least met browsers half-way and might have made the transition less overall painful.)
Nobody is advocating for synchronously importable modules in browsers. Also, people had already written support utilities like require1k [1] to support cjs in browsers. We don't necessarily need a full bundler - we only need a module analysis step between parse and execute which a native js engine is quite well positioned to facilitate.
Having said that, the fact remains that vast majority of pain points that people who actually need to maintain isomorphic libraries face today have nothing to do with synchronous vs asynchronous nature of cjs and esm modules.
It is the myriad pointless nuances like default export, namespace imports etc. that are sources of biggest headaches in day to day work. In cjs there is a simple model that a module exports an object and while importing you import that object. Instead now we have a situation where we need to deal with:
export default {
foo() {...}
}
is not same as:
export function foo() { ... }
and
import Foo from "foo"
is something different from
import * as Foo from "foo"
Plus the additional complexities introduced by import bindings being live etc. are just annoyances that one has to deal with over and over again every time module interop is involved.