ESM and CommonJS Interoperability
The transformation of ESM and CJS
when ESM transform to CJS the default export of ESM will transform to a named export in CJS
export default 1;
// transform
exports.default = 1; // not module.exports = 1
Object.defineProperty(exports, '__esModule', { value: true });
when CJS transform to ESM, the usual way is to wrap the whole CJS module in a function and the transfomed ESM code will export default of the return value of that warpper, this preserved the semantic of CJS: the exports are determined when code excution.
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var require_stdin = __commonJS({
"<stdin>"(exports, module) {
const A = 1;
const B = 2;
module.exports = {
A,
B,
default: 1
};
}
});
export default require_stdin();
But this will force the invoker to import the default value of the cjs-esm, and deconstruct the named export from the default value, a work around to this is to wrap the cjs-esm with CJS’s named export to ESM named export:
// the wrapper
private async wrapESM(cjsModule) {
await init();
const raw = await readFile(cjsModule, "utf-8");
const ast = parse(raw);
const namedExports = ast.exports;
let wrapESMContent = `
import * as __module from "${cjsModule}";
const { default: __default, ...__rest } = __module;
export default (__default !== undefined ? __default : __rest);
`;
if (namedExports && namedExports.length > 0) {
wrapESMContent += `export const { ${namedExports
.filter((name) => name !== "default")
.join(",")} } = __default ?? __module;`; // if the default is present, all the named export will bind on it
}
const wrapESMPath = path.dirname(cjsModule) + "/index.mjs";
await writeFile(wrapESMPath, wrapESMContent, "utf-8");
return wrapESMPath;
}
when cjs require
is used, mostly in some libary that declared to support both node and browser, they often achive this by
<!-- using browser api -->
if (typeof window !== "undefined" && window.api) {
return window.api();
}
else if (typeof require === "function") {
return require("xxx")();
}
this is troublesome when a bundler like webpack try to bundle on this module to browser. it will see the require
is used, it will try bundle the required module to output, but that module is not browser compatible. some library wil try to bypass webpack’s require behavior by using __non_webpack_require__
to avoid the bundler to bundle the required module
But require and import are totally different things, require runs at runtime, for js perspecitve its just a fucntion call, while import in specification is a declaration, its just a pure semantic and static analysisble top level structure, also the dynamic import expression is not equivalent to require, it returns a promise.
furthermore, when TLA is used, cjs and module are become more imcompatible, asume module A is a module that use TLA,
whats the result of a require(esm)
? nodejs still works on this, but how bundler deal with it? webpack support a simmilar
concepte like TLA called async modules, it transforms the async module imports like this:
async module() {
const esm = await webpack_require('async_esm');
function fn() {
// use module as is a sync module
return esm.default;
}
}
but this has color problem, all modules that involved this module alone the path will transform to async modules too, until there is
a require()
call on that path, webpack will keep the async module as is
Reference
https://github.com/evanw/esbuild/issues/946 https://joyeecheung.github.io/blog/2024/03/18/require-esm-in-node-js/