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

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

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

Например:

project/
├─ node_modules/
│  ├─ lodash/
│  └─ package-a/
│      └─ node_modules/
│          └─ lodash/

В такой ситуации существуют две отдельные копии lodash. Несмотря на одинаковое содержимое, для системы модулей это разные сущности, поскольку они располагаются по разным путям.

Если дедупликация не выполняется, итоговый бандл может содержать несколько экземпляров одной библиотеки, что приводит к:

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

Почему дублирование опасно

Наиболее заметные проблемы возникают в библиотеках, которые используют глобальное состояние или внутренние реестры объектов.

Рассмотрим условный пакет:

// store.js

const state = {
    counter: 0
};

export default state;

Если модуль присутствует в сборке дважды:

import stateA from 'store';
import stateB from 'store';

ожидается:

stateA === stateB;

Но при наличии двух физических копий:

false

Каждая копия создаёт собственный объект состояния.

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

  • React;
  • Vue;
  • MobX;
  • Redux Toolkit;
  • Zustand;
  • RxJS;
  • библиотеках внедрения зависимостей;
  • системах маршрутизации.

Как Rollup определяет уникальность модулей

Основой работы Rollup является граф зависимостей.

Каждый импорт проходит этап резолвинга:

import lodash from 'lodash';

После разрешения идентификатора Rollup получает абсолютный путь:

/project/node_modules/lodash/index.js

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

Если другой импорт приводит к тому же файлу:

/project/node_modules/lodash/index.js

Rollup использует уже существующий экземпляр.

Если путь отличается:

/project/node_modules/package-a/node_modules/lodash/index.js

создаётся новый узел графа.

Таким образом, дедупликация основана не на названии пакета, а на результате резолвинга.


Дублирование через вложенные зависимости

Классический пример:

Application
├─ React 18
├─ Package A
│  └─ React 18
└─ Package B
    └─ React 18

На уровне npm это может выглядеть следующим образом:

node_modules/
├─ react/
├─ package-a/
│  └─ node_modules/
│      └─ react/
└─ package-b/

При построении графа Rollup обнаруживает:

/node_modules/react/index.js
/node_modules/package-a/node_modules/react/index.js

Это разные файлы.

Следовательно:

ReactA !== ReactB

В итоговой сборке появляется несколько экземпляров React.

Для React подобная ситуация особенно критична, поскольку вызывает ошибки вида:

Invalid hook call

или

Hooks can only be called inside the body of a function component

Причина заключается в том, что различные компоненты начинают использовать разные копии внутреннего рантайма.


Роль плагина node-resolve

Большинство механизмов дедупликации реализуется через плагин:

import { nodeResolve } from '@rollup/plugin-node-resolve';

Он отвечает за поиск пакетов внутри node_modules.

Базовая конфигурация:

export default {
    plugins: [
        nodeResolve()
    ]
};

Однако без дополнительной настройки плагин будет использовать стандартный алгоритм поиска модулей Node.js.

В результате вложенные зависимости могут разрешаться в собственные локальные копии пакетов.


Опция dedupe

Для принудительной дедупликации используется параметр:

nodeResolve({
    dedupe: ['react']
})

Теперь независимо от места импорта пакет будет разрешаться к верхнеуровневой версии.

Пример:

import React from 'react';

из:

src/App.js

и из:

node_modules/package-a/index.js

будет указывать на один файл:

/project/node_modules/react/index.js

В графе появится единственный экземпляр React.


Дедупликация нескольких библиотек

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

Например:

nodeResolve({
    dedupe: [
        'react',
        'react-dom',
        'rxjs',
        'mobx'
    ]
})

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

Это особенно важно для экосистем, состоящих из нескольких тесно связанных пакетов.

Пример:

nodeResolve({
    dedupe: [
        'react',
        'react-dom',
        'scheduler'
    ]
})

Использование функции в dedupe

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

Пример:

nodeResolve({
    dedupe(importee) {
        return importee.startsWith('react');
    }
})

Будут дедуплицироваться:

react
react-dom
react/jsx-runtime
react/jsx-dev-runtime

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


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

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

Например:

import { jsx } from 'react/jsx-runtime';

или:

import scheduler from 'scheduler/tracing';

При использовании массива:

dedupe: ['react']

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

То есть:

react/jsx-runtime

будет дедуплицирован автоматически.

Это позволяет избежать перечисления большого количества путей.


Связь дедупликации с tree shaking

Tree shaking удаляет неиспользуемый код.

Однако если библиотека присутствует в графе дважды:

react copy #1
react copy #2

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

Даже при агрессивной оптимизации часть кода может сохраниться дважды.

Пример:

50 KB + 50 KB

вместо:

50 KB

Поэтому эффективная дедупликация повышает результативность tree shaking.


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

При разделении кода на чанки проблема становится ещё заметнее.

Допустим:

const admin = import('./admin.js');
const profile = import('./profile.js');

Если оба раздела используют разные копии одной библиотеки:

admin chunk
 └─ react copy #1

profile chunk
 └─ react copy #2

вместо общего чанка могут появиться дополнительные дубликаты.

После дедупликации Rollup способен сформировать единый разделяемый модуль:

shared-react.js

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


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

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

Рассмотрим библиотеку:

{
  "name": "my-library",
  "peerDependencies": {
    "react": "^18.0.0"
  }
}

Такой пакет не устанавливает собственную копию React.

Вместо этого используется экземпляр приложения:

Application
└─ react

Это практически исключает вероятность появления нескольких версий React в графе.


Дедупликация и монорепозитории

В монорепозиториях проблема возникает особенно часто.

Структура:

packages/
├─ app/
├─ ui/
├─ utils/
└─ core/

Каждый пакет может содержать собственные зависимости.

Например:

ui/node_modules/react
core/node_modules/react

Во время сборки приложения появляются несколько экземпляров библиотеки.

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

nodeResolve({
    dedupe: ['react']
})

заставляет все пакеты использовать одну копию.

Это особенно актуально для:

  • pnpm;
  • Rush;
  • Nx;
  • Turborepo;
  • Lerna.

Особенности работы с pnpm

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

Физическое расположение модулей может выглядеть необычно:

node_modules/.pnpm/

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

Например:

react@18.2.0
react@18.3.0

Для Rollup это различные зависимости.

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

nodeResolve({
    dedupe: ['react']
})

Но если реально установлены разные версии пакета, дедупликация не может автоматически объединить несовместимые реализации.


Когда дедупликация невозможна

Иногда две версии библиотеки действительно отличаются.

Например:

lodash@3
lodash@4

или:

react@17
react@18

Объединение таких модулей может привести к поломке приложения.

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

Он работает только с разрешением импортов.

Если разные части проекта требуют несовместимые версии зависимости, необходимо решать проблему на уровне управления пакетами:

  • обновлять зависимости;
  • выравнивать версии;
  • использовать peerDependencies;
  • изменять структуру монорепозитория.

Проверка наличия дубликатов

Признаками дублирования могут служить:

  • неожиданно большой размер бандла;
  • повторяющиеся участки кода;
  • несколько копий React или Vue в отчётах анализаторов;
  • ошибки работы хуков;
  • наличие нескольких экземпляров состояния.

Для диагностики часто используются:

npm ls react

или

pnpm why react

Также помогают визуализаторы графа зависимостей и анализаторы чанков.

Если отчёт показывает:

react
react (duplicate)

это явный сигнал о необходимости дедупликации.


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

Типичная конфигурация крупного проекта выглядит следующим образом:

import { defineConfig } from 'rollup';
import { nodeResolve } from '@rollup/plugin-node-resolve';

export default defineConfig({
    input: 'src/index.js',

    plugins: [
        nodeResolve({
            dedupe: [
                'react',
                'react-dom',
                'scheduler',
                'rxjs'
            ]
        })
    ]
});

Такая настройка обеспечивает:

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

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