Модули ES
Это руководство поможет вам понять, что такое модули ES (ESM) и как сделать приложение Nuxt (или библиотеку-зависимость) совместимым с ESM.
Предпосылки
Модули CommonJS
CommonJS (CJS) — это формат, представленный Node.js, который позволяет совместно использовать функциональность между изолированными модулями JavaScript (подробнее). Возможно, вы уже знакомы с этим синтаксисом:
const a = require('./a')
module.exports.a = a
Такие сборщики, как webpack и Rollup, поддерживают этот синтаксис и позволяют использовать в браузере модули, написанные на CommonJS.
Синтаксис ESM
Чаще всего, когда люди говорят о ESM и CJS, они говорят о разном синтаксисе написания модулей.
import a from './a'
export { a }
До того, как модули ECMAScript (ESM) стали стандартом (на это ушло более 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.Однако в последних релизах Node.js LTS теперь можно использовать нативный модуль ESM в Node.js. Это означает, что сам Node.js может обрабатывать JavaScript с использованием синтаксиса ESM, хотя по умолчанию он этого не делает. Два наиболее распространенных способа включить синтаксис ESM:
- установите
"type": "module"вpackage.jsonи продолжайте использовать расширение.js - используйте расширения файлов
.mjs(рекомендуется)
Это то, что мы делаем для Nitro в Nuxt; мы получаем на выходе файл .output/server/index.mjs. Это говорит Node.js, что этот файл нужно рассматривать как нативный модуль ES.
Что такое допустимые импорты в контексте Node.js?
Когда вы делаете import модуля, а не require, Node.js разрешает его по-другому. Например, при импорте sample-library Node.js сначала ищет запись 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. До сих пор это не было проблемой, поскольку они использовались только сборщиками, такими как webpack, которые не особо заботятся о расширении файла.
Однако если вы попытаетесь импортировать пакет с файлом .esm.js в ESM-контексте Node.js, это не сработает, и вы получите ошибку следующего вида:
(node:22145) Предупреждение: чтобы загрузить ES-модуль, задайте "type": "module" в package.json или используйте расширение .mjs.
/path/to/index.js:1
export default {}
^^^^^^
SyntaxError: неожиданный токен «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)
Вы также можете получить эту ошибку, если у вас есть именованный импорт из сборки с синтаксисом ESM, которую Node.js считает CJS:
file:///path/to/index.mjs:5
import { named } from 'sample-library'
^^^^^
SyntaxError: именованный экспорт «named» не найден. Запрошенный модуль «sample-library» — CommonJS-модуль; не все поля module.exports доступны как именованные экспорты.
CommonJS-модули всегда можно импортировать через экспорт по умолчанию, например:
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)
Устранение неполадок ESM
Если вы столкнулись с этими ошибками, проблема почти наверняка в восходящей библиотеке. Ей нужно исправить пакет для поддержки импорта в Node.
Транспиляция библиотек
В то же время вы можете указать Nuxt не пытаться импортировать эти библиотеки, добавив их в build.transpile:
export default defineNuxtConfig({
build: {
transpile: ['sample-library'],
},
})
Вы можете обнаружить, что вам также необходимо добавить другие пакеты, импортируемые этими библиотеками.
Задание алиасов библиотекам
В некоторых случаях вам также может потребоваться вручную назначить алиас библиотеке для версии CJS, например:
export default defineNuxtConfig({
alias: {
'sample-library': 'sample-library/dist/sample-library.cjs.js',
},
})
Экспорты по умолчанию
Зависимость с форматом CommonJS может использовать 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 и упаковщики, такие как webpack, предоставляют механизм совместимости, чтобы мы могли импортировать такую библиотеку по умолчанию. Этот механизм часто называют интеропом с экспортом по умолчанию из CommonJS («interop require default»):
import pkg from 'cjs-pkg'
console.log(pkg) // { test: 123 }
Однако из-за сложностей определения синтаксиса и различных форматов пакетов всегда существует вероятность того, что взаимодействие по умолчанию не сработает, и мы получим что-то вроде этого:
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' } }
В этом случае нам необходимо вручную настроить экспорт по умолчанию:
// Статический импорт
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, в отличие от CJS, глобальные 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 })
Лучшие практики
- Предпочитать именованные экспорты вместо экспорта по умолчанию. Это помогает уменьшить конфликты CJS. (см. раздел Экспорты по умолчанию)
- Избегать зависимостей от встроенных модулей Node.js и зависимостей, характерных только для CommonJS или Node.js, насколько это возможно, чтобы вашу библиотеку можно было использовать в браузерах и на пограничных воркерах (Edge Workers) без полифиллов Nitro.
- Использовать новое поле
exportsдля условного экспорта. (Подробнее).
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}