ESM: нативные ES-модули

ESM (ECMAScript Modules) — стандартная модульная система JavaScript, официально включённая в спецификацию ECMAScript. Она пришла на смену множеству альтернативных решений, таких как CommonJS, AMD и UMD, и стала единым механизмом организации кода как в браузерах, так и в серверной среде.

Модуль представляет собой отдельный файл с собственным пространством имён. Переменные, функции и классы, объявленные внутри модуля, не попадают в глобальную область видимости автоматически. Для обмена функциональностью между файлами используются конструкции export и import.

Простейший пример экспорта:

// math.js

export function sum(a, b) {
    return a + b;
}

Импорт в другом модуле:

// app.js

import { sum } from "./math.js";

console.log(sum(2, 3));

Основная идея ESM заключается в явном описании зависимостей между файлами. Благодаря этому инструменты сборки, включая Esbuild, способны эффективно анализировать структуру проекта и выполнять оптимизации.


Почему ESM стал стандартом

До появления ES-модулей каждая среда использовала собственную систему модулей.

CommonJS

Использовался в Node.js:

const math = require("./math");

console.log(math.sum(2, 3));

Экспорт:

module.exports = {
    sum
};

AMD

Применялся преимущественно в браузерах:

define(["math"], function(math) {
    console.log(math.sum(2, 3));
});

Проблемы старых систем

  • отсутствие единого стандарта;
  • сложности совместимости между платформами;
  • ограниченные возможности статического анализа;
  • менее эффективная оптимизация сборщиков.

ESM решил эти проблемы благодаря единому синтаксису и статической структуре импортов.


Поддержка ESM в Esbuild

Esbuild создавался уже в эпоху распространения ES-модулей, поэтому ESM является для него естественным форматом работы.

При обработке проекта Esbuild:

  1. Анализирует импортируемые модули.
  2. Строит граф зависимостей.
  3. Исключает неиспользуемый код.
  4. Объединяет модули в итоговый бандл.
  5. При необходимости преобразует формат модулей.

Пример запуска:

esbuild src/index.js --bundle --outfile=dist/app.js

Если входной файл содержит ESM-импорты:

import { sum } from "./math.js";

console.log(sum(1, 2));

Esbuild автоматически найдёт все зависимости и включит их в сборку.


Именованный экспорт

Наиболее распространённый вариант работы с ESM — именованный экспорт.

Файл:

// utils.js

export function formatDate(date) {
    return date.toISOString();
}

export function formatPrice(price) {
    return `$${price}`;
}

Импорт:

import { formatDate, formatPrice } from "./utils.js";

Преимущества:

  • высокая читаемость;
  • точный контроль экспортируемого API;
  • удобство tree shaking;
  • простота рефакторинга.

Экспорт через отдельное объявление

Функции можно экспортировать после объявления:

function sum(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

export { sum, subtract };

Подобный стиль часто используется в библиотеках, где экспортируемый интерфейс собирается в конце файла.


Переименование экспортов

ESM позволяет задавать другое имя экспортируемой сущности.

function formatDate(date) {
    return date.toISOString();
}

export {
    formatDate as format
};

Импорт:

import { format } from "./utils.js";

Такой механизм помогает формировать более удобный публичный API.


Экспорт по умолчанию

Модуль может иметь один экспорт по умолчанию.

export default function createLogger() {
    return {
        log(message) {
            console.log(message);
        }
    };
}

Импорт:

import createLogger from "./logger.js";

Название при импорте выбирается произвольно:

import loggerFactory from "./logger.js";

Это отличает default export от именованных экспортов.


Одновременное использование default и named exports

Оба подхода могут использоваться совместно.

export default class User {}

export const VERSION = "1.0.0";
export const AUTHOR = "Admin";

Импорт:

import User, { VERSION, AUTHOR } from "./user.js";

Импорт всех экспортов

Иногда требуется получить объект со всеми экспортами модуля.

import * as utils from "./utils.js";

console.log(utils.formatDate(new Date()));
console.log(utils.formatPrice(100));

После импорта:

utils.formatDate(...)
utils.formatPrice(...)

Такой подход удобен для больших наборов функций, но может снижать эффективность tree shaking.


Реэкспорт модулей

ESM позволяет перенаправлять экспорты.

Реэкспорт отдельных элементов

export { sum } from "./math.js";

Реэкспорт всего содержимого

export * from "./math.js";

Комбинированный вариант

export * from "./math.js";
export * from "./string.js";
export * from "./date.js";

Этот подход широко используется при создании публичных API библиотек.


Индексные файлы

Часто создаётся файл:

// index.js

С содержимым:

export * from "./math.js";
export * from "./string.js";
export * from "./date.js";

Использование:

import { sum, capitalize } from "./index.js";

В крупных проектах индексные файлы позволяют скрывать внутреннюю структуру каталогов.


Динамический импорт

ESM поддерживает ленивую загрузку модулей.

const module = await import("./analytics.js");

Возвращается Promise:

import("./analytics.js")
    .then(module => {
        module.track();
    });

Преимущества:

  • уменьшение стартового размера приложения;
  • загрузка кода по требованию;
  • разделение приложения на части.

Dynamic Import и Esbuild

Esbuild умеет анализировать динамические импорты и формировать отдельные чанки.

Пример:

async function openAdminPanel() {
    const admin = await import("./admin.js");

    admin.start();
}

Сборка:

esbuild src/index.js \
  --bundle \
  --splitting \
  --format=esm \
  --outdir=dist

Параметр:

--splitting

включает code splitting.

В результате создаются отдельные файлы:

dist/
├── index.js
├── admin-XYZ.js

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


Статический анализ модулей

Одно из ключевых преимуществ ESM — возможность статического анализа.

Корректный импорт:

import { sum } from "./math.js";

Некорректный динамический вариант:

const name = "math";

require("./" + name);

Во втором случае сборщику значительно сложнее понять структуру зависимостей.

Благодаря фиксированному синтаксису ESM Esbuild способен заранее определить:

  • какие файлы будут загружены;
  • какие экспорты используются;
  • какие части кода можно удалить.

Tree Shaking

Tree Shaking — удаление неиспользуемого кода.

Модуль:

export function sum(a, b) {
    return a + b;
}

export function multiply(a, b) {
    return a * b;
}

export function divide(a, b) {
    return a / b;
}

Использование:

import { sum } from "./math.js";

console.log(sum(1, 2));

Во время сборки Esbuild может исключить:

multiply
divide

из итогового бандла.

Это возможно именно благодаря статической природе ES-модулей.


Live Bindings

Особенность ESM — живые ссылки на экспортируемые значения.

Модуль:

export let counter = 0;

export function increment() {
    counter++;
}

Импорт:

import { counter, increment } from "./counter.js";

console.log(counter);

increment();

console.log(counter);

Вывод:

0
1

Импортируемая переменная автоматически отражает изменения экспортируемого значения.

В CommonJS такое поведение отличается.


Циклические зависимости

ESM способен работать с циклическими зависимостями.

Файл A:

import { b } from "./b.js";

export const a = "A";

Файл B:

import { a } from "./a.js";

export const b = "B";

Esbuild корректно анализирует подобные случаи, однако большое количество циклических зависимостей обычно считается архитектурным недостатком.

Рекомендуется минимизировать такие связи между модулями.


ESM в браузере

Современные браузеры поддерживают ES-модули напрямую.

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

Внутри файла:

import { sum } from "./math.js";

console.log(sum(2, 3));

Особенности:

  • автоматический строгий режим;
  • собственная область видимости;
  • поддержка import/export;
  • асинхронная загрузка модулей.

ESM в Node.js

Node.js поддерживает ESM несколькими способами.

Через package.json

{
  "type": "module"
}

После этого:

import fs from "fs";

становится допустимым синтаксисом.

Через расширение .mjs

app.mjs

Node автоматически интерпретирует файл как ES-модуль.


Форматы вывода Esbuild

Esbuild способен преобразовывать модули в разные форматы.

ESM

esbuild src/index.js \
  --bundle \
  --format=esm \
  --outfile=dist/app.js

Выходной код остаётся ES-модульным.

CommonJS

esbuild src/index.js \
  --bundle \
  --format=cjs \
  --outfile=dist/app.js

Используется для совместимости со старым окружением Node.js.

IIFE

esbuild src/index.js \
  --bundle \
  --format=iife \
  --global-name=App \
  --outfile=dist/app.js

Подходит для подключения через обычный тег <script>.


Разрешение путей модулей

ESM использует относительные пути.

import { sum } from "./math.js";

Импорт из родительской директории:

import { sum } from "../utils/math.js";

Импорт пакета:

import React from "react";

Esbuild повторяет стандартную логику поиска модулей и дополнительно умеет оптимизировать процесс разрешения зависимостей.


Пакетный экспорт библиотек

Современные библиотеки часто публикуют ESM-сборки.

Пример структуры:

package/
├── dist/
│   ├── index.js
│   ├── index.cjs
│   └── index.d.ts
└── package.json

Конфигурация:

{
  "main": "./dist/index.cjs",
  "module": "./dist/index.js"
}

Либо через поле exports:

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Esbuild корректно обрабатывает подобные настройки и выбирает подходящую версию пакета.


Практические рекомендации при работе с ESM и Esbuild

Предпочитать именованные экспорты

export function fetchUser() {}
export function saveUser() {}

Они лучше взаимодействуют с tree shaking.

Избегать смешивания множества default export

Большие проекты становятся сложнее для навигации, когда большинство файлов экспортирует значения по умолчанию.

Использовать динамический импорт для тяжёлых частей приложения

const chart = await import("./charts.js");

Это уменьшает объём первоначальной загрузки.

Формировать публичный API через index.js

export * from "./api.js";
export * from "./models.js";
export * from "./utils.js";

Сохранять модульную структуру

Небольшие специализированные модули обычно легче оптимизируются Esbuild и обеспечивают более эффективное удаление неиспользуемого кода.


Взаимосвязь ESM и производительности сборки

Esbuild достигает высокой скорости благодаря сочетанию нескольких факторов:

  • статического анализа импортов;
  • построения графа зависимостей;
  • эффективного tree shaking;
  • параллельной обработки файлов;
  • быстрого разрешения модулей;
  • поддержки code splitting.

Чем последовательнее проект использует нативные ES-модули, тем больше возможностей получает Esbuild для оптимизации итогового результата. ESM становится не только стандартом организации кода, но и фундаментом для современных механизмов сборки, минимизации и доставки JavaScript-приложений.