Разделение чанков: manualChunks

При сборке проекта Vite использует bundler Rollup для формирования итоговых JavaScript-файлов. По умолчанию Rollup самостоятельно анализирует граф зависимостей и создаёт чанки автоматически. Такой подход подходит для большинства проектов, однако в крупных приложениях часто требуется ручной контроль над структурой сборки.

Параметр build.rollupOptions.output.manualChunks позволяет:

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

Настройка располагается в vite.config.js:

import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                manualChunks: {

                }
            }
        }
    }
})

Автоматическое разделение чанков

Без manualChunks Rollup сам создаёт зависимости:

dist/
├── index.js
├── vendor.js
├── chunk-AB12.js
└── chunk-CD34.js

Однако автоматическое разделение не всегда эффективно:

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

Базовое использование manualChunks

Самый простой вариант — объект с именами чанков.

export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    vue: ['vue'],
                    lodash: ['lodash'],
                    charts: ['chart.js']
                }
            }
        }
    }
})

Результат:

dist/
├── vue.js
├── lodash.js
├── charts.js
└── index.js

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


Выделение vendor-чанка

Наиболее распространённая практика — создание общего vendor-файла.

export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    vendor: [
                        'vue',
                        'vue-router',
                        'pinia'
                    ]
                }
            }
        }
    }
})

Такой подход:

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

Если код приложения меняется, браузер сможет использовать кешированный vendor.js.


Разделение тяжёлых библиотек

Некоторые зависимости значительно увеличивают размер bundle:

  • monaco-editor
  • chart.js
  • three
  • moment
  • firebase

Их полезно выделять отдельно.

manualChunks: {
    monaco: ['monaco-editor'],
    firebase: ['firebase/app', 'firebase/auth'],
    charts: ['chart.js']
}

Разделение по страницам

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

manualChunks: {
    admin: [
        './src/pages/admin/index.js'
    ],
    dashboard: [
        './src/pages/dashboard/index.js'
    ]
}

Это особенно полезно при:

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

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

Наиболее мощный вариант — функция.

manualChunks(id) {

}

Аргумент id содержит путь к модулю.

Пример:

manualChunks(id) {
    if (id.includes('node_modules')) {
        return 'vendor'
    }
}

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


Группировка библиотек по категориям

Функция позволяет создавать сложную структуру сборки.

manualChunks(id) {
    if (id.includes('node_modules')) {

        if (id.includes('vue')) {
            return 'vue'
        }

        if (id.includes('chart.js')) {
            return 'charts'
        }

        if (id.includes('firebase')) {
            return 'firebase'
        }

        return 'vendor'
    }
}

Результат:

dist/
├── vue.js
├── charts.js
├── firebase.js
├── vendor.js
└── index.js

Разделение UI-библиотек

UI-фреймворки часто занимают значительный объём.

manualChunks(id) {
    if (id.includes('@mui')) {
        return 'mui'
    }

    if (id.includes('antd')) {
        return 'antd'
    }

    if (id.includes('element-plus')) {
        return 'element'
    }
}

Разделение редакторов

Редакторы кода и WYSIWYG-компоненты обычно очень тяжёлые.

manualChunks(id) {
    if (id.includes('monaco-editor')) {
        return 'editor'
    }

    if (id.includes('ckeditor')) {
        return 'ckeditor'
    }
}

Выделение библиотек визуализации

manualChunks(id) {
    if (
        id.includes('chart.js') ||
        id.includes('echarts') ||
        id.includes('d3')
    ) {
        return 'charts'
    }
}

Разделение по маршрутам

Для крупных SPA удобно создавать route-based chunks.

manualChunks(id) {
    if (id.includes('/pages/admin/')) {
        return 'admin'
    }

    if (id.includes('/pages/profile/')) {
        return 'profile'
    }

    if (id.includes('/pages/dashboard/')) {
        return 'dashboard'
    }
}

Совместное использование с dynamic import

manualChunks особенно эффективен вместе с ленивой загрузкой.

const AdminPage = () => import('./pages/AdminPage.vue')

При этом:

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

Пример полноценной конфигурации

import { defineConfig } from 'vite'

export default defineConfig({
    build: {
        rollupOptions: {
            output: {
                manualChunks(id) {

                    if (id.includes('node_modules')) {

                        if (id.includes('vue')) {
                            return 'vue'
                        }

                        if (id.includes('firebase')) {
                            return 'firebase'
                        }

                        if (id.includes('chart.js')) {
                            return 'charts'
                        }

                        return 'vendor'
                    }

                    if (id.includes('/admin/')) {
                        return 'admin'
                    }

                    if (id.includes('/dashboard/')) {
                        return 'dashboard'
                    }
                }
            }
        }
    }
})

Анализ итоговой структуры

После сборки можно получить:

dist/
├── assets/
│   ├── vue.js
│   ├── vendor.js
│   ├── firebase.js
│   ├── charts.js
│   ├── admin.js
│   ├── dashboard.js
│   └── index.js

Как Rollup определяет чанки

Rollup строит граф зависимостей:

App
 ├── Router
 ├── Pinia
 ├── Chart.js
 └── Firebase

Затем:

  1. анализирует импорты;
  2. определяет общие зависимости;
  3. создаёт shared chunks;
  4. объединяет модули.

manualChunks вмешивается в этот процесс и позволяет вручную задавать правила группировки.


Приоритет manualChunks

Если модуль попадает под правило manualChunks, Rollup использует именно его.

if (id.includes('vue')) {
    return 'vue'
}

Даже если библиотека могла бы попасть в другой chunk, приоритет остаётся за manualChunks.


Кеширование и стабильность файлов

Одна из главных причин использования manualChunks — стабильное кеширование.

Без разделения:

app.js

После любого изменения:

app.js -> новый hash

Браузер заново скачивает весь bundle.

С разделением:

vendor.js
app.js

Изменение приложения:

vendor.js -> не меняется
app.js -> обновляется

Браузер использует кеш vendor-файла.


Проблема слишком большого vendor chunk

Ошибка многих проектов — создание одного гигантского vendor.js.

Плохо:

manualChunks(id) {
    if (id.includes('node_modules')) {
        return 'vendor'
    }
}

При большом количестве библиотек:

vendor.js = 3 MB

Это ухудшает:

  • time to interactive;
  • parse time;
  • execution time;
  • загрузку на мобильных устройствах.

Грамотная стратегия разделения

Хороший подход:

manualChunks(id) {

    if (id.includes('vue')) {
        return 'vue'
    }

    if (id.includes('firebase')) {
        return 'firebase'
    }

    if (id.includes('chart.js')) {
        return 'charts'
    }

    if (id.includes('monaco-editor')) {
        return 'editor'
    }

    if (id.includes('node_modules')) {
        return 'vendor'
    }
}

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

Наиболее эффективен в проектах:

  • enterprise SPA;
  • административные панели;
  • CRM;
  • аналитические системы;
  • редакторы;
  • dashboard-приложения;
  • приложения с тяжёлыми зависимостями.

Когда manualChunks может быть вреден

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

  • слишком много HTTP-запросов;
  • рост overhead;
  • ухудшение загрузки;
  • сложность поддержки;
  • нестабильные чанки.

Плохой пример:

manualChunks(id) {
    return id.split('/').pop()
}

Это создаст огромное количество мелких файлов.


Оптимальный размер чанков

Желательно:

50–300 KB gzip

Слишком маленькие:

5 KB

создают лишние запросы.

Слишком большие:

2–5 MB

замедляют загрузку.


Анализ bundle после разделения

Для анализа структуры используют:

npm install --save-dev rollup-plugin-visualizer

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

import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
    plugins: [
        visualizer()
    ]
})

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

npm run build

создаётся HTML-отчёт.


Связь manualChunks и tree shaking

manualChunks не отключает tree shaking.

Rollup продолжает:

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

Пример:

import { debounce } from 'lodash-es'

В chunk попадёт только нужный модуль.


Разделение CommonJS-библиотек

С CommonJS иногда возникают крупные чанки.

manualChunks(id) {
    if (id.includes('lodash')) {
        return 'lodash'
    }
}

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

  • moment;
  • lodash;
  • старых npm-пакетов.

Использование вместе с preload

Vite автоматически добавляет preload-зависимости.

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

index.js
 ├── preload vendor.js
 ├── preload vue.js
 └── lazy charts.js

Это улучшает производительность.


Проверка результата сборки

Размеры можно анализировать:

npm run build

Vite показывает:

dist/assets/index.js      45.21 kB
dist/assets/vendor.js    220.11 kB
dist/assets/charts.js    480.42 kB

Стратегии разделения

По типу библиотек

vue
charts
editor
firebase
vendor

По маршрутам

admin
profile
dashboard

По критичности

critical
lazy
analytics

По платформенным зонам

desktop
mobile
admin
public

Разделение внутри monorepo

В monorepo часто используют:

manualChunks(id) {
    if (id.includes('/packages/ui/')) {
        return 'ui'
    }

    if (id.includes('/packages/core/')) {
        return 'core'
    }
}

SSR и manualChunks

При SSR разделение особенно важно:

  • уменьшается server bundle;
  • ускоряется hydration;
  • оптимизируется загрузка клиента.

Однако необходимо избегать различий между server/client chunk graph.


Влияние на HTTP/2 и HTTP/3

Современные протоколы лучше работают с несколькими чанками, чем HTTP/1.1.

Но даже при HTTP/2 чрезмерное дробление остаётся проблемой:

200 маленьких чанков

всё ещё хуже, чем:

10–20 хорошо организованных файлов

Практический пример архитектуры

Крупное приложение:

vue.js
vendor.js
charts.js
editor.js
firebase.js
admin.js
dashboard.js
profile.js

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

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

Рекомендации по использованию

Хорошие практики

  • выделять тяжёлые библиотеки;
  • использовать lazy loading;
  • анализировать bundle visualizer;
  • создавать стабильные vendor chunks;
  • группировать зависимости логически.

Плохие практики

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