Нативные ES-модули (ECMAScript Modules, ESM) — стандартная модульная система JavaScript, встроенная непосредственно в язык и поддерживаемая современными браузерами. До появления ESM разработчики использовали различные подходы к разделению кода:
Появление ES-модулей стандартизировало работу с зависимостями и позволило браузеру понимать структуру приложения без дополнительных библиотек.
Главная идея ESM — разделение программы на независимые файлы-модули с явно объявленными экспортами и импортами.
Пример простого модуля:
// math.js
export function sum(a, b) {
return a + b;
}
Импорт:
// main.js
import { sum } from './math.js';
console.log(sum(2, 3));
Браузер самостоятельно загружает зависимости и выстраивает граф модулей.
Для работы ES-модулей используется атрибут
type="module".
<script type="module" src="./main.js"></script>
После указания type="module" браузер начинает
интерпретировать файл как ESM-модуль.
Это изменяет поведение Jav * aScript:
import/export;use strict);Обычные <script> помещают переменные в глобальный
объект window.
<script>
const message = 'hello';
</script>
Переменная становится глобальной.
Модули работают иначе:
// module.js
const message = 'hello';
message недоступна вне модуля.
Это устраняет множество проблем:
Наиболее распространённый вариант.
export const API_URL = '/api';
export function request() {}
export class User {}
Импорт:
import { API_URL, request, User } from './api.js';
const API_URL = '/api';
function request() {}
export { API_URL, request };
Такой стиль часто используется для улучшения читаемости.
const API_URL = '/api';
export { API_URL as BASE_URL };
Импорт:
import { BASE_URL } from './api.js';
Модуль может иметь один default export.
export default function initApp() {
console.log('init');
}
Импорт:
import initApp from './app.js';
Имя при импорте выбирается произвольно.
import start from './app.js';
export default class App {}
export const version = '1.0.0';
Импорт:
import App, { version } from './app.js';
Иногда требуется получить объект со всеми экспортами.
import * as math from './math.js';
console.log(math.sum(2, 5));
Все экспорты становятся свойствами объекта math.
import { request as apiRequest } from './api.js';
Полезно при конфликтах имен.
Модули могут переэкспортировать содержимое других файлов.
export { sum } from './math.js';
export { request } from './api.js';
Либо полностью:
export * from './utils.js';
Это позволяет создавать единые точки входа.
Пример:
// index.js
export * from './math.js';
export * from './date.js';
export * from './network.js';
Импорт:
import { sum, formatDate } from './index.js';
Одно из важнейших преимуществ ESM — статический анализ.
Импорты определяются до выполнения программы.
import { sum } from './math.js';
Браузер заранее знает:
Это даёт множество преимуществ:
ES-модули экспортируют не копию значения, а ссылку.
// counter.js
export let count = 0;
export function increment() {
count++;
}
// app.js
import { count, increment } from './counter.js';
console.log(count);
increment();
console.log(count);
После вызова increment() значение count
изменится и в импортирующем модуле.
Это называется live bindings.
Все ES-модули автоматически работают в strict mode.
x = 10;
Код вызовет ошибку:
ReferenceError
Повторное объявление переменных также запрещено.
Модульные скрипты ведут себя аналогично defer.
<script type="module" src="./main.js"></script>
Браузер:
Это улучшает производительность страницы.
ES-модули загружаются через механизм Fetch API и подчиняются политике CORS.
Файл нельзя просто открыть через:
file://
Во многих случаях модуль не загрузится.
Необходим HTTP-сервер:
npx vite
или:
python -m http.server
Браузер проверяет MIME-тип модулей.
Сервер должен возвращать:
Content-Type: application/javascript
Некорректный MIME-тип приводит к ошибкам загрузки.
Рассмотрим структуру:
main.js
├── api.js
└── ui.js
└── dom.js
Когда браузер загружает main.js:
Порядок выполнения:
dom.js
ui.js
api.js
main.js
Модуль выполняется только один раз.
import './config.js';
import './config.js';
Файл config.js не будет выполнен повторно.
Все импорты получают один и тот же экземпляр модуля.
ESM поддерживает циклические импорты.
// a.js
import { b } from './b.js';
export const a = 'A';
// b.js
import { a } from './a.js';
export const b = 'B';
Однако сложные циклы могут вызывать:
Поэтому циклические зависимости считаются архитектурным антипаттерном.
Помимо статического импорта существует динамический.
const module = await import('./math.js');
import() возвращает Promise.
import('./dialog.js')
.then(module => {
module.openDialog();
});
Динамический импорт используется для:
Пример:
button.addEventListener('click', async () => {
const chart = await import('./chart.js');
chart.render();
});
Модуль загрузится только после клика.
Современные ES-модули поддерживают await на верхнем
уровне.
const response = await fetch('/api/data');
const data = await response.json();
console.log(data);
Без обёртки в async-функцию.
Это особенно полезно:
Import Maps позволяют задавать алиасы модулей прямо в браузере.
<script type="importmap">
{
"imports": {
"lodash": "/libs/lodash.js"
}
}
</script>
Импорт:
import _ from 'lodash';
Без import maps браузер требует относительные или абсолютные пути.
Браузер требует точный путь.
Неверно:
import { sum } from './math';
Верно:
import { sum } from './math.js';
В отличие от Node.js или сборщиков браузер не пытается автоматически искать расширение.
Модуль идентифицируется URL-адресом.
Разные query-параметры считаются разными модулями.
import './config.js?v=1';
import './config.js?v=2';
Это приведёт к двойной загрузке.
nomoduleДля поддержки старых браузеров используется fallback.
<script type="module" src="main.js"></script>
<script nomodule src="legacy.js"></script>
Современные браузеры загрузят main.js, старые —
legacy.js.
Нативные модули обладают рядом преимуществ:
Браузер загружает зависимости одновременно.
Модули повторно не выполняются.
Неиспользуемый код может удаляться сборщиками.
Поддерживается через import().
Крупное приложение можно разбить на независимые части.
Несмотря на преимущества, у ESM есть ограничения.
При отсутствии сборщика приложение может загружать сотни файлов.
Браузер не понимает:
Нет:
Именно поэтому инструменты вроде Vite используют нативные ES-модули в режиме разработки, но создают production-сборку через bundling.
Архитектура Vite напрямую основана на возможностях нативного ESM.
Во время разработки Vite:
Пример:
import { createApp } from 'vue';
Vite преобразует импорт в URL внутри node_modules.
Браузер получает уже готовые ES-модули и загружает их напрямую.
Это обеспечивает:
Hot Module Replacement тесно связан с ESM.
При изменении файла:
Нет необходимости перезагружать всю страницу или пересобирать приложение целиком.
import { sum } from './math.js';
export function calc() {}
Особенности:
const math = require('./math');
module.exports = {};
Особенности:
Браузер умеет работать только с URL.
Корректно:
import './utils.js';
import '/src/app.js';
import 'https://cdn.site.com/lib.js';
Некорректно без import maps:
import 'lodash';
Именно поэтому Vite и другие инструменты преобразуют bare imports.
Современные CDN могут отдавать библиотеки как ESM.
Пример:
import { debounce } from 'https://esm.sh/lodash-es';
Популярные ESM-CDN:
Это позволяет запускать небольшие приложения вообще без сборки.
Модульная система улучшает безопасность кода:
Кроме того, браузер строго контролирует происхождение модулей через CORS.
Пример структуры:
src/
├── main.js
├── api/
│ ├── client.js
│ └── users.js
├── ui/
│ ├── modal.js
│ └── tabs.js
├── utils/
│ ├── date.js
│ └── math.js
└── store/
└── state.js
main.js:
import { createTabs } from './ui/tabs.js';
import { fetchUsers } from './api/users.js';
import { formatDate } from './utils/date.js';
Каждый модуль отвечает только за собственную область ответственности.
Нативные ES-модули существенно изменили экосистему JavaScript.
До ESM большинство приложений требовало:
После появления ESM стали возможны:
Именно на этой архитектуре построены современные инструменты: