21. Асинхронные компоненты
⚡ Урок 22 — Асинхронные компоненты в Vue 3
Заголовок раздела «⚡ Урок 22 — Асинхронные компоненты в Vue 3»Асинхронные компоненты — это способ загружать Vue-компоненты лениво, только когда они нужны. Это фундаментальный инструмент для оптимизации размера бандла, ускорения первой загрузки и реализации code-splitting. Vue 3 предоставляет defineAsyncComponent — элегантный и мощный API для работы с ними.
🎯 Зачем нужен code splitting?
Заголовок раздела «🎯 Зачем нужен code splitting?»При сборке SPA весь код по умолчанию попадает в один бандл. Пользователь скачивает весь JavaScript при первом посещении, даже если большая часть кода нужна только в редких сценариях.
Без code splitting:bundle.js → 2.4MB (всё сразу)
С code splitting:main.js → 120KB (критический путь)dashboard.js → 380KB (только для авторизованных)admin.js → 240KB (только для админов)charts.js → 560KB (только на странице аналитики)Пользователь скачивает только то, что нужно прямо сейчас.
📦 defineAsyncComponent — базовый синтаксис
Заголовок раздела «📦 defineAsyncComponent — базовый синтаксис»Простейшее использование:
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() } }})🧩 Loading Component
Заголовок раздела «🧩 Loading Component»Компонент загрузки получает специальные пропы:
<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 Component
Заголовок раздела «❌ Error Component»Компонент ошибки получает проп error:
<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>⏳ Интеграция с Suspense
Заголовок раздела «⏳ Интеграция с Suspense»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>🛣️ Code Splitting с Vue Router
Заголовок раздела «🛣️ Code Splitting с Vue Router»Самый распространённый паттерн — ленивая загрузка страниц через Vue Router:
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') } ] } ]})📦 Группировка chunks через webpackChunkName
Заголовок раздела «📦 Группировка chunks через webpackChunkName»Несколько компонентов можно объединить в один chunk:
// Все компоненты попадут в admin.chunk.jsconst 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:
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'] } } } }}🔁 Retry паттерн при сетевых ошибках
Заголовок раздела «🔁 Retry паттерн при сетевых ошибках»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'))🖥️ Предзагрузка async компонентов
Заголовок раздела «🖥️ Предзагрузка async компонентов»Чтобы компонент был готов до того, как пользователь перейдёт на страницу:
// Предзагрузка при наведении на ссылку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') }})🧪 Тестирование async компонентов
Заголовок раздела «🧪 Тестирование async компонентов»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!') })})