Сохранение ESM-структуры для Tree Shaking потребителями

При публикации Javascript-библиотеки через Webpack часто возникает ситуация, когда итоговый пакет теряет преимущества Tree Shaking у конечного потребителя. Это особенно критично для UI-библиотек, утилитарных пакетов, SDK и модульных фреймворков, где пользователь должен иметь возможность импортировать только используемые части.

Главная причина проблемы — преобразование исходных ESM-модулей в единый bundle или в CommonJS-структуру. После этого инструменты потребителя уже не способны безопасно удалить неиспользуемый код.

Неправильная сборка библиотеки приводит к следующим последствиям:

  • увеличению размера клиентского bundle;
  • невозможности удаления неиспользуемых экспортов;
  • попаданию побочных модулей в production-сборку;
  • ухудшению производительности загрузки;
  • снижению эффективности splitChunks;
  • невозможности глубокой оптимизации зависимостей.

Почему Tree Shaking зависит от ESM

Tree Shaking основан на статическом анализе импортов и экспортов. Такой анализ возможен только при использовании ECMAScript Modules.

Webpack способен определить:

import { sum } from './math';

что используется только sum, а остальные экспорты можно удалить.

В CommonJS это невозможно гарантировать:

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

Поскольку объект может изменяться динамически.


Как Webpack разрушает ESM-структуру

Классическая библиотечная сборка:

module.exports = {
  mode: 'production',

  entry: './src/index.js',

  output: {
    filename: 'library.js',
    path: path.resolve(__dirname, 'dist'),
    library: 'MyLibrary',
    libraryTarget: 'umd',
  },
};

создаёт единый bundle.

После bundling:

  • все модули оказываются объединены;
  • реальные ESM-import/export исчезают;
  • Webpack потребителя больше не видит структуру модулей.

В результате Tree Shaking перестаёт работать полноценно.


Сохранение ESM как основная стратегия

Современные библиотеки всё чаще публикуют:

  • исходные ESM-модули;
  • минимально преобразованные ESM-файлы;
  • структуру файлов без объединения.

Главная идея:

  • не собирать библиотеку в один bundle;
  • сохранить import/export в итоговом output.

output.module

Webpack 5 поддерживает генерацию ESM-вывода.

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

module.exports = {
  experiments: {
    outputModule: true,
  },

  output: {
    module: true,
  },
};

Это позволяет Webpack генерировать ESM-бандл вместо CommonJS/UMD.


Ограничения output.module

Даже при использовании:

output: {
  module: true,
}

Webpack всё ещё создаёт bundle.

Это означает:

  • структура файлов исчезает;
  • модули инкапсулируются;
  • внутренний runtime Webpack остаётся;
  • Tree Shaking у потребителя ограничивается.

Поэтому output.module полезен, но недостаточен для идеальной publish-стратегии библиотеки.


Настоящее сохранение модульной структуры

Для полноценного Tree Shaking необходимо:

  • сохранять отдельные файлы;
  • сохранять import/export;
  • избегать объединения модулей.

На практике это означает подход:

src/
  button.js
  modal.js
  dropdown.js

dist/
  button.js
  modal.js
  dropdown.js

без bundle-агрегации.


experiments.outputModule

Полный пример:

const path = require('path');

module.exports = {
  mode: 'production',

  entry: './src/index.js',

  experiments: {
    outputModule: true,
  },

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.js',
    module: true,
  },
};

Результат:

export { Button } from './button.js';
export { Modal } from './modal.js';

а не:

__webpack_require__(...)

library.type = ‘module’

Современный вариант library-конфигурации:

output: {
  library: {
    type: 'module',
  },
},

В старом синтаксисе:

output: {
  libraryTarget: 'module',
}

не рекомендуется.


preserveModules-подход

Webpack изначально ориентирован на bundling, поэтому полноценного аналога Rollup preserveModules у него нет.

Однако можно приблизиться к этому поведению через:

  • множественные entry;
  • отключение chunk aggregation;
  • минимизацию runtime;
  • отсутствие optimization.concatenateModules.

Множественные entry points

Пример:

module.exports = {
  entry: {
    button: './src/button.js',
    modal: './src/modal.js',
    dropdown: './src/dropdown.js',
  },

  experiments: {
    outputModule: true,
  },

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
    module: true,
  },
};

Результат:

dist/
  button.js
  modal.js
  dropdown.js

sideEffects и Tree Shaking

Webpack потребителя использует поле:

{
  "sideEffects": false
}

в package.json.

Это один из важнейших механизмов Tree Shaking.


Что означает sideEffects: false

Поле сообщает bundler-у:

  • модули безопасно удалять;
  • import не вызывает побочных эффектов;
  • unused exports можно исключать.

Пример:

{
  "sideEffects": false
}

Теперь:

import { Button } from 'my-ui-library';

не приведёт к включению всей библиотеки.


Когда sideEffects: false опасен

Некоторые модули имеют side effects:

import './styles.css';

или:

window.myGlobal = {};

или:

customElements.define(...);

Если указать:

{
  "sideEffects": false
}

Webpack может удалить такие модули.


Исключения для sideEffects

Корректный вариант:

{
  "sideEffects": [
    "*.css",
    "./polyfills.js"
  ]
}

Теперь:

  • CSS не удаляется;
  • полифилы сохраняются;
  • остальные модули tree-shakable.

barrel-файлы и проблема реэкспортов

Файл:

export * from './button';
export * from './modal';
export * from './dropdown';

может ухудшать Tree Shaking в некоторых bundler-ах.

Особенно при:

  • CommonJS-трансформациях;
  • Babel transpilation;
  • смешанных export-конструкциях.

Безопасные реэкспорты

Предпочтительнее:

export { Button } from './button';
export { Modal } from './modal';
export { Dropdown } from './dropdown';

В таком случае Webpack проще анализирует граф зависимостей.


Babel и разрушение ESM

Классическая проблема:

{
  "presets": ["@babel/preset-env"]
}

Babel может преобразовать ESM в CommonJS.

Тогда даже идеальная Webpack-конфигурация перестаёт помогать.


modules: false

Обязательная настройка Babel:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false
      }
    ]
  ]
}

Теперь Babel сохраняет:

import/export

вместо:

require/module.exports

Проверка итоговой сборки

Правильный ESM-output содержит:

export
import

Неправильный:

__webpack_require__
exports.default
module.exports

type: module в package.json

Для ESM-публикации используется:

{
  "type": "module"
}

Теперь .js трактуются как ESM.


exports map

Современная публикация библиотеки:

{
  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button.js",
    "./modal": "./dist/modal.js"
  }
}

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

  • явная структура API;
  • оптимизация резолвинга;
  • поддержка deep imports;
  • лучшая совместимость с Node.js и bundler-ами.

Deep Imports

Потребитель получает возможность:

import Button from 'my-library/button';

вместо:

import { Button } from 'my-library';

Это дополнительно уменьшает размер bundle.


dual package strategy

Часто библиотеки публикуют одновременно:

  • ESM;
  • CommonJS.

Пример:

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

или:

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

module field

Поле:

{
  "module": "./dist/esm/index.js"
}

исторически используется bundler-ами для выбора ESM-версии.

Хотя современный стандарт — exports, многие инструменты всё ещё ориентируются на module.


Почему CommonJS ломает Tree Shaking

Даже простой код:

exports.sum = sum;
exports.multiply = multiply;

не гарантирует статичность.

Webpack не может безопасно удалить:

multiply

поскольку:

exports[name] = dynamicValue;

разрешено спецификацией CommonJS.


Module Concatenation

Webpack использует scope hoisting:

optimization: {
  concatenateModules: true,
}

Это улучшает производительность.

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


Когда concatenateModules лучше отключить

При публикации ESM-библиотеки:

optimization: {
  concatenateModules: false,
}

может быть полезнее.

Особенно если цель:

  • сохранить модульность;
  • облегчить Tree Shaking;
  • улучшить debugging.

runtimeChunk

Для библиотек runtime Webpack часто вреден.

Лучше избегать:

optimization: {
  runtimeChunk: 'single',
}

поскольку runtime увеличивает связность output.


externals

Tree Shaking ухудшается, если зависимости встраиваются внутрь библиотеки.

Правильный подход:

externals: {
  react: 'react',
  lodash: 'lodash',
}

или:

externalsPresets: {
  node: true,
}

Почему externals важен

Если встроить React внутрь bundle:

  • дублируются зависимости;
  • ломается deduplication;
  • ухудшается shaking;
  • растёт размер итогового приложения.

CSS как источник side effects

Импорт:

import './button.css';

всегда считается side effect.

Поэтому CSS-модули:

  • нельзя бездумно удалять;
  • нужно явно описывать в sideEffects.

Публикация несобранного кода

Многие современные библиотеки публикуют почти исходный код:

dist/
  components/
  hooks/
  utils/

с минимальной транспиляцией.

Причины:

  • современные bundler-ы сами умеют оптимизировать;
  • Tree Shaking становится эффективнее;
  • уменьшается количество промежуточных трансформаций.

Сравнение подходов

Полный bundle

library.js

Плюсы:

  • простое подключение;
  • совместимость со старыми системами.

Минусы:

  • плохой Tree Shaking;
  • большой размер;
  • слабая модульность.

ESM bundle

library.mjs

Плюсы:

  • современный формат;
  • частичная поддержка shaking.

Минусы:

  • модули всё ещё объединены.

Preserve modules

dist/
  button.js
  modal.js

Плюсы:

  • максимальный Tree Shaking;
  • глубокая оптимизация;
  • минимальный размер потребительского bundle.

Минусы:

  • сложнее publish pipeline;
  • больше файлов.

Рекомендуемая конфигурация ESM-библиотеки

const path = require('path');

module.exports = {
  mode: 'production',

  entry: {
    index: './src/index.js',
    button: './src/button.js',
    modal: './src/modal.js',
  },

  experiments: {
    outputModule: true,
  },

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',

    module: true,

    library: {
      type: 'module',
    },
  },

  optimization: {
    concatenateModules: false,
  },

  externals: {
    react: 'react',
  },
};

Рекомендуемый package.json

{
  "type": "module",

  "sideEffects": [
    "*.css"
  ],

  "main": "./dist/index.js",

  "module": "./dist/index.js",

  "exports": {
    ".": "./dist/index.js",
    "./button": "./dist/button.js",
    "./modal": "./dist/modal.js"
  }
}

Основные признаки tree-shakable библиотеки

  • сохранены ESM import/export;
  • отсутствует CommonJS;
  • sideEffects настроен корректно;
  • зависимости вынесены в externals;
  • структура модулей не уничтожена bundling-ом;
  • exports map поддерживает deep imports;
  • Babel не преобразует ESM;
  • CSS помечен как side effect;
  • библиотека не превращена в единый UMD-файл.