ES-модули
В этом разделе объясняется, что такое ES-модули и как сделать приложение Nuxt (или зависимую библиотеку) совместимым с ESM.
Предыстория
Модули CommonJS
CommonJS (CJS) — формат, появившийся в Node.js для обмена кодом между изолированными модулями (подробнее). Синтаксис может быть уже знаком:
const a = require('./a')
module.exports.a = a
Бандлеры вроде webpack и Rollup поддерживают этот синтаксис и позволяют использовать CJS-модули в браузере.
Синтаксис ESM
Когда говорят ESM против CJS, обычно имеют в виду разный синтаксис модулей:
import a from './a'
export { a }
До появления стандарта ECMAScript Modules (на это ушло больше 10 лет) инструменты вроде webpack и даже TypeScript поддерживали так называемый синтаксис ESM. При этом есть отличия от финальной спецификации; полезный обзор — здесь.
Что такое «нативный» ESM?
Вы могли давно писать приложение в синтаксисе ESM: он поддерживается браузером, а в Nuxt 2 весь ваш код компилировался в нужный формат (CJS для сервера, ESM для браузера).
С модулями в пакетах было иначе. Библиотека могла отдавать и CJS, и ESM-версии:
{
"name": "sample-library",
"main": "dist/sample-library.cjs.js",
"module": "dist/sample-library.esm.js"
}
В Nuxt 2 бандлер (webpack) подтягивал CJS-файл (main) для серверной сборки и ESM (module) для клиентской.
module — соглашение для бандлеров вроде webpack и Rollup; Node.js его не использует. Node.js опирается на поля exports и main.В современных LTS-релизах Node.js можно использовать нативные ESM. Node.js может выполнять JavaScript в синтаксисе ESM, хотя по умолчанию этого не делает. Включить ESM можно так:
- задать
"type": "module"вpackage.jsonи оставить расширение.js; - использовать расширение
.mjs(рекомендуется).
Так сделано в Nitro: создаётся файл .output/server/index.mjs, и Node.js воспринимает его как нативный ES-модуль.
Какие импорты допустимы в Node.js?
При import вместо require Node.js разрешает модули иначе. Например, при импорте sample-library Node смотрит поле exports в package.json библиотеки или при его отсутствии — main.
То же относится к динамическим импортам: const b = await import('sample-library').
Node поддерживает такие импорты (документация):
- файлы с расширением
.mjs— ожидается синтаксис ESM; - файлы с расширением
.cjs— ожидается синтаксис CJS; - файлы с расширением
.js— ожидается CJS, если вpackage.jsonнет"type": "module".
Какие бывают проблемы?
Долгое время авторы библиотек собирали сборки в синтаксисе ESM и клали их в файлы вроде .esm.js или .es.js, указывая их в поле module в package.json. Для бандлеров этого хватало. Но при импорте такого пакета в нативном ESM-контексте Node.js возникнет ошибка:
(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'
...
Та же идея, если вы делаете именованный импорт из ESM-сборки, а Node считает модуль 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...
Решение проблем с ESM
Если вы видите такие ошибки, причина почти наверняка в подключаемой библиотеке. Ей нужно исправить поддержку импорта в Node.
Транспиляция библиотек
Временно можно исключить проблемные библиотеки из прямого импорта, добавив их в build.transpile:
export default defineNuxtConfig({
build: {
transpile: ['sample-library'],
},
})
Иногда нужно добавить и другие пакеты, от которых зависят эти библиотеки.
Алиасы библиотек
В некоторых случаях можно вручную задать алиас на CJS-версию:
export default defineNuxtConfig({
alias: {
'sample-library': 'sample-library/dist/sample-library.cjs.js',
},
})
Default-экспорты
Зависимость в формате CommonJS может отдавать default-экспорт через module.exports или exports:
module.exports = { test: 123 }
// или
exports.test = 123
При require это обычно работает:
const pkg = require('cjs-pkg')
console.log(pkg) // { test: 123 }
Node.js в режиме ESM, TypeScript с esModuleInterop и бандлеры предоставляют совместимость для default-импорта такой библиотеки. Этот механизм часто называют «interop require default»:
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }
Из-за различий в определении формата и вида сборки interop иногда не срабатывает:
import pkg from 'cjs-pkg'
console.log(pkg) // { default: { test: 123 } }
При динамическом импорте (и в CJS, и в ESM) часто получается так:
import('cjs-pkg').then(console.log) // [Module: null prototype] { default: { test: '123' } }
В таком случае default-экспорт нужно обработать вручную:
// Статический импорт
import { default as pkg } from 'cjs-pkg'
// Динамический импорт
import('cjs-pkg').then(m => m.default || m).then(console.log)
Для более сложных случаев в Nuxt используется mlly, который корректно обрабатывает именованные экспорты:
import { interopDefault } from 'mlly'
// Форма: { default: { foo: 'bar' }, baz: 'qux' }
import myModule from 'my-module'
console.log(interopDefault(myModule)) // { foo: 'bar', baz: 'qux' }
Руководство для авторов библиотек
Исправить совместимость с ESM обычно несложно. Основные варианты:
- Переименовать ESM-файлы так, чтобы расширение было
.mjs.
Рекомендуемый и самый простой вариант. Возможно, придётся поправить зависимости и систему сборки, но в большинстве случаев этого достаточно. CJS-файлы лучше переименовать в.cjsдля ясности. - Сделать библиотеку только ESM.
То есть задать"type": "module"вpackage.jsonи собирать библиотеку в синтаксисе ESM. У части зависимостей могут быть ограничения, и такая библиотека будет работать только в ESM-окружении.
Миграция с CJS на ESM
Первый шаг — заменить require на import:
module.exports = function () { /* ... */ }
exports.hello = 'world'
export default function () { /* ... */ }
export const hello = 'world'
const myLib = require('my-lib')
import myLib from 'my-lib'
// или
const dynamicMyLib = await import('my-lib').then(lib => lib.default || lib)
В ESM глобальные require, require.resolve, __filename и __dirname недоступны; вместо них используйте import() и import.meta.filename.
const { join } = require('node:path')
const newDir = join(__dirname, 'new-dir')
import { fileURLToPath } from 'node:url'
const newDir = fileURLToPath(new URL('./new-dir', import.meta.url))
const someFile = require.resolve('./lib/foo.js')
import { resolvePath } from 'mlly'
const someFile = await resolvePath('my-lib', { url: import.meta.url })
Рекомендации
- Предпочитайте именованные экспорты default — так меньше конфликтов с CJS (см. раздел Default exports).
- По возможности не зависьте от встроенных модулей Node и CJS/Node-зависимостей, чтобы библиотеку можно было использовать в браузере и Edge Workers без полифиллов Nitro.
- Используйте поле
exportsс условными экспортами (подробнее):
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}