Дедупликация модулей

Дедупликация модулей — это процесс устранения повторяющихся зависимостей внутри итогового бандла. В контексте Webpack под дублированием понимается ситуация, когда один и тот же модуль, библиотека или набор функций попадает в сборку несколько раз.

Подобные дубликаты возникают при:

  • использовании нескольких версий одной библиотеки;
  • неправильной конфигурации splitChunks;
  • подключении одинаковых пакетов в разных чанках;
  • смешивании CommonJS и ES Modules;
  • наличии симлинков и локальных пакетов;
  • использовании monorepo;
  • ошибках резолвинга модулей;
  • дублировании runtime-кода.

Основная цель дедупликации — уменьшение размера бандла и снижение объёма выполняемого JavaScript-кода.


Как возникают дубликаты модулей

Повторное подключение одной библиотеки

Наиболее распространённая ситуация:

npm ls lodash

Результат:

project
├── lodash@4.17.21
├─┬ package-a
│ └── lodash@4.17.20
└─┬ package-b
  └── lodash@4.17.21

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

В результате:

  • часть кода использует lodash@4.17.20;
  • другая часть использует lodash@4.17.21;
  • обе версии попадают в бандл.

Разные пути импорта

Даже одинаковые файлы могут считаться разными модулями.

Пример:

import utils from './utils';
import utils2 from './utils/index.js';

Если конфигурация настроена неправильно, Webpack может создать два независимых модуля.


Дублирование через node_modules

Вложенные зависимости часто создают копии библиотек:

node_modules/
  react/
  package-a/
    node_modules/
      react/

В этом случае в бандле окажутся две копии React.

Это особенно критично для:

  • React;
  • Vue;
  • MobX;
  • Zustand;
  • Redux Toolkit;
  • RxJS.

Некоторые библиотеки вообще не способны корректно работать в двух экземплярах.


Как Webpack идентифицирует модули

Webpack строит граф зависимостей.

Каждый модуль получает внутренний идентификатор на основе:

  • абсолютного пути;
  • типа загрузчика;
  • query-параметров;
  • resolve-конфигурации;
  • условий импорта.

Например:

/src/utils/index.js

и

/src/utils/index.js?raw

для Webpack — разные модули.


Встроенная дедупликация Webpack

Webpack автоматически устраняет часть дубликатов.

Single Module Instance

Если один и тот же путь импортируется многократно:

import a from './math';
import b from './math';

Webpack создаёт только один экземпляр модуля.

Это базовый механизм кэширования внутри dependency graph.


Module Cache

Во время выполнения бандла используется runtime-кэш:

__webpack_module_cache__

После первого выполнения модуля результат сохраняется.

Повторные импорты используют уже созданный экземпляр.

Это предотвращает:

  • повторное выполнение кода;
  • создание новых объектов;
  • повторную инициализацию библиотек.

Оптимизация через SplitChunksPlugin

Наиболее важный инструмент дедупликации — SplitChunksPlugin.

Проблема без splitChunks

Допустим, имеются два entry:

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

Оба используют React:

import React from 'react';

Без оптимизации React может попасть:

  • в admin.bundle.js;
  • в client.bundle.js.

Выделение общих зависимостей

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

module.exports = {
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
};

Webpack анализирует граф зависимостей и выносит общие модули в отдельный chunk.

Результат:

vendors.js
admin.js
client.js

Теперь React находится только в vendors.js.


Алгоритм работы splitChunks

Webpack анализирует:

  • размер модуля;
  • количество использований;
  • принадлежность к node_modules;
  • пересечение между чанками;
  • асинхронные импорты;
  • cache groups.

После этого принимается решение:

  • оставить модуль внутри chunk;
  • вынести его в shared chunk;
  • создать vendor chunk.

Настройка cacheGroups

Выделение vendor-библиотек

optimization: {
    splitChunks: {
        cacheGroups: {
            vendors: {
                test: /[\\/]node_modules[\\/]/,
                name: 'vendors',
                chunks: 'all'
            }
        }
    }
}

Все зависимости из node_modules собираются отдельно.


Выделение общих модулей приложения

optimization: {
    splitChunks: {
        cacheGroups: {
            common: {
                minChunks: 2,
                minSize: 0,
                name: 'common',
                chunks: 'all'
            }
        }
    }
}

Модули, используемые минимум два раза, выносятся в общий chunk.


Параметр minChunks

Ключевой механизм дедупликации.

minChunks: 2

Означает:

модуль должен использоваться минимум в двух чанках.

Пример:

Button.js

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

  • в home.js;
  • в profile.js.

Webpack создаст:

common.js

и вынесет Button.js туда.


Дедупликация runtime-кода

Webpack runtime тоже может дублироваться.

Проблема

Каждый entry содержит:

  • module loader;
  • chunk loader;
  • module cache;
  • runtime bootstrap.

При большом количестве entry runtime начинает занимать заметный объём.


runtimeChunk

Решение:

optimization: {
    runtimeChunk: 'single'
}

Webpack создаёт отдельный runtime:

runtime.js
vendors.js
app.js
admin.js

Теперь bootstrap-код не дублируется.


Module Federation и дедупликация

В микрофронтендах проблема становится особенно серьёзной.

Например:

  • shell использует React;
  • remote-module тоже использует React.

Без дедупликации браузер загрузит две копии.


Shared Dependencies

Module Federation предоставляет механизм shared modules.

shared: {
    react: {
        singleton: true
    },
    'react-dom': {
        singleton: true
    }
}

singleton

singleton: true

означает:

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

Это критически важно для React.


eager и lazy sharing

eager

shared: {
    react: {
        eager: true
    }
}

Библиотека загружается сразу.

Плюсы:

  • меньше runtime-запросов.

Минусы:

  • хуже lazy loading.

lazy sharing

По умолчанию shared-зависимости могут загружаться динамически.

Это снижает initial bundle size.


resolve.alias как инструмент дедупликации

Иногда разные пакеты используют разные пути к одной библиотеке.

Решение

resolve: {
    alias: {
        react: path.resolve(__dirname, 'node_modules/react')
    }
}

Теперь все импорты React указывают на единый экземпляр.


В monorepo и локальных пакетах часто возникают дубликаты.

Причина:

  • npm link;
  • yarn link;
  • pnpm workspace;
  • symlink-пакеты.

resolve: {
    symlinks: false
}

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

Это помогает избежать появления нескольких копий одной библиотеки.


Дедупликация через package manager

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


npm dedupe

Команда:

npm dedupe

пытается поднять одинаковые зависимости выше по дереву.

Пример:

До:

package-a/node_modules/react
package-b/node_modules/react

После:

node_modules/react

Yarn resolutions

Принудительное выравнивание версий:

{
  "resolutions": {
    "react": "^18.2.0"
  }
}

Все зависимости будут использовать единую версию.


pnpm dedupe

В pnpm используется контентно-адресуемое хранилище.

Это уже уменьшает физическое дублирование файлов.

Дополнительно:

pnpm dedupe

выравнивает версии зависимостей.


Tree Shaking и дедупликация

Tree Shaking и дедупликация — разные механизмы.

Tree Shaking

Удаляет:

  • неиспользуемые exports;
  • мёртвый код.

Дедупликация

Удаляет:

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

Проблема CommonJS

CommonJS хуже поддаётся дедупликации.

Причины:

  • динамический require;
  • mutable exports;
  • сложный static analysis.

Пример:

require(moduleName);

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


ES Modules и дедупликация

ESM намного эффективнее.

Webpack способен:

  • точно анализировать imports;
  • определять shared modules;
  • удалять unused exports;
  • оптимизировать chunk graph.

Concatenation и Scope Hoisting

ModuleConcatenationPlugin уменьшает overhead между модулями.

optimization: {
    concatenateModules: true
}

Webpack объединяет несколько модулей в одну функцию.

Это снижает:

  • размер bootstrap-кода;
  • число wrapper-функций;
  • объём runtime.

Анализ дубликатов

webpack-bundle-analyzer

Установка:

npm install webpack-bundle-analyzer --save-dev

Подключение:

const BundleAnalyzerPlugin =
    require('webpack-bundle-analyzer')
        .BundleAnalyzerPlugin;

plugins: [
    new BundleAnalyzerPlugin()
]

Что показывает analyzer

Инструмент позволяет увидеть:

  • duplicated packages;
  • oversized vendors;
  • повторяющиеся зависимости;
  • неэффективные chunks;
  • multiple React copies;
  • лишние версии библиотек.

Типичные признаки дублирования

Несколько версий React

Признаки:

  • hooks работают нестабильно;
  • появляется ошибка invalid hook call;
  • увеличивается bundle size.

Повторяющиеся utility-библиотеки

Например:

lodash
date-fns
moment
core-js

Несколько версий сильно раздувают vendors chunk.


Дублирование polyfills

Часто возникает при:

  • неправильной настройке Babel;
  • использовании разных preset-env;
  • смешивании core-js версий.

sideEffects и дедупликация

Webpack осторожно относится к модулям с побочными эффектами.

{
  "sideEffects": false
}

Это позволяет:

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

Проблемы агрессивной дедупликации

Иногда устранение дубликатов приводит к ошибкам.


Разные версии API

Пример:

react@17
react@18

Принудительное объединение может сломать приложение.


Stateful singleton libraries

Некоторые библиотеки зависят от singleton-состояния:

  • React;
  • MobX;
  • styled-components;
  • i18n frameworks.

Две копии создают конфликт контекстов.


Нарушение lazy loading

Слишком агрессивный splitChunks способен:

  • увеличивать число запросов;
  • ухудшать кеширование;
  • создавать giant vendor chunks.

Баланс между дедупликацией и кешированием

Огромный общий chunk — не всегда хорошо.

Проблема giant vendor

vendors.js = 4 MB

Изменение одной библиотеки инвалидирует весь кэш.


Более эффективная стратегия

Разделение по группам:

cacheGroups: {
    reactVendor: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: 'react-vendor'
    },

    utilityVendor: {
        test: /[\\/]node_modules[\\/](lodash|date-fns)[\\/]/,
        name: 'utility-vendor'
    }
}

Это улучшает:

  • долгосрочное кеширование;
  • переиспользование чанков;
  • стабильность hash-значений.

Deterministic Module IDs

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

optimization: {
    moduleIds: 'deterministic'
}

Это уменьшает churn hash-значений между сборками.

Влияние на дедупликацию косвенное, но важное:

  • стабильные идентификаторы;
  • более эффективный browser cache;
  • меньший объём повторной загрузки.

RemoveAvailableModulesPlugin

Webpack умеет удалять модули, уже доступные в родительских чанках.

Механизм:

  • анализирует chunk hierarchy;
  • проверяет доступность модулей;
  • предотвращает повторное включение.

Особенно эффективно при dynamic imports.


Async Chunks и shared dependencies

Пример:

import('./dashboard');
import('./profile');

Оба чанка используют Chart.js.

Webpack может:

  • продублировать библиотеку;
  • либо создать shared async chunk.

Поведение зависит от:

  • splitChunks.chunks;
  • minSize;
  • minChunks;
  • reuseExistingChunk.

reuseExistingChunk

reuseExistingChunk: true

Webpack переиспользует уже созданный chunk вместо генерации нового.

Это уменьшает duplication между async-модулями.


Практическая production-конфигурация

module.exports = {
    optimization: {
        runtimeChunk: 'single',

        moduleIds: 'deterministic',

        splitChunks: {
            chunks: 'all',

            cacheGroups: {
                reactVendor: {
                    test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
                    name: 'react-vendor',
                    priority: 20
                },

                vendors: {
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendors',
                    priority: 10
                },

                common: {
                    minChunks: 2,
                    priority: 5,
                    reuseExistingChunk: true
                }
            }
        }
    }
};

Основные эффекты дедупликации

Грамотно настроенная дедупликация позволяет:

  • уменьшить bundle size;
  • снизить объём JavaScript;
  • ускорить parse/eval;
  • сократить время hydration;
  • улучшить кеширование;
  • уменьшить memory consumption;
  • избежать конфликтов singleton-библиотек;
  • ускорить загрузку приложения;
  • повысить эффективность code splitting.