Skip to content

Research: ES Modules vs CommonJS

Matěj Chalk edited this page Aug 22, 2023 · 12 revisions

Motivation

We've decided on using JavaScript config files. But this presents the problem whether to support both CommonJS and ES Modules, and how that can be implemented and tested.

Comparison

CommonJS ES Modules
ecosystem trends older and more widely supported Node 13+ and some newer packages
import/export syntax require() and module.exports = import and export, also dynamic import()
explicit file extensions .cjs/.cts .mjs/.mts
implicit file extensions (.js) if "type" not specified or set to "commonjs" in package.json if "type": "module" in package.json
package.json's "exports" (separate entry points) "require": "<path/to/cjs/script>" "import": "<path/to/esm/script>"
interoperability ESM ➡️ CJS: must use async import() CJS ➡️ ESM: import ... from './foo.cjs'

References

Build tools

Testing

  • Jest runs as CommonJS, support for ESM experimental
  • Vitest runs as ESM

PoC

See esm-vs-cjs branch.

End result

  • CLI is ESM-only
  • ESLint plugin imports eslint (CommonJS) and is both ESM and CJS
  • Lighthouse plugin imports lighthouse (ESM) and is therefore ESM-only
  • config files can use ESM (.js or .mjs), CJS (.cjs) or TypeScript (.ts), but CJS is only possible if no ESM-only plugins are included (e.g. Lighthouse)
  • loading config files can be tested
    • depends on build step
    • config files may include imports from linked plugins via NPM package name
  • config file names are resolved relative to working directory

How it works

  • Rollup is used to create both .cjs and .mjs files along with appropriate package.json metadata (main, module, exports)
    • @nx/rollup uses .cjs.js and .mjs.js extensions, workaround via custom rollup.config.js and patch-package
  • Vitest used for testing, since unlike Jest it uses ESM
  • imports in JS configs (e.g. import eslintPlugin from '@quality-metrics/eslint-plugin') enabled via NPM workspaces
    • automatically create symlinks in node_modules/ for dist/packages/* on npm install
    • this is why test target depends on build targets (automated with Nx task dependencies)
  • import.meta.url used to resolve config files relative to CWD
  • Jiti used to import TypeScript files
    • problematic when importing lighthouse, because Jiti executes in CommonJS - hackfixed via custom Babel plugin (plugin handbook and AST Explorer were incredibly useful, BTW)