Дедупликация модулей (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
Каждая копия создаёт собственный объект состояния.
Подобные проблемы часто возникают в:
Основой работы 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
Причина заключается в том, что различные компоненты начинают использовать разные копии внутреннего рантайма.
Большинство механизмов дедупликации реализуется через плагин:
import { nodeResolve } from '@rollup/plugin-node-resolve';
Он отвечает за поиск пакетов внутри node_modules.
Базовая конфигурация:
export default {
plugins: [
nodeResolve()
]
};
Однако без дополнительной настройки плагин будет использовать стандартный алгоритм поиска модулей Node.js.
В результате вложенные зависимости могут разрешаться в собственные локальные копии пакетов.
Для принудительной дедупликации используется параметр:
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'
]
})
Плагин поддерживает функцию вместо массива.
Пример:
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 удаляет неиспользуемый код.
Однако если библиотека присутствует в графе дважды:
react copy #1
react copy #2
каждая копия анализируется отдельно.
Даже при агрессивной оптимизации часть кода может сохраниться дважды.
Пример:
50 KB + 50 KB
вместо:
50 KB
Поэтому эффективная дедупликация повышает результативность tree shaking.
При разделении кода на чанки проблема становится ещё заметнее.
Допустим:
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.
Рассмотрим библиотеку:
{
"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 активно использует символические ссылки.
Физическое расположение модулей может выглядеть необычно:
node_modules/.pnpm/
Одна и та же библиотека способна иметь несколько ссылок на разные версии.
Например:
react@18.2.0
react@18.3.0
Для Rollup это различные зависимости.
Дедупликация помогает гарантировать использование конкретной версии:
nodeResolve({
dedupe: ['react']
})
Но если реально установлены разные версии пакета, дедупликация не может автоматически объединить несовместимые реализации.
Иногда две версии библиотеки действительно отличаются.
Например:
lodash@3
lodash@4
или:
react@17
react@18
Объединение таких модулей может привести к поломке приложения.
Поэтому Rollup не пытается выполнять интеллектуальное слияние содержимого.
Он работает только с разрешением импортов.
Если разные части проекта требуют несовместимые версии зависимости, необходимо решать проблему на уровне управления пакетами:
Признаками дублирования могут служить:
Для диагностики часто используются:
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'
]
})
]
});
Такая настройка обеспечивает:
Дедупликация модулей является важной частью оптимизации графа зависимостей Rollup. Хотя механизм выглядит относительно простым, именно он позволяет избежать множества трудноуловимых ошибок, связанных с существованием нескольких экземпляров одной и той же библиотеки внутри итоговой сборки.