ES-модули

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

  1. файлы с расширением .mjs — ожидается синтаксис ESM;
  2. файлы с расширением .cjs — ожидается синтаксис CJS;
  3. файлы с расширением .js — ожидается CJS, если в package.json нет "type": "module".

Какие бывают проблемы?

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

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

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

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 и бандлеры предоставляют совместимость для 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 обычно несложно. Основные варианты:

  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 глобальные 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')

Рекомендации

  • Предпочитайте именованные экспорты default — так меньше конфликтов с CJS (см. раздел Default exports).
  • По возможности не зависьте от встроенных модулей Node и CJS/Node-зависимостей, чтобы библиотеку можно было использовать в браузере и Edge Workers без полифиллов Nitro.
  • Используйте поле exports с условными экспортами (подробнее):
{
  "exports": {
    ".": {
      "import": "./dist/mymodule.mjs"
    }
  }
}
Был ли материал полезен?