Перейти к содержимому

21. Асинхронные компоненты

Асинхронные компоненты — это способ загружать Vue-компоненты лениво, только когда они нужны. Это фундаментальный инструмент для оптимизации размера бандла, ускорения первой загрузки и реализации code-splitting. Vue 3 предоставляет defineAsyncComponent — элегантный и мощный API для работы с ними.


При сборке SPA весь код по умолчанию попадает в один бандл. Пользователь скачивает весь JavaScript при первом посещении, даже если большая часть кода нужна только в редких сценариях.

Без code splitting:
bundle.js → 2.4MB (всё сразу)
С code splitting:
main.js → 120KB (критический путь)
dashboard.js → 380KB (только для авторизованных)
admin.js → 240KB (только для админов)
charts.js → 560KB (только на странице аналитики)

Пользователь скачивает только то, что нужно прямо сейчас.


Простейшее использование:

import { defineAsyncComponent } from 'vue'
// Минимальный вариант — просто ленивый импорт
const AsyncDashboard = defineAsyncComponent(
() => import('./components/Dashboard.vue')
)

Vite / Webpack автоматически создадут отдельный chunk для Dashboard.vue. Компонент загрузится только при первом рендеринге.

<template>
<!-- Компонент загрузится при первом рендере -->
<AsyncDashboard v-if="isLoggedIn" />
</template>

Полная конфигурация defineAsyncComponent:

import { defineAsyncComponent, h } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
const AsyncChart = defineAsyncComponent({
// Функция-загрузчик: возвращает Promise компонента
loader: () => import('./components/Chart.vue'),
// Компонент, показываемый во время загрузки
loadingComponent: LoadingSpinner,
// Задержка перед показом loading (мс)
// Предотвращает мигание при быстром соединении
delay: 200,
// Компонент при ошибке загрузки
errorComponent: ErrorDisplay,
// Таймаут: если не загрузился — показываем ошибку
timeout: 8000,
// Кастомная обработка ошибок загрузки
onError(error, retry, fail, attempts) {
if (error.message.includes('fetch') && attempts < 3) {
// Автоматическая повторная попытка
retry()
} else {
fail()
}
}
})

Компонент загрузки получает специальные пропы:

LoadingSpinner.vue
<template>
<div class="loading-wrapper">
<div class="spinner" />
<p>Загружаем компонент...</p>
</div>
</template>
<style scoped>
.spinner {
width: 40px;
height: 40px;
border: 3px solid #f0faf6;
border-top-color: #42b883;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

Компонент ошибки получает проп error:

AsyncError.vue
<template>
<div class="error-box">
<span class="icon">⚠️</span>
<h3>Не удалось загрузить компонент</h3>
<p class="message">{{ error?.message }}</p>
<button @click="$emit('retry')">Попробовать снова</button>
</div>
</template>
<script setup>
defineProps(['error'])
defineEmits(['retry'])
</script>

defineAsyncComponent отлично работает с <Suspense>. Когда async компонент загружается, он автоматически приостанавливает ближайший Suspense:

<template>
<Suspense>
<template #default>
<!-- Несколько async компонентов — все ждут вместе -->
<AsyncSidebar />
<AsyncMainContent />
<AsyncRecommendations />
</template>
<template #fallback>
<!-- Один скелетон пока все загружаются -->
<PageSkeleton />
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncSidebar = defineAsyncComponent(
() => import('./Sidebar.vue')
)
const AsyncMainContent = defineAsyncComponent(
() => import('./MainContent.vue')
)
const AsyncRecommendations = defineAsyncComponent(
() => import('./Recommendations.vue')
)
</script>

Самый распространённый паттерн — ленивая загрузка страниц через Vue Router:

router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
// Домашняя страница загружается сразу
component: () => import('./views/Home.vue')
},
{
path: '/dashboard',
// Дашборд — только для авторизованных, грузится лениво
component: () => import('./views/Dashboard.vue')
},
{
path: '/analytics',
// Аналитика — тяжёлые чарты, грузятся отдельно
component: () => import('./views/Analytics.vue')
},
{
path: '/admin',
component: () => import('./views/Admin.vue'),
children: [
{
path: 'users',
component: () => import('./views/admin/Users.vue')
},
{
path: 'settings',
component: () => import('./views/admin/Settings.vue')
}
]
}
]
})

Несколько компонентов можно объединить в один chunk:

// Все компоненты попадут в admin.chunk.js
const AdminUsers = defineAsyncComponent(
() => import(/* webpackChunkName: "admin" */ './views/admin/Users.vue')
)
const AdminSettings = defineAsyncComponent(
() => import(/* webpackChunkName: "admin" */ './views/admin/Settings.vue')
)
const AdminLogs = defineAsyncComponent(
() => import(/* webpackChunkName: "admin" */ './views/admin/Logs.vue')
)

В Vite используется rollupOptions.output.manualChunks в vite.config.ts:

vite.config.ts
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-charts': ['chart.js', 'vue-chartjs'],
'vendor-editor': ['@tiptap/vue-3', '@tiptap/starter-kit'],
'admin': ['./src/views/admin/Users.vue', './src/views/admin/Settings.vue']
}
}
}
}
}

function createRetryableAsyncComponent(importFn, maxRetries = 3) {
return defineAsyncComponent({
loader: importFn,
onError(error, retry, fail, attempts) {
const isNetworkError = !navigator.onLine ||
error.message.includes('Failed to fetch') ||
error.message.includes('ChunkLoadError')
if (isNetworkError && attempts <= maxRetries) {
console.warn("Попытка " + attempts + " из " + maxRetries + "...")
setTimeout(retry, 1000 * attempts) // экспоненциальная задержка
} else {
fail()
}
},
timeout: 10000
})
}
// Использование:
const AsyncHeavyComponent = createRetryableAsyncComponent(
() => import('./HeavyComponent.vue')
)

Чтобы компонент был готов до того, как пользователь перейдёт на страницу:

// Предзагрузка при наведении на ссылку
const prefetchDashboard = () => import('./views/Dashboard.vue')
// В шаблоне:
// <router-link to="/dashboard" @mouseenter="prefetchDashboard">
// Перейти в дашборд
// </router-link>

Или через Vue Router Navigation Guards:

router.beforeEach((to) => {
if (to.name === 'dashboard') {
// Начинаем предзагрузку при навигации
import('./heavy-deps/charts.js')
}
})

import { mount, flushPromises } from '@vue/test-utils'
import { defineAsyncComponent } from 'vue'
import { describe, it, expect, vi } from 'vitest'
describe('AsyncComponent', () => {
it('показывает loading во время загрузки', () => {
const AsyncComp = defineAsyncComponent({
loader: () => new Promise(() => {}), // никогда не резолвится
loadingComponent: { template: '<div>Loading...</div>' },
delay: 0
})
const wrapper = mount(AsyncComp)
expect(wrapper.text()).toContain('Loading...')
})
it('показывает компонент после загрузки', async () => {
const AsyncComp = defineAsyncComponent(
() => Promise.resolve({ template: '<div>Loaded!</div>' })
)
const wrapper = mount(AsyncComp)
await flushPromises()
expect(wrapper.text()).toContain('Loaded!')
})
it('показывает error при провале загрузки', async () => {
const AsyncComp = defineAsyncComponent({
loader: () => Promise.reject(new Error('Load failed')),
errorComponent: { template: '<div>Error!</div>' },
delay: 0
})
const wrapper = mount(AsyncComp)
await flushPromises()
expect(wrapper.text()).toContain('Error!')
})
})