В стандартной модели загрузки JavaScript все модули подключаются заранее. При запуске приложения браузер получает основной bundle, внутри которого уже находятся все зависимости. Такой подход удобен для небольших проектов, но при росте кодовой базы возникают серьёзные проблемы:
Динамические импорты решают эту проблему за счёт ленивой загрузки модулей.
Вместо статического импорта:
import { createChart } from './chart.js'
используется специальная конструкция:
const module = await import('./chart.js')
В этом случае модуль не попадает в основной bundle. Vite создаёт отдельный chunk и загружает его только в момент выполнения кода.
Code splitting — механизм разделения приложения на независимые части.
Во время сборки Vite анализирует граф зависимостей и определяет:
Каждый динамический импорт автоматически становится точкой разделения кода.
Например:
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.
Поэтому доступны все стандартные механизмы асинхронности:
const module = await import('./editor.js')
import('./editor.js')
.then(module => {
module.start()
})
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)
export default function createPlayer() {
console.log('player')
}
Использование:
const module = await import('./player.js')
module.default()
Часто применяют деструктуризацию:
const { default: createPlayer } =
await import('./player.js')
createPlayer()
Динамические импорты особенно полезны для редко используемых возможностей:
Пример:
button.addEventListener('click', async () => {
const { openEditor } =
await import('./editor.js')
openEditor()
})
Редактор не загружается до нажатия кнопки.
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
Это уменьшает дублирование кода.
Один из самых распространённых сценариев — ленивые страницы.
const routes = [
{
path: '/admin',
component: () => import('./pages/Admin.vue')
}
]
const AdminPage = React.lazy(() =>
import('./AdminPage.jsx')
)
const page = await import('./pages/about.svelte')
В результате страницы загружаются только при переходе пользователя.
Главная задача 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
Старт приложения становится значительно быстрее.
Code splitting влияет на несколько ключевых метрик:
Большой bundle создаёт нагрузку не только на сеть, но и на JavaScript engine браузера.
Даже после загрузки файл должен быть:
Разделение кода уменьшает объём работы при старте приложения.
Не каждый импорт может быть полностью динамическим.
Корректно:
await import('./pages/about.js')
Проблематично:
await import(path)
Поскольку сборщик должен заранее понимать структуру зависимостей.
Vite поддерживает ограниченную динамику:
await import(`./pages/${name}.js`)
Но только если Rollup способен определить возможные файлы заранее.
Например:
pages/
├── home.js
├── about.js
└── contacts.js
Vite создаст chunks для всех возможных модулей.
Для работы с большим количеством динамических модулей Vite предоставляет специальный API.
const modules = import.meta.glob('./pages/*.js')
Результат:
{
'./pages/home.js': [Function],
'./pages/about.js': [Function],
'./pages/blog.js': [Function]
}
Каждое значение — функция ленивого импорта.
const pages = import.meta.glob('./pages/*.js')
const module =
await pages['./pages/about.js']()
module.render()
Vite автоматически создаёт отдельные chunks.
По умолчанию import.meta.glob работает лениво.
Для немедленного импорта используется:
const modules = import.meta.glob(
'./pages/*.js',
{ eager: true }
)
Теперь модули подключаются сразу.
Это аналогично обычным статическим импортам.
const modules = import.meta.glob(
'./pages/*.js',
{
import: 'setup'
}
)
Если файл содержит:
export function setup() {}
то результатом станет импорт только нужного экспорта.
const modules = import.meta.glob(
'./pages/*.js',
{
eager: true,
import: 'default'
}
)
const modules = import.meta.glob([
'./pages/*.js',
'./widgets/*.js'
])
const modules = import.meta.glob([
'./pages/*.js',
'!./pages/admin.js'
])
Code splitting не означает полное отсутствие предварительной загрузки.
Существуют стратегии:
Ресурс понадобится очень скоро.
<link rel="modulepreload" href="/assets/chunk.js">
Ресурс может понадобиться позже.
<link rel="prefetch" href="/assets/chunk.js">
Vite автоматически использует modulepreload для
оптимизации зависимостей.
Разделённые chunks хорошо подходят для долгосрочного кэширования.
Например:
main-A1.js
editor-B9.js
charts-C4.js
При изменении editor-модуля:
main-A1.js
editor-Z8.js
charts-C4.js
Кэш браузера сохранится для остальных файлов.
Иногда требуется полный контроль над структурой 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
Каждая страница становится отдельной точкой входа.
Это одна из наиболее эффективных стратегий оптимизации.
Динамический импорт может существовать внутри другого динамического импорта.
await import('./admin.js')
Внутри:
await import('./charts.js')
Получается каскадная загрузка:
main.js
→ admin.js
→ charts.js
Избыточная вложенность способна ухудшить производительность.
Плохой сценарий:
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
Для анализа результатов code splitting часто используют:
Пример подключения visualizer:
import { visualizer } from
'rollup-plugin-visualizer'
export default {
plugins: [
visualizer()
]
}
После сборки появляется интерактивная карта bundle.
Vite и Rollup продолжают выполнять tree shaking даже внутри разделённых chunks.
Если модуль экспортирует:
export function a() {}
export function b() {}
export function c() {}
а используется только:
a()
неиспользуемый код может быть удалён.
Каждый динамический импорт создаёт асинхронную границу.
Это влияет на архитектуру приложения:
Динамический импорт способен завершиться ошибкой:
Пример:
try {
const module =
await import('./editor.js')
module.start()
} catch (error) {
console.error(
'Ошибка загрузки editor.js',
error
)
}
Иногда полезно повторить загрузку:
async function loadModule(retries = 3) {
while (retries > 0) {
try {
return await import('./heavy.js')
} catch {
retries--
}
}
throw new Error('Module load failed')
}
Во время SSR динамические импорты работают иначе.
На сервере:
Во время hydration браузер уже переключается на обычную клиентскую схему загрузки.
В режиме разработки Vite использует native ESM.
Каждый файл существует отдельно:
/src/main.js
/src/admin.js
/src/chart.js
В production:
Поведение dynamic import визуально одинаковое, но внутренняя реализация различается.
Rollup автоматически генерирует имена:
index-a1b2.js
vendor-f9d2.js
Настройка:
export default {
build: {
rollupOptions: {
output: {
chunkFileNames:
'chunks/[name]-[hash].js'
}
}
}
}
Существует режим:
inlineDynamicImports: true
Он отключает code splitting и объединяет всё в один bundle.
Используется редко.
Избыточное дробление приложения создаёт проблемы:
Плохой сценарий:
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
Поскольку chunks загружаются асинхронно, интерфейс должен учитывать состояние ожидания.
Типичные решения:
Пример:
showLoader()
const module =
await import('./editor.js')
hideLoader()
module.start()
Vite поддерживает динамические импорты даже внутри workers.
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
)
Внутри worker:
const math =
await import('./math.js')
Chunks будут разделяться независимо от основного потока.
Динамические импорты поддерживаются современными браузерами:
Для старых браузеров требуется дополнительная трансформация через legacy-плагины.
import() является частью стандарта ECMAScript
Modules.
Vite строит свою архитектуру именно вокруг ESM:
Поэтому динамические импорты являются одной из фундаментальных возможностей Vite.