ModuleConcatenationPlugin и его ограничения

ModuleConcatenationPlugin — встроенный плагин Webpack, реализующий механизм scope hoisting. Его задача заключается в объединении нескольких JavaScript-модулей в одну общую область видимости без создания дополнительных обёрток функций для каждого модуля.

До появления scope hoisting большинство сборщиков формировали итоговый bundle по схеме:

[
    function(module, exports, require) {
        // module A
    },
    function(module, exports, require) {
        // module B
    }
]

Каждый модуль изолировался собственной функцией. Такая архитектура удобна, но создаёт дополнительные накладные расходы:

  • увеличение размера bundle;
  • рост количества функций;
  • ухудшение inline-оптимизаций движка JavaScript;
  • более медленный startup приложения.

ModuleConcatenationPlugin устраняет часть этих проблем, объединяя совместимые модули:

// module A
const value = 10;

// module B
console.log(value);

После конкатенации:

const value = 10;

console.log(value);

Webpack перестаёт создавать отдельные runtime-обёртки для некоторых модулей и превращает их в единый фрагмент кода.


Scope Hoisting

Термин scope hoisting появился благодаря Rollup, где такая оптимизация была реализована одной из первых.

Смысл подхода:

  • уменьшить количество замыканий;
  • сократить bootstrap-код;
  • улучшить tree shaking;
  • помочь V8 и другим JS-движкам эффективнее оптимизировать код.

Без scope hoisting:

(function() {
    function moduleA() {
        return 5;
    }

    function moduleB() {
        console.log(moduleA());
    }

    moduleB();
})();

Со scope hoisting:

function moduleA() {
    return 5;
}

console.log(moduleA());

Удаляются лишние уровни абстракции.


Автоматическое включение

В production-режиме Webpack автоматически включает ModuleConcatenationPlugin.

Эквивалент:

module.exports = {
    mode: 'production'
};

Webpack internally добавляет:

new webpack.optimize.ModuleConcatenationPlugin()

В development-режиме плагин отключён.


Ручное подключение

Явное подключение:

const webpack = require('webpack');

module.exports = {
    mode: 'none',

    plugins: [
        new webpack.optimize.ModuleConcatenationPlugin()
    ]
};

Обычно ручное подключение требуется:

  • при использовании mode: 'none';
  • при кастомных сборках;
  • при сравнении производительности;
  • при анализе bundle.

Как работает конкатенация модулей

Webpack строит dependency graph и анализирует:

  • типы импортов;
  • экспортируемые значения;
  • side effects;
  • динамические зависимости;
  • совместимость модулей.

После анализа формируются группы модулей:

  • root module;
  • inner modules.

Пример:

// math.js
export const sum = (a, b) => a + b;

// app.js
import { sum } from './math';

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

Без конкатенации:

__webpack_require__(/*! ./math */);

С конкатенацией:

const sum = (a, b) => a + b;

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

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

Уменьшение размера bundle

Удаляются:

  • runtime-обёртки;
  • вызовы __webpack_require__;
  • служебный bootstrap-код.

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


Ускорение startup

JavaScript-движок выполняет меньше:

  • function creation;
  • closure allocation;
  • module initialization.

Для крупных SPA это может заметно снижать время запуска.


Улучшение tree shaking

После объединения модулей Webpack проще анализировать использование экспортов.

Пример:

export function used() {}
export function unused() {}

Если код объединён в одну область видимости, неиспользуемые части проще удалить во время minimization.


Более эффективная оптимизация V8

V8 хуже оптимизирует deeply nested closures.

Scope hoisting позволяет:

  • лучше inline-функции;
  • уменьшить deopt;
  • сократить hidden class transitions.

Ограничения ModuleConcatenationPlugin

Несмотря на преимущества, Webpack не может объединять все модули подряд.

Существует большое количество ограничений.


ES Modules — обязательное условие

Конкатенация работает только для ES-модулей.

Поддерживается:

import { x } from './a';
export const value = 1;

Не поддерживается:

const lib = require('./a');

module.exports = {};

CommonJS несовместим со scope hoisting, потому что:

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

Проблемы CommonJS

Пример динамического require:

require('./' + name);

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

  • структуру зависимостей;
  • порядок исполнения;
  • набор экспортов.

Из-за этого модуль исключается из конкатенации.


Dynamic Import

Модули с import() создают отдельные chunk.

Пример:

import('./admin');

Такие зависимости становятся асинхронными границами.

Webpack не может конкатенировать:

  • синхронные модули;
  • асинхронные chunks.

Несовместимость с multiple chunks

Если модуль используется в нескольких chunk одновременно, конкатенация часто становится невозможной.

Причина:

  • объединённый код должен существовать в одном месте;
  • shared-модули требуют отдельного runtime-контейнера.

Пример:

entry: {
    main: './src/main.js',
    admin: './src/admin.js'
}

Общий utility-модуль может быть вынесен в отдельный chunk вместо конкатенации.


HMR и ModuleConcatenationPlugin

Hot Module Replacement плохо сочетается со scope hoisting.

Во время HMR Webpack должен:

  • изолировать модули;
  • заменять их независимо;
  • отслеживать boundaries обновления.

После конкатенации границы размываются.

Поэтому в development:

  • плагин обычно отключается;
  • HMR работает стабильнее;
  • rebuild быстрее.

Side Effects

Webpack избегает агрессивной конкатенации модулей с потенциальными side effects.

Пример:

console.log('init');

export const value = 1;

или:

window.shared = {};

Такие модули могут влиять на глобальное состояние.

Webpack обязан сохранить корректный порядок исполнения.


package.json и sideEffects

Для эффективной оптимизации используется:

{
    "sideEffects": false
}

Либо:

{
    "sideEffects": [
        "*.css"
    ]
}

Это помогает Webpack:

  • безопаснее выполнять tree shaking;
  • эффективнее объединять модули;
  • удалять unused exports.

Re-export chains

Сложные цепочки re-export ухудшают конкатенацию.

Пример:

export * from './a';
export * from './b';

Особенно проблемны:

  • namespace exports;
  • wildcard exports;
  • deep barrel-файлы.

Namespace import

Конструкция:

import * as utils from './utils';

создаёт namespace object.

Webpack сложнее анализировать подобные структуры.

Иногда это блокирует оптимизацию.


Eval Devtool

Некоторые devtool-режимы несовместимы со scope hoisting.

Особенно:

devtool: 'eval'

или:

devtool: 'eval-source-map'

Webpack вынужден генерировать код в виде отдельных eval-блоков, что мешает объединению модулей.


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

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

for (const key in exports) {
    console.log(key);
}

или:

Object.assign(exports, dynamicData);

Webpack теряет возможность статически анализировать структуру экспортов.


Circular Dependencies

Циклические зависимости значительно усложняют scope hoisting.

Пример:

// a.js
import { b } from './b';

export const a = 1;

// b.js
import { a } from './a';

export const b = 2;

Webpack может отказаться от конкатенации для сохранения корректного порядка инициализации.


Top Level This

ES Modules имеют:

this === undefined

CommonJS:

this === module.exports

Если модуль использует top-level this, Webpack часто исключает его из конкатенации.


Non-Strict Mode

ESM всегда strict mode.

Некоторые legacy-модули:

  • используют sloppy mode;
  • зависят от особенностей non-strict semantics;
  • модифицируют globals.

Это создаёт несовместимость.


Babel и потеря ESM

Одна из самых распространённых проблем — неправильная настройка Babel.

Плохая конфигурация:

{
    presets: [
        ['@babel/preset-env', {
            modules: 'commonjs'
        }]
    ]
}

Babel превращает ESM в CommonJS:

import x from './x';

становится:

var x = require('./x');

Webpack больше не может выполнять scope hoisting.


Правильная настройка Babel

Нужно сохранять ES-модули:

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

или:

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

при использовании Babel вместе с Webpack.


Как посмотреть результат конкатенации

Webpack помечает объединённые модули.

Статистика:

webpack --display-optimization-bailout

или:

stats: {
    optimizationBailout: true
}

Optimization Bailout

Webpack показывает причины отказа.

Примеры:

ModuleConcatenation bailout:
Module is not an ECMAScript module

или:

Cannot concat with ./node_modules/library/index.js

или:

Module uses eval()

Эти сообщения критически важны при оптимизации bundle.


Анализ через stats.json

Генерация:

webpack --json > stats.json

Далее анализируются:

  • concatenated modules;
  • optimization bailouts;
  • chunk graph;
  • duplicated dependencies.

Влияние на Source Maps

После объединения модулей source maps становятся сложнее.

Проблемы:

  • менее очевидные stack traces;
  • сложный mapping;
  • большие sourcemap-файлы.

Особенно заметно в крупных проектах.


Влияние на Debugging

Scope hoisting ухудшает:

  • читаемость bundle;
  • разделение модулей;
  • predictability runtime-кода.

В development это неудобно.

Поэтому production и development-конфигурации обычно различаются.


ModuleConcatenationPlugin и Terser

Наибольший эффект достигается совместно с minimizer.

После конкатенации Terser способен:

  • лучше inline-константы;
  • удалять dead branches;
  • выполнять cross-module optimizations;
  • агрессивнее минимизировать код.

Пример эффекта минимизации

До конкатенации:

function moduleA(exports) {
    exports.value = 5;
}

function moduleB(require) {
    console.log(require('./a').value);
}

После конкатенации и minimization:

console.log(5);

Scope Hoisting и Tree Shaking

Эти механизмы тесно связаны.

Tree shaking удаляет:

  • unused exports;
  • unused imports;
  • unreachable code.

Scope hoisting упрощает анализ зависимостей между модулями.

Вместе они формируют основу production-оптимизации Webpack.


Когда ModuleConcatenationPlugin почти бесполезен

Эффект минимален если:

  • проект состоит из нескольких крупных файлов;
  • используется heavy code splitting;
  • большинство модулей — CommonJS;
  • присутствует много dynamic import;
  • bundle уже сильно минимизирован.

Когда эффект максимален

Наиболее заметная выгода наблюдается:

  • в SPA с сотнями мелких ESM-модулей;
  • в UI-библиотеках;
  • в component-driven архитектуре;
  • в frontend-framework ecosystem;
  • при активном tree shaking.

Поведение в Webpack 5

Webpack 5 улучшил:

  • deterministic module ids;
  • chunk graph;
  • tree shaking;
  • side effects analysis;
  • module federation compatibility.

Но фундаментальные ограничения scope hoisting сохранились:

  • dynamic runtime мешает статическому анализу;
  • CommonJS остаётся проблемным;
  • async boundaries блокируют объединение.

Практические рекомендации

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

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

import/export

вместо:

require/module.exports

Минимизация barrel exports

Избыточные:

export * from ...

ухудшают анализ зависимостей.


Сохранение ESM в Babel

Критически важно:

modules: false

Корректный sideEffects

Неверная настройка:

{
    "sideEffects": false
}

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


Разделение production/development

Типичная схема:

mode: 'development'

без aggressive optimization.

И:

mode: 'production'

со scope hoisting и minimization.


Типичная production-конфигурация

const path = require('path');

module.exports = {
    mode: 'production',

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

    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'bundle.js'
    },

    optimization: {
        concatenateModules: true,
        usedExports: true,
        minimize: true
    }
};

optimization.concatenateModules

В Webpack 5 существует shorthand-опция:

optimization: {
    concatenateModules: true
}

Она управляет ModuleConcatenationPlugin internally.

Отключение:

optimization: {
    concatenateModules: false
}

Полезно:

  • при debugging;
  • анализе bundle;
  • сравнении производительности;
  • исследовании optimization bailouts.