. В Node.js нужно либо использовать расширение .mjs, либо прописать \"type\": \"module\" в package.json."}},{"@type":"Question","name":"Чем отличается default export от именованного?","acceptedAnswer":{"@type":"Answer","text":"Именованных экспортов в модуле может быть сколько угодно (export function foo() {}), а export default — только один. Именованные нужно импортировать ровно под тем же именем и в фигурных скобках: import { foo } from './x.js'. А default можно импортировать под любым именем: import whatever from './x.js'."}},{"@type":"Question","name":"Что такое динамический import() в JavaScript?","acceptedAnswer":{"@type":"Answer","text":"import(), вызванный как функция, возвращает промис, который резолвится экспортами модуля. В отличие от статического import, он срабатывает в момент вызова — значит, код можно подгружать по условию или по требованию. Именно так реализуют code-splitting и ленивую загрузку."}},{"@type":"Question","name":"Нужно ли указывать расширение файла в import?","acceptedAnswer":{"@type":"Answer","text":"В нативных ES-модулях — в браузере и в ESM-загрузчике Node — да, обязательно. Писать надо ./utils.js, а не ./utils. Сборщики вроде Vite и webpack более лояльны и сами достроят путь без расширения, но если полагаться на это, код перестанет быть переносимым."}}]}
Menu
Русский

ES-модули в JavaScript: import, export и dynamic import()

Разбираемся, как работают ES-модули в JavaScript: именованные и default-экспорты, синтаксис import, динамический import() и чем модули отличаются от обычных скриптов.

Модуль — это файл со своей областью видимости

До появления ES-модулей каждый тег <script> вываливал свои переменные в глобальное пространство имён, а порядок подключения решал, кто что увидит. ES-модули в JavaScript решают эту проблему: каждый файл получает собственную область видимости. Наружу ничего не утечёт, пока вы явно не напишете export. Внутрь ничего не попадёт, пока вы явно не сделаете import.

Два файла — один экспортирует, другой импортирует:

index.js
Output
Click Run to see the output here.

add и multiply живут в math.js. Снаружи, в main.js, они становятся доступны только благодаря import. Всё остальное, что есть в math.js — вспомогательные функции, константы и прочая внутренняя кухня — извне недоступно.

Отсюда вытекают два правила, которые лучше усвоить сразу:

  • Модули автоматически работают в строгом режиме. Писать 'use strict' не нужно.
  • На верхнем уровне this равен undefined, а не глобальному объекту.

Именованный экспорт в JavaScript: экспортируем по ходу дела

Самый ходовой вариант. Просто ставим export перед любой function, class, const или let — и всё, это уже часть публичного API модуля:

index.js
Output
Click Run to see the output here.

Имена в фигурных скобках должны точно совпадать с экспортируемыми — import { circlearea } уже не сработает. Если имя конфликтует с чем-то, что у вас уже есть в коде, переименуйте его прямо при импорте через as:

index.js
Output
Click Run to see the output here.

Экспорты можно собрать и в самом конце файла, а не ставить их прямо у объявлений — некоторым так удобнее: получается наглядная секция «публичного API» модуля.

index.js
Output
Click Run to see the output here.

Оба варианта дают одинаковый результат. Выберите один стиль и придерживайтесь его в рамках проекта.

Export default в JavaScript: один на модуль

У модуля также может быть один экспорт по умолчанию — export default. Его удобно использовать, когда в файле действительно есть одна главная сущность: компонент, класс или объект с конфигом:

index.js
Output
Click Run to see the output here.

Здесь стоит обратить внимание на три момента:

  • Вокруг log на стороне импорта нет фигурных скобок.
  • Имя при импорте — произвольное. import shout from './logger.js' сработает точно так же.
  • export default может быть только один на файл. Попробуйте добавить второй — файл просто не распарсится.

Именованный и дефолтный экспорт прекрасно уживаются в одном файле:

index.js
Output
Click Run to see the output here.

Сначала идёт default, затем именованные в фигурных скобках. Порядок фиксированный.

Что же выбрать? Именованный экспорт в JavaScript проще рефакторить: переименование по всему проекту — это одна операция «найти и заменить», потому что во всех импортах используется одно и то же имя. export default гибче, но каждый, кто импортирует, может дать модулю своё имя — и grep'ом такое уже не поймаешь. Большинство современных гайдов по стилю склоняются к именованным экспортам, а default оставляют для модулей, у которых действительно одна-единственная задача.

Импорт всего, реэкспорт и побочные эффекты

Есть ещё несколько форм import, которые вам точно встретятся.

Чтобы собрать все именованные экспорты в один объект-пространство имён:

index.js
Output
Click Run to see the output here.

Реэкспорт из другого модуля без импорта в текущую область видимости:

index.js
Output
Click Run to see the output here.

Примерно так устроены «точки входа» у библиотек: из внутренних файлов собирается единая публичная поверхность.

И напоследок — import вообще без привязок, для модулей, которые нужны только ради побочных эффектов (полифилы, CSS-in-JS, регистрация обработчиков):

index.js
Output
Click Run to see the output here.

Файл выполняется один раз; ничего не импортируется по имени.

Импорты статичны и «живые»

У import есть пара особенностей, которые порой застают врасплох.

Статичность. Объявления import разрешаются до того, как запустится хоть одна строчка вашего кода. Нельзя засунуть import внутрь if, функции или try. Путь обязан быть строковым литералом, а не переменной. Именно благодаря этому инструменты умеют анализировать импорты, не выполняя код, — на это опираются и бандлеры, и проверка типов, и tree-shaking.

// Не разрешено — SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

Если нужна условная загрузка — используйте import() (о нём поговорим чуть ниже).

Живая связь. Импортированное значение — это не копия, а доступная только для чтения ссылка на экспорт. Если модуль-источник переопределит значение, все, кто его импортировал, увидят новое:

index.js
Output
Click Run to see the output here.

Переназначить импорт на стороне потребителя тоже не получится — строка count = 5 в main.js выбросит ошибку. Импорты — это read-only представления.

Динамический import() для загрузки по требованию

Когда решение о загрузке модуля нужно принимать во время выполнения — тяжёлые фичи, разделение кода по роутам, условные полифилы — используйте import() как функцию. Она возвращает промис, который резолвится в экспорты модуля:

index.js
Output
Click Run to see the output here.

Так как это обычный вызов функции, с ним можно:

  • писать await внутри async-функции;
  • передавать путь через переменную;
  • использовать его в if или try/catch.

Деструктуризация полученного объекта работает точно так же, как и при статическом импорте:

index.js
Output
Click Run to see the output here.

default — это ключ для default-экспорта при деструктуризации. Можешь переименовать его как угодно.

На практике такой подход удобен для code splitting (подгружаем библиотеку графиков только когда пользователь нажал «показать график»), подгрузки полифилов по feature detection и плагинов, которые определяются в рантайме.

Запуск ES-модулей: браузер и Node.js

Синтаксис везде одинаковый — разница только в том, как рантайм ищет и загружает файл.

В браузере достаточно пометить входной скрипт как модуль:

<script type="module" src="./main.js"></script>

С атрибутом type="module" браузер понимает import/export, запускает код в strict mode и откладывает выполнение до тех пор, пока HTML не будет полностью распарсен. Пути должны быть относительными (./, ../) либо абсолютными URL — «голые» спецификаторы вида import 'lodash' без import map или сборщика не сработают.

В Node.js включить ES-модули можно двумя способами:

  • дать файлу расширение .mjs, либо
  • прописать "type": "module" в ближайшем package.json — тогда каждый .js-файл станет модулем.

Кроме того, Node требует указывать полный путь с расширением: import './utils.js', а не import './utils'.

// package.json
{
  "type": "module",
  "main": "./index.js"
}

Оба окружения требуют явно указывать расширения файлов в нативных ESM. Сборщики (Vite, webpack, esbuild) при разработке сами достроят путь без расширения — удобно, но если полагаться на это, исходники перестанут работать без шага сборки.

Частые грабли

Вот на чём обычно спотыкаются:

  • Забыли type="module" в браузере. Без этого <script> выполняется как классический скрипт, и import превращается в синтаксическую ошибку.
  • Нет расширения файла в Node. import './utils' упадёт, а import './utils.js' — сработает. Сборщики это прячут, а нативные рантаймы — нет.
  • Ждёте __dirname или require внутри ES-модуля. Это всё из CommonJS. В ESM используйте import.meta.url и преобразуйте его, когда нужен путь к файлу.
  • Циклические импорты, которые обращаются к значениям до их готовности. Два модуля, импортирующих друг друга, — это легально, но если прочитать экспорт, которому ещё не присвоено значение, получите undefined. Перестройте код так, чтобы цикл не задевался на этапе инициализации, или разбейте его.
  • Попытки подключить import по условию. Статический import так не умеет. Для всего, что зависит от рантайма, берите динамический import().

Дальше: CommonJS vs ESM

ES-модули — это стандарт, но в живом Node-коде до сих пор полно CommonJS: require, module.exports и совсем другая логика того, когда выполняется код. Разобраться с обоими форматами и с тем, как они дружат между собой, — тема следующей страницы.

Часто задаваемые вопросы

Как подключить ES-модули в JavaScript?

Из одного файла отдаём значения через export или export default, в другом забираем их через import. В браузере точка входа подключается так: <script type="module" src="main.js"></script>. В Node.js нужно либо использовать расширение .mjs, либо прописать "type": "module" в package.json.

Чем отличается default export от именованного?

Именованных экспортов в модуле может быть сколько угодно (export function foo() {}), а export default — только один. Именованные нужно импортировать ровно под тем же именем и в фигурных скобках: import { foo } from './x.js'. А default можно импортировать под любым именем: import whatever from './x.js'.

Что такое динамический import() в JavaScript?

import(), вызванный как функция, возвращает промис, который резолвится экспортами модуля. В отличие от статического import, он срабатывает в момент вызова — значит, код можно подгружать по условию или по требованию. Именно так реализуют code-splitting и ленивую загрузку.

Нужно ли указывать расширение файла в import?

В нативных ES-модулях — в браузере и в ESM-загрузчике Node — да, обязательно. Писать надо ./utils.js, а не ./utils. Сборщики вроде Vite и webpack более лояльны и сами достроят путь без расширения, но если полагаться на это, код перестанет быть переносимым.

Учитесь программировать с Coddy

НАЧАТЬ