При публикации Javascript-библиотеки через Webpack часто возникает ситуация, когда итоговый пакет теряет преимущества Tree Shaking у конечного потребителя. Это особенно критично для UI-библиотек, утилитарных пакетов, SDK и модульных фреймворков, где пользователь должен иметь возможность импортировать только используемые части.
Главная причина проблемы — преобразование исходных ESM-модулей в единый bundle или в CommonJS-структуру. После этого инструменты потребителя уже не способны безопасно удалить неиспользуемый код.
Неправильная сборка библиотеки приводит к следующим последствиям:
Tree Shaking основан на статическом анализе импортов и экспортов. Такой анализ возможен только при использовании ECMAScript Modules.
Webpack способен определить:
import { sum } from './math';
что используется только sum, а остальные экспорты можно
удалить.
В CommonJS это невозможно гарантировать:
const math = require('./math');
Поскольку объект может изменяться динамически.
Классическая библиотечная сборка:
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: 'library.js',
path: path.resolve(__dirname, 'dist'),
library: 'MyLibrary',
libraryTarget: 'umd',
},
};
создаёт единый bundle.
После bundling:
В результате Tree Shaking перестаёт работать полноценно.
Современные библиотеки всё чаще публикуют:
Главная идея:
Webpack 5 поддерживает генерацию ESM-вывода.
Конфигурация:
module.exports = {
experiments: {
outputModule: true,
},
output: {
module: true,
},
};
Это позволяет Webpack генерировать ESM-бандл вместо CommonJS/UMD.
Даже при использовании:
output: {
module: true,
}
Webpack всё ещё создаёт bundle.
Это означает:
Поэтому output.module полезен, но недостаточен для
идеальной publish-стратегии библиотеки.
Для полноценного Tree Shaking необходимо:
На практике это означает подход:
src/
button.js
modal.js
dropdown.js
↓
dist/
button.js
modal.js
dropdown.js
без bundle-агрегации.
Полный пример:
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-конфигурации:
output: {
library: {
type: 'module',
},
},
В старом синтаксисе:
output: {
libraryTarget: 'module',
}
не рекомендуется.
Webpack изначально ориентирован на bundling, поэтому полноценного
аналога Rollup preserveModules у него нет.
Однако можно приблизиться к этому поведению через:
Пример:
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
Webpack потребителя использует поле:
{
"sideEffects": false
}
в package.json.
Это один из важнейших механизмов Tree Shaking.
Поле сообщает bundler-у:
Пример:
{
"sideEffects": false
}
Теперь:
import { Button } from 'my-ui-library';
не приведёт к включению всей библиотеки.
Некоторые модули имеют side effects:
import './styles.css';
или:
window.myGlobal = {};
или:
customElements.define(...);
Если указать:
{
"sideEffects": false
}
Webpack может удалить такие модули.
Корректный вариант:
{
"sideEffects": [
"*.css",
"./polyfills.js"
]
}
Теперь:
Файл:
export * from './button';
export * from './modal';
export * from './dropdown';
может ухудшать Tree Shaking в некоторых bundler-ах.
Особенно при:
Предпочтительнее:
export { Button } from './button';
export { Modal } from './modal';
export { Dropdown } from './dropdown';
В таком случае Webpack проще анализирует граф зависимостей.
Классическая проблема:
{
"presets": ["@babel/preset-env"]
}
Babel может преобразовать ESM в CommonJS.
Тогда даже идеальная Webpack-конфигурация перестаёт помогать.
Обязательная настройка Babel:
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
]
}
Теперь Babel сохраняет:
import/export
вместо:
require/module.exports
Правильный ESM-output содержит:
export
import
Неправильный:
__webpack_require__
exports.default
module.exports
Для ESM-публикации используется:
{
"type": "module"
}
Теперь .js трактуются как ESM.
Современная публикация библиотеки:
{
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js",
"./modal": "./dist/modal.js"
}
}
Преимущества:
Потребитель получает возможность:
import Button from 'my-library/button';
вместо:
import { Button } from 'my-library';
Это дополнительно уменьшает размер bundle.
Часто библиотеки публикуют одновременно:
Пример:
{
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js"
}
или:
{
"exports": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
Поле:
{
"module": "./dist/esm/index.js"
}
исторически используется bundler-ами для выбора ESM-версии.
Хотя современный стандарт — exports, многие инструменты
всё ещё ориентируются на module.
Даже простой код:
exports.sum = sum;
exports.multiply = multiply;
не гарантирует статичность.
Webpack не может безопасно удалить:
multiply
поскольку:
exports[name] = dynamicValue;
разрешено спецификацией CommonJS.
Webpack использует scope hoisting:
optimization: {
concatenateModules: true,
}
Это улучшает производительность.
Но для библиотек иногда ухудшает прозрачность модульной структуры.
При публикации ESM-библиотеки:
optimization: {
concatenateModules: false,
}
может быть полезнее.
Особенно если цель:
Для библиотек runtime Webpack часто вреден.
Лучше избегать:
optimization: {
runtimeChunk: 'single',
}
поскольку runtime увеличивает связность output.
Tree Shaking ухудшается, если зависимости встраиваются внутрь библиотеки.
Правильный подход:
externals: {
react: 'react',
lodash: 'lodash',
}
или:
externalsPresets: {
node: true,
}
Если встроить React внутрь bundle:
Импорт:
import './button.css';
всегда считается side effect.
Поэтому CSS-модули:
Многие современные библиотеки публикуют почти исходный код:
dist/
components/
hooks/
utils/
с минимальной транспиляцией.
Причины:
library.js
Плюсы:
Минусы:
library.mjs
Плюсы:
Минусы:
dist/
button.js
modal.js
Плюсы:
Минусы:
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',
},
};
{
"type": "module",
"sideEffects": [
"*.css"
],
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": "./dist/index.js",
"./button": "./dist/button.js",
"./modal": "./dist/modal.js"
}
}