Динамические импорты и code splitting

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

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

Динамические импорты решают эту проблему за счёт ленивой загрузки модулей.

Вместо статического импорта:

import { createChart } from './chart.js'

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

const module = await import('./chart.js')

В этом случае модуль не попадает в основной bundle. Vite создаёт отдельный chunk и загружает его только в момент выполнения кода.


Как работает code splitting

Code splitting — механизм разделения приложения на независимые части.

Во время сборки Vite анализирует граф зависимостей и определяет:

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

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

Например:

async function openAdminPanel() {
    const module = await import('./admin.js')

    module.initAdmin()
}

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

dist/
├── assets/
│   ├── index-AB12.js
│   ├── admin-XY99.js
│   └── vendor-QW11.js

Главный bundle загружается сразу:

index-AB12.js

Файл:

admin-XY99.js

будет загружен только при вызове openAdminPanel().


Поведение динамического импорта

Функция import() возвращает Promise.

Поэтому доступны все стандартные механизмы асинхронности:

await

const module = await import('./editor.js')

then

import('./editor.js')
    .then(module => {
        module.start()
    })

Promise.all

const [charts, maps] = await Promise.all([
    import('./charts.js'),
    import('./maps.js')
])

Структура импортируемого модуля

Динамический импорт возвращает объект модуля.

Модуль:

export function init() {
    console.log('started')
}

export const version = '1.0'

Импорт:

const module = await import('./feature.js')

module.init()
console.log(module.version)

Импорт default-экспорта

export default function createPlayer() {
    console.log('player')
}

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

const module = await import('./player.js')

module.default()

Часто применяют деструктуризацию:

const { default: createPlayer } =
    await import('./player.js')

createPlayer()

Условия для ленивой загрузки

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

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

Пример:

button.addEventListener('click', async () => {
    const { openEditor } =
        await import('./editor.js')

    openEditor()
})

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


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

Vite использует Rollup во время production-сборки. Rollup умеет автоматически выделять общие зависимости.

Например:

const moduleA = await import('./a.js')
const moduleB = await import('./b.js')

Если оба модуля используют:

lodash

Rollup может вынести библиотеку в отдельный vendor chunk.

Результат:

vendor.js
a.js
b.js

Это уменьшает дублирование кода.


Динамические импорты в роутинге

Один из самых распространённых сценариев — ленивые страницы.

Vue Router

const routes = [
    {
        path: '/admin',
        component: () => import('./pages/Admin.vue')
    }
]

React

const AdminPage = React.lazy(() =>
    import('./AdminPage.jsx')
)

Svelte

const page = await import('./pages/about.svelte')

В результате страницы загружаются только при переходе пользователя.


Размер initial bundle

Главная задача code splitting — уменьшение initial bundle.

Без динамических импортов:

main.js = 2.8 MB

С динамическими импортами:

main.js = 240 KB
charts.js = 900 KB
editor.js = 1.1 MB
admin.js = 500 KB

Старт приложения становится значительно быстрее.


Lazy loading и производительность

Code splitting влияет на несколько ключевых метрик:

  • First Contentful Paint;
  • Largest Contentful Paint;
  • Time To Interactive;
  • Total Blocking Time.

Большой bundle создаёт нагрузку не только на сеть, но и на JavaScript engine браузера.

Даже после загрузки файл должен быть:

  1. распакован;
  2. распарсен;
  3. скомпилирован;
  4. выполнен.

Разделение кода уменьшает объём работы при старте приложения.


Ограничения динамических импортов

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

Корректно:

await import('./pages/about.js')

Проблематично:

await import(path)

Поскольку сборщик должен заранее понимать структуру зависимостей.


Частично динамические пути

Vite поддерживает ограниченную динамику:

await import(`./pages/${name}.js`)

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

Например:

pages/
├── home.js
├── about.js
└── contacts.js

Vite создаст chunks для всех возможных модулей.


import.meta.glob

Для работы с большим количеством динамических модулей Vite предоставляет специальный API.

const modules = import.meta.glob('./pages/*.js')

Результат:

{
    './pages/home.js': [Function],
    './pages/about.js': [Function],
    './pages/blog.js': [Function]
}

Каждое значение — функция ленивого импорта.


Ленивое подключение через import.meta.glob

const pages = import.meta.glob('./pages/*.js')

const module =
    await pages['./pages/about.js']()

module.render()

Vite автоматически создаёт отдельные chunks.


eager-режим

По умолчанию import.meta.glob работает лениво.

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

const modules = import.meta.glob(
    './pages/*.js',
    { eager: true }
)

Теперь модули подключаются сразу.

Это аналогично обычным статическим импортам.


Получение конкретного экспорта

const modules = import.meta.glob(
    './pages/*.js',
    {
        import: 'setup'
    }
)

Если файл содержит:

export function setup() {}

то результатом станет импорт только нужного экспорта.


Комбинирование eager и import

const modules = import.meta.glob(
    './pages/*.js',
    {
        eager: true,
        import: 'default'
    }
)

Несколько шаблонов glob

const modules = import.meta.glob([
    './pages/*.js',
    './widgets/*.js'
])

Исключение файлов

const modules = import.meta.glob([
    './pages/*.js',
    '!./pages/admin.js'
])

preload и prefetch

Code splitting не означает полное отсутствие предварительной загрузки.

Существуют стратегии:

preload

Ресурс понадобится очень скоро.

<link rel="modulepreload" href="/assets/chunk.js">

prefetch

Ресурс может понадобиться позже.

<link rel="prefetch" href="/assets/chunk.js">

Vite автоматически использует modulepreload для оптимизации зависимостей.


Кэширование chunks

Разделённые chunks хорошо подходят для долгосрочного кэширования.

Например:

main-A1.js
editor-B9.js
charts-C4.js

При изменении editor-модуля:

main-A1.js
editor-Z8.js
charts-C4.js

Кэш браузера сохранится для остальных файлов.


Ручное разделение chunks

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

Используется параметр:

build.rollupOptions.output.manualChunks

Пример:

export default {
    build: {
        rollupOptions: {
            output: {
                manualChunks: {
                    vendor: ['vue'],
                    charts: ['chart.js'],
                    editor: ['monaco-editor']
                }
            }
        }
    }
}

Результат:

vendor.js
charts.js
editor.js

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

Крупные SPA часто делят по маршрутам:

home.js
catalog.js
product.js
admin.js
profile.js

Каждая страница становится отдельной точкой входа.

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


Nested chunks

Динамический импорт может существовать внутри другого динамического импорта.

await import('./admin.js')

Внутри:

await import('./charts.js')

Получается каскадная загрузка:

main.js
→ admin.js
→ charts.js

Проблема waterfall-загрузок

Избыточная вложенность способна ухудшить производительность.

Плохой сценарий:

main.js
→ page.js
→ editor.js
→ plugins.js
→ locales.js

Каждый следующий chunk начинает загружаться только после предыдущего.

Это называется waterfall loading.


Оптимизация каскадной загрузки

Параллельные импорты

const [
    editor,
    plugins,
    locales
] = await Promise.all([
    import('./editor.js'),
    import('./plugins.js'),
    import('./locales.js')
])

Уменьшение глубины зависимостей

Лучше:

main.js
→ feature.js

Хуже:

main.js
→ a.js
→ b.js
→ c.js
→ d.js

Анализ структуры bundle

Для анализа результатов code splitting часто используют:

  • rollup-plugin-visualizer;
  • vite-bundle-visualizer;
  • source-map-explorer.

Пример подключения visualizer:

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

export default {
    plugins: [
        visualizer()
    ]
}

После сборки появляется интерактивная карта bundle.


Tree shaking и динамические импорты

Vite и Rollup продолжают выполнять tree shaking даже внутри разделённых chunks.

Если модуль экспортирует:

export function a() {}
export function b() {}
export function c() {}

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

a()

неиспользуемый код может быть удалён.


Асинхронные границы приложения

Каждый динамический импорт создаёт асинхронную границу.

Это влияет на архитектуру приложения:

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

Обработка ошибок загрузки

Динамический импорт способен завершиться ошибкой:

  • отсутствует chunk;
  • потеряно соединение;
  • повреждён кэш;
  • произошёл timeout.

Пример:

try {
    const module =
        await import('./editor.js')

    module.start()
} catch (error) {
    console.error(
        'Ошибка загрузки editor.js',
        error
    )
}

Retry-механизмы

Иногда полезно повторить загрузку:

async function loadModule(retries = 3) {
    while (retries > 0) {
        try {
            return await import('./heavy.js')
        } catch {
            retries--
        }
    }

    throw new Error('Module load failed')
}

Dynamic import и SSR

Во время SSR динамические импорты работают иначе.

На сервере:

  • импорт может выполняться сразу;
  • chunks не загружаются через сеть;
  • используются серверные модули Node.js.

Во время hydration браузер уже переключается на обычную клиентскую схему загрузки.


Разница между dev и production

В режиме разработки Vite использует native ESM.

Каждый файл существует отдельно:

/src/main.js
/src/admin.js
/src/chart.js

В production:

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

Поведение dynamic import визуально одинаковое, но внутренняя реализация различается.


Именование chunks

Rollup автоматически генерирует имена:

index-a1b2.js
vendor-f9d2.js

Настройка:

export default {
    build: {
        rollupOptions: {
            output: {
                chunkFileNames:
                    'chunks/[name]-[hash].js'
            }
        }
    }
}

Inline dynamic imports

Существует режим:

inlineDynamicImports: true

Он отключает code splitting и объединяет всё в один bundle.

Используется редко.


Когда code splitting вреден

Избыточное дробление приложения создаёт проблемы:

  • слишком много HTTP-запросов;
  • увеличение latency;
  • сложная структура зависимостей;
  • waterfall-загрузка;
  • ухудшение кэширования.

Плохой сценарий:

200 chunks по 5 KB

Хороший сценарий:

10–20 логически разделённых chunks

Практические стратегии разделения

По маршрутам

/home
/catalog
/product
/admin

По тяжёлым библиотекам

chart.js
monaco-editor
three.js

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

export
analytics
reports

По ролям пользователей

guest
user
admin
moderator

Пример полноценной архитектуры

main.js
├── vendor.js
├── router.js
├── home.js
├── catalog.js
├── product.js
├── admin.js
├── charts.js
└── editor.js

Стартовая загрузка:

main.js + vendor.js

При открытии каталога:

catalog.js

При открытии админки:

admin.js

При запуске аналитики:

charts.js

Оптимизация UX при ленивой загрузке

Поскольку chunks загружаются асинхронно, интерфейс должен учитывать состояние ожидания.

Типичные решения:

  • skeleton screens;
  • progress indicators;
  • fallback UI;
  • lazy hydration;
  • optimistic rendering.

Пример:

showLoader()

const module =
    await import('./editor.js')

hideLoader()

module.start()

Dynamic import внутри Web Workers

Vite поддерживает динамические импорты даже внутри workers.

const worker = new Worker(
    new URL('./worker.js', import.meta.url),
    { type: 'module' }
)

Внутри worker:

const math =
    await import('./math.js')

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


Совместимость с браузерами

Динамические импорты поддерживаются современными браузерами:

  • Chrome;
  • Edge;
  • Firefox;
  • Safari.

Для старых браузеров требуется дополнительная трансформация через legacy-плагины.


Связь между dynamic import и ESM

import() является частью стандарта ECMAScript Modules.

Vite строит свою архитектуру именно вокруг ESM:

  • native imports;
  • module graph;
  • lazy loading;
  • HMR;
  • tree shaking;
  • code splitting.

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