10 KiB
title | description |
---|---|
ES Modules | Nuxt 3 (and Bridge) uses Native ES Modules. |
This guide helps explain what ES Modules are and how to make a Nuxt app (or upstream library) compatible with ESM.
Background
CommonJS Modules
CommonJS (CJS) is a format introduced by Node.js that allows sharing functionality between isolated JavaScript modules (read more). You might be already familiar with this syntax:
const a = require('./a')
module.exports.a = a
Bundlers like webpack and Rollup support this syntax and allow you to use modules written in CommonJS in the browser.
ESM Syntax
Most of the time, when people talk about ESM vs CJS, they are talking about a different syntax for writing modules.
import a from './a'
export { a }
Before ECMAScript Modules (ESM) became a standard (it took more than 10 years!), tooling like webpack and even languages like TypeScript started supporting so-called ESM syntax. However, there are some key differences with actual spec; here's a helpful explainer.
What is 'Native' ESM?
You may have been writing your app using ESM syntax for a long time. After all, it's natively supported by the browser, and in Nuxt 2 we compiled all the code you wrote to the appropriate format (CJS for server, ESM for browser).
When adding modules to your package, things were a little different. A sample library might expose both CJS and ESM versions, and let us pick which one we wanted:
{
"name": "sample-library",
"main": "dist/sample-library.cjs.js",
"module": "dist/sample-library.esm.js"
}
So in Nuxt 2, the bundler (webpack) would pull in the CJS file ('main') for the server build and use the ESM file ('module') for the client build.
However, in recent Node.js LTS releases, it is now possible to use native ESM module within Node.js. That means that Node.js itself can process JavaScript using ESM syntax, although it doesn't do it by default. The two most common ways to enable ESM syntax are:
- set
type: 'module'
within yourpackage.json
and keep using.js
extension - use the
.mjs
file extensions (recommended)
This is what we do for Nuxt Nitro; we output a .output/server/index.mjs
file. That tells Node.js to treat this file as a native ES module.
What Are Valid Imports in a Node.js Context?
When you import
a module rather than require
it, Node.js resolves it differently. For example, when you import sample-library
, Node.js will look not for the main
but for the exports
or module
entry in that library's package.json
.
This is also true of dynamic imports, like const b = await import('sample-library')
.
Node supports the following kinds of imports (see docs):
- files ending in
.mjs
- these are expected to use ESM syntax - files ending in
.cjs
- these are expected to use CJS syntax - files ending in
.js
- these are expected to use CJS syntax unless theirpackage.json
hastype: 'module'
What Kinds of Problems Can There Be?
For a long time module authors have been producing ESM-syntax builds but using conventions like .esm.js
or .es.js
, which they have added to the module
field in their package.json
. This hasn't been a problem until now because they have only been used by bundlers like webpack, which don't especially care about the file extension.
However, if you try to import a package with an .esm.js
file in a Node.js ESM context, it won't work, and you'll get an error like:
(node:22145) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/path/to/index.js:1
export default {}
^^^^^^
SyntaxError: Unexpected token 'export'
at wrapSafe (internal/modules/cjs/loader.js:1001:16)
at Module._compile (internal/modules/cjs/loader.js:1049:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)
....
at async Object.loadESM (internal/process/esm_loader.js:68:5)
You might also get this error if you have a named import from an ESM-syntax build that Node.js thinks is CJS:
file:///path/to/index.mjs:5
import { named } from 'sample-library'
^^^^^
SyntaxError: Named export 'named' not found. The requested module 'sample-library' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from 'sample-library';
const { named } = pkg;
at ModuleJob._instantiate (internal/modules/esm/module_job.js:120:21)
at async ModuleJob.run (internal/modules/esm/module_job.js:165:5)
at async Loader.import (internal/modules/esm/loader.js:177:24)
at async Object.loadESM (internal/process/esm_loader.js:68:5)
Troubleshooting ESM Issues
If you encounter these errors, the issue is almost certainly with the upstream library. They need to fix their library to support being imported by Node.
Transpiling Libraries
In the meantime, you can tell Nuxt not to try to import these libraries by adding them to build.transpile
:
export default defineNuxtConfig({
build: {
transpile: ['sample-library']
}
})
You may find that you also need to add other packages that are being imported by these libraries.
Aliasing Libraries
In some cases, you may also need to manually alias the library to the CJS version, for example:
export default defineNuxtConfig({
alias: {
'sample-library': 'sample-library/dist/sample-library.cjs.js'
}
})
Default Exports
A dependency with CommonJS format, can use module.exports
or exports
to provide a default export:
module.exports = { test: 123 }
// or
exports.test = 123
This normally works well if we require
such dependency:
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }
Node.js in native ESM mode, typescript with esModuleInterop
enabled and bundlers such as webpack, provide a compatibility mechanism so that we can default import such library.
This mechanism is often referred to as "interop require default":
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }
However, because of the complexities of syntax detection and different bundle formats, there is always a chance that the interop default fails and we end up with something like this:
import pkg from 'cjs-pkg'
console.log(pkg) // { default: { test: 123 } }
Also when using dynamic import syntax (in both CJS and ESM files), we always have this situation:
import('cjs-pkg').then(console.log) // [Module: null prototype] { default: { test: '123' } }
In this case, we need to manually interop the default export:
// Static import
import { default as pkg } from 'cjs-pkg'
// Dynamic import
import('cjs-pkg').then(m => m.default || m).then(console.log)
For handling more complex situations and more safety, we recommend and internally use mlly in Nuxt 3 that can preserve named exports.
import { interopDefault } from 'mlly'
// Assuming the shape is { default: { foo: 'bar' }, baz: 'qux' }
import myModule from 'my-module'
console.log(interopDefault(myModule)) // { foo: 'bar', baz: 'qux' }
Library Author Guide
The good news is that it's relatively simple to fix issues of ESM compatibility. There are two main options:
-
You can rename your ESM files to end with
.mjs
.This is the recommended and simplest approach. You may have to sort out issues with your library's dependencies and possibly with your build system, but in most cases, this should fix the problem for you. It's also recommended to rename your CJS files to end with
.cjs
, for the greatest explicitness. -
You can opt to make your entire library ESM-only.
This would mean setting
type: 'module'
in yourpackage.json
and ensuring that your built library uses ESM syntax. However, you may face issues with your dependencies - and this approach means your library can only be consumed in an ESM context.
Migration
The initial step from CJS to ESM is updating any usage of require
to use import
instead:
::code-group
module.exports = ...
exports.hello = ...
export default ...
export const hello = ...
::
::code-group
const myLib = require('my-lib')
import myLib from 'my-lib'
// or
const myLib = await import('my-lib').then(lib => lib.default || lib)
::
In ESM Modules, unlike CJS, require
, require.resolve
, __filename
and __dirname
globals are not available
and should be replaced with import()
and import.meta.filename
.
::code-group
import { join } from 'path'
const newDir = join(__dirname, 'new-dir')
import { fileURLToPath } from 'node:url'
const newDir = fileURLToPath(new URL('./new-dir', import.meta.url))
::
::code-group
const someFile = require.resolve('./lib/foo.js')
import { resolvePath } from 'mlly'
const someFile = await resolvePath('my-lib', { url: import.meta.url })
::
Best Practices
-
Prefer named exports rather than default export. This helps reduce CJS conflicts. (see Default exports section)
-
Avoid depending on Node.js built-ins and CommonJS or Node.js-only dependencies as much as possible to make your library usable in Browsers and Edge Workers without needing Nitro polyfills.
-
Use new
exports
field with conditional exports. (read more).
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}