Модуль — это файл со своей областью видимости
До появления ES-модулей каждый тег <script> вываливал свои переменные в глобальное пространство имён, а порядок подключения решал, кто что увидит. ES-модули в JavaScript решают эту проблему: каждый файл получает собственную область видимости. Наружу ничего не утечёт, пока вы явно не напишете export. Внутрь ничего не попадёт, пока вы явно не сделаете import.
Два файла — один экспортирует, другой импортирует:
add и multiply живут в math.js. Снаружи, в main.js, они становятся доступны только благодаря import. Всё остальное, что есть в math.js — вспомогательные функции, константы и прочая внутренняя кухня — извне недоступно.
Отсюда вытекают два правила, которые лучше усвоить сразу:
- Модули автоматически работают в строгом режиме. Писать
'use strict'не нужно. - На верхнем уровне
thisравенundefined, а не глобальному объекту.
Именованный экспорт в JavaScript: экспортируем по ходу дела
Самый ходовой вариант. Просто ставим export перед любой function, class, const или let — и всё, это уже часть публичного API модуля:
Имена в фигурных скобках должны точно совпадать с экспортируемыми — import { circlearea } уже не сработает. Если имя конфликтует с чем-то, что у вас уже есть в коде, переименуйте его прямо при импорте через as:
Экспорты можно собрать и в самом конце файла, а не ставить их прямо у объявлений — некоторым так удобнее: получается наглядная секция «публичного API» модуля.
Оба варианта дают одинаковый результат. Выберите один стиль и придерживайтесь его в рамках проекта.
Export default в JavaScript: один на модуль
У модуля также может быть один экспорт по умолчанию — export default. Его удобно использовать, когда в файле действительно есть одна главная сущность: компонент, класс или объект с конфигом:
Здесь стоит обратить внимание на три момента:
- Вокруг
logна стороне импорта нет фигурных скобок. - Имя при импорте — произвольное.
import shout from './logger.js'сработает точно так же. export defaultможет быть только один на файл. Попробуйте добавить второй — файл просто не распарсится.
Именованный и дефолтный экспорт прекрасно уживаются в одном файле:
Сначала идёт default, затем именованные в фигурных скобках. Порядок фиксированный.
Что же выбрать? Именованный экспорт в JavaScript проще рефакторить: переименование по всему проекту — это одна операция «найти и заменить», потому что во всех импортах используется одно и то же имя. export default гибче, но каждый, кто импортирует, может дать модулю своё имя — и grep'ом такое уже не поймаешь. Большинство современных гайдов по стилю склоняются к именованным экспортам, а default оставляют для модулей, у которых действительно одна-единственная задача.
Импорт всего, реэкспорт и побочные эффекты
Есть ещё несколько форм import, которые вам точно встретятся.
Чтобы собрать все именованные экспорты в один объект-пространство имён:
Реэкспорт из другого модуля без импорта в текущую область видимости:
Примерно так устроены «точки входа» у библиотек: из внутренних файлов собирается единая публичная поверхность.
И напоследок — import вообще без привязок, для модулей, которые нужны только ради побочных эффектов (полифилы, CSS-in-JS, регистрация обработчиков):
Файл выполняется один раз; ничего не импортируется по имени.
Импорты статичны и «живые»
У import есть пара особенностей, которые порой застают врасплох.
Статичность. Объявления import разрешаются до того, как запустится хоть одна строчка вашего кода. Нельзя засунуть import внутрь if, функции или try. Путь обязан быть строковым литералом, а не переменной. Именно благодаря этому инструменты умеют анализировать импорты, не выполняя код, — на это опираются и бандлеры, и проверка типов, и tree-shaking.
// Не разрешено — SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
Если нужна условная загрузка — используйте import() (о нём поговорим чуть ниже).
Живая связь. Импортированное значение — это не копия, а доступная только для чтения ссылка на экспорт. Если модуль-источник переопределит значение, все, кто его импортировал, увидят новое:
Переназначить импорт на стороне потребителя тоже не получится — строка count = 5 в main.js выбросит ошибку. Импорты — это read-only представления.
Динамический import() для загрузки по требованию
Когда решение о загрузке модуля нужно принимать во время выполнения — тяжёлые фичи, разделение кода по роутам, условные полифилы — используйте import() как функцию. Она возвращает промис, который резолвится в экспорты модуля:
Так как это обычный вызов функции, с ним можно:
- писать
awaitвнутриasync-функции; - передавать путь через переменную;
- использовать его в
ifилиtry/catch.
Деструктуризация полученного объекта работает точно так же, как и при статическом импорте:
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 более лояльны и сами достроят путь без расширения, но если полагаться на это, код перестанет быть переносимым.