Модули 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') для сборки клиента.
Однако в последних релизах 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 будет искать не main
, а exports
или module
запись в package.json
этой библиотеки.
Это также справедливо для динамического импорта, например 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) 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)
Вы также можете получить эту ошибку, если у вас есть именованный импорт из сборки с синтаксисом ESM, которую Node.js считает 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)
Устранение неполадок 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, предоставляют механизм совместимости, чтобы мы могли импортировать такую библиотеку по умолчанию. Этот механизм часто называют "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 = ...
exports.hello = ...
const myLib = require('my-lib')
В модулях ESM, в отличие от CJS, глобальные переменные require
, require.resolve
, __filename
и __dirname
недоступны и должны быть заменены на import()
и import.meta.filename
.
import { join } from 'path'
const newDir = join(__dirname, 'new-dir')
const someFile = require.resolve('./lib/foo.js')
Лучшие практики
- Предпочитать именованные экспорты вместо экспорта по умолчанию. Это помогает уменьшить конфликты CJS. (см. раздел Экспорты по умолчанию)
- Избегать зависимостей от встроенных модулей Node.js и зависимостей, характерных только для CommonJS или Node.js, насколько это возможно, чтобы вашу библиотеку можно было использовать в браузерах и Edge Workers без необходимости использования полифиллов Nitro.
- Использовать новое поле
exports
для условного экспорта. (Подробнее).
{
"exports": {
".": {
"import": "./dist/mymodule.mjs"
}
}
}