Поле output.preserveModules

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

Это особенно важно для библиотек, SDK, монорепозиториев и пакетов, где требуется:

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

Базовый пример

Исходная структура:

src/
├── index.js
├── math/
│   ├── add.js
│   └── sub.js
└── utils/
    └── format.js

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

export default {
    input: 'src/index.js',
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true
    }
};

После сборки:

dist/
├── index.js
├── math/
│   ├── add.js
│   └── sub.js
└── utils/
    └── format.js

Каждый модуль остаётся отдельным файлом.


Поведение без preserveModules

Стандартная сборка:

export default {
    input: 'src/index.js',
    output: {
        file: 'dist/bundle.js',
        format: 'es'
    }
};

Результат:

dist/
└── bundle.js

Все модули объединяются в один файл.


Разница между file и dir

При использовании preserveModules нельзя применять поле output.file.

Неверно:

output: {
    file: 'dist/bundle.js',
    preserveModules: true
}

Rollup выдаст ошибку.

Правильно:

output: {
    dir: 'dist',
    preserveModules: true
}

Причина очевидна: при сохранении модулей генерируется множество файлов, а не один бандл.


Как Rollup сохраняет структуру

Rollup повторяет дерево исходных файлов относительно точки входа.

Пример:

src/
├── components/
│   ├── button.js
│   └── modal.js
└── core/
    └── config.js

Выход:

dist/
├── components/
│   ├── button.js
│   └── modal.js
└── core/
    └── config.js

Импорты автоматически переписываются:

Исходный код:

import { config } from '../core/config.js';

После сборки:

import { config } from '../core/config.js';

Пути корректируются относительно выходной структуры.


Использование в библиотеках

preserveModules особенно полезен при публикации библиотек.

Пример структуры npm-пакета:

dist/
├── index.js
├── hooks/
│   ├── useFetch.js
│   └── useStorage.js
└── utils/
    └── debounce.js

Пользователь может импортировать отдельные части:

import debounce from 'my-lib/utils/debounce.js';

или:

import { useFetch } from 'my-lib/hooks/useFetch.js';

Это снижает размер итогового бандла приложения потребителя.


Влияние на tree shaking

При обычной сборке Rollup может объединять код таким образом, что отдельные части библиотеки становятся менее изолированными.

preserveModules позволяет сохранить модульные границы, благодаря чему:

  • webpack;
  • Vite;
  • Rollup;
  • esbuild;

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


Использование с несколькими entry points

Пример:

export default {
    input: [
        'src/index.js',
        'src/admin.js'
    ],
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true
    }
};

Rollup создаст структуру, содержащую все зависимости обеих точек входа.


Поле preserveEntrySignatures

При работе с preserveModules часто используется:

preserveEntrySignatures: 'strict'

Пример:

export default {
    input: 'src/index.js',
    preserveEntrySignatures: 'strict',
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true
    }
};

Это помогает сохранить экспортные контракты модулей без изменений.


Генерация CommonJS-модулей

preserveModules работает не только с ES Modules.

Пример:

export default {
    input: 'src/index.js',
    output: {
        dir: 'dist',
        format: 'cjs',
        preserveModules: true
    }
};

Результат:

'use strict';

const add = require('./math/add.js');

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


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

Без дополнительной настройки Rollup может включать лишние каталоги в итоговую структуру.

Пример:

src/
└── library/
    ├── index.js
    └── utils.js

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

output: {
    dir: 'dist',
    preserveModules: true
}

Результат:

dist/
└── library/
    ├── index.js
    └── utils.js

Иногда требуется убрать промежуточный каталог library.

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

output: {
    dir: 'dist',
    preserveModules: true,
    preserveModulesRoot: 'src/library'
}

Теперь результат:

dist/
├── index.js
└── utils.js

Работа с TypeScript

Частый сценарий:

import typescript from '@rollup/plugin-typescript';

export default {
    input: 'src/index.ts',
    plugins: [
        typescript()
    ],
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true
    }
};

Исходная структура:

src/
├── index.ts
├── api/
│   └── client.ts
└── helpers/
    └── parse.ts

После сборки:

dist/
├── index.js
├── api/
│   └── client.js
└── helpers/
    └── parse.js

Особенности генерации чанков

Даже при preserveModules Rollup всё равно может создавать дополнительные shared chunks.

Причина — повторно используемые зависимости.

Пример:

src/
├── a.js
├── b.js
└── shared.js

Если a.js и b.js используют shared.js, Rollup может вынести код в отдельный чанк.


Полное сохранение модулей без shared chunks

Иногда требуется абсолютное соответствие структуре исходников.

Для этого используют:

output: {
    preserveModules: true,
    hoistTransitiveImports: false
}

Однако полностью отключить внутреннюю оптимизацию Rollup невозможно без дополнительных плагинов и архитектурных ограничений.


Пример структуры большой библиотеки

Исходники:

src/
├── index.js
├── components/
│   ├── button/
│   │   ├── index.js
│   │   └── styles.js
│   └── modal/
│       ├── index.js
│       └── animations.js
├── hooks/
│   ├── useFetch.js
│   └── useTheme.js
└── utils/
    ├── debounce.js
    └── throttle.js

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

export default {
    input: 'src/index.js',
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true,
        preserveModulesRoot: 'src'
    }
};

Результат:

dist/
├── index.js
├── components/
│   ├── button/
│   │   ├── index.js
│   │   └── styles.js
│   └── modal/
│       ├── index.js
│       └── animations.js
├── hooks/
│   ├── useFetch.js
│   └── useTheme.js
└── utils/
    ├── debounce.js
    └── throttle.js

Отличие от code splitting

preserveModules и code splitting решают разные задачи.

Code splitting

Цель:

  • уменьшение размера initial bundle;
  • ленивые загрузки;
  • оптимизация производительности приложения.

Результат:

dist/
├── main.js
├── vendor.js
└── chunk-XYZ.js

Preserve modules

Цель:

  • сохранение структуры библиотеки;
  • публикация отдельных модулей;
  • поддержка tree shaking.

Результат:

dist/
├── utils/
├── hooks/
└── components/

Работа с внешними зависимостями

Часто preserveModules используется вместе с external.

Пример:

export default {
    input: 'src/index.js',
    external: ['react'],
    output: {
        dir: 'dist',
        format: 'es',
        preserveModules: true
    }
};

Rollup не будет включать React в выходные файлы.


Генерация dual package

Распространённая практика — создание одновременно ESM и CommonJS-версий.

Пример:

export default [
    {
        input: 'src/index.js',
        output: {
            dir: 'dist/esm',
            format: 'es',
            preserveModules: true
        }
    },
    {
        input: 'src/index.js',
        output: {
            dir: 'dist/cjs',
            format: 'cjs',
            preserveModules: true
        }
    }
];

Структура:

dist/
├── esm/
│   ├── index.js
│   └── utils/
└── cjs/
    ├── index.js
    └── utils/

Проблемы при использовании preserveModules

Увеличение количества файлов

Вместо одного бандла могут генерироваться сотни файлов.

Это приводит к:

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

Сложности с Node.js resolution

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

Особенно это касается:

  • старых версий webpack;
  • устаревших CommonJS-проектов;
  • legacy build pipelines.

Усложнение публикации

Необходимо аккуратно настраивать:

  • package.json;
  • поле exports;
  • типы TypeScript;
  • sourcemaps.

Совместимость с package.json exports

Пример:

{
    "exports": {
        ".": "./dist/index.js",
        "./utils/*": "./dist/utils/*.js",
        "./hooks/*": "./dist/hooks/*.js"
    }
}

Это позволяет официально экспортировать внутренние модули.


Sourcemaps и preserveModules

Sourcemaps работают корректно:

output: {
    dir: 'dist',
    format: 'es',
    preserveModules: true,
    sourcemap: true
}

Результат:

dist/
├── index.js
├── index.js.map
├── utils/
│   ├── helper.js
│   └── helper.js.map

Когда preserveModules использовать не стоит

Поле редко применяется в:

  • SPA-приложениях;
  • frontend-приложениях с единой точкой входа;
  • production-бандлах сайтов;
  • SSR-приложениях без библиотечной архитектуры.

В подобных случаях эффективнее стандартный code splitting.


Типичный production-конфиг библиотеки

import typescript from '@rollup/plugin-typescript';

export default {
    input: 'src/index.ts',
    external: [
        'react',
        'react-dom'
    ],
    plugins: [
        typescript({
            declaration: true,
            declarationDir: 'dist/types'
        })
    ],
    output: {
        dir: 'dist/esm',
        format: 'es',
        preserveModules: true,
        preserveModulesRoot: 'src',
        sourcemap: true
    }
};

Такой подход обеспечивает:

  • модульную структуру;
  • качественный tree shaking;
  • независимые импорты;
  • удобную поддержку;
  • совместимость с современными bundler-системами.