Модули ES

Nuxt использует нативные модули 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 поддерживает следующие виды импорта (см. документацию):

  1. файлы, заканчивающиеся на .mjs — ожидается синтаксис ESM
  2. файлы, заканчивающиеся на .cjs — ожидается синтаксис CJS
  3. файлы, заканчивающиеся на .js — ожидается синтаксис CJS, если только в package.json нет "type": "module"

Какие могут быть проблемы?

Долгое время авторы модулей создавали сборки с ESM-синтаксисом, но использовали соглашения вроде .esm.js или .es.js, которые они добавляли в поле module в своем package.json. До сих пор это не было проблемой, поскольку они использовались только сборщиками, такими как webpack, которые не особо заботятся о расширении файла.

Однако если вы попытаетесь импортировать пакет с файлом .esm.js в ESM-контексте Node.js, это не сработает, и вы получите ошибку следующего вида:

Terminal
(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:

Terminal
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 для предоставления экспорта по умолчанию:

node_modules/cjs-pkg/index.js
module.exports = { test: 123 }
// или
exports.test = 123

Обычно это работает хорошо, если мы делаем require такой ​​зависимости:

test.cjs
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 относительно просто. Есть два основных варианта:

  1. Переименуйте файлы ESM так, чтобы они заканчивались на .mjs.
    Это рекомендуемый и самый простой подход. Возможно, вам придётся разобраться с зависимостями вашей библиотеки и с системой сборки, но в большинстве случаев этого достаточно. Для ясности файлы CJS лучше называть с расширением .cjs.
  2. Сделайте библиотеку доступной только в формате ESM.
    Это подразумевает "type": "module" в package.json и сборку библиотеки в синтаксисе ESM. Возможны сложности с зависимостями, а сама библиотека будет применима только в ESM-окружении.

Миграция

Первым шагом при переходе от CJS к ESM является замена любого использования require на import:

module.exports = function () { /* ... */ }

exports.hello = 'world'
const myLib = require('my-lib')

В модулях ESM, в отличие от CJS, глобальные require, require.resolve, __filename и __dirname недоступны и должны быть заменены на import() и import.meta.filename.

const { join } = require('node: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"
    }
  }
}